summaryrefslogtreecommitdiffstats
path: root/comm/chat
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/chat')
-rw-r--r--comm/chat/chat-prefs.js123
-rw-r--r--comm/chat/components/public/imIAccount.idl331
-rw-r--r--comm/chat/components/public/imIAccountsService.idl63
-rw-r--r--comm/chat/components/public/imICommandsService.idl79
-rw-r--r--comm/chat/components/public/imIContactsService.idl290
-rw-r--r--comm/chat/components/public/imIConversationsService.idl117
-rw-r--r--comm/chat/components/public/imICoreService.idl28
-rw-r--r--comm/chat/components/public/imILogger.idl86
-rw-r--r--comm/chat/components/public/imIStatusInfo.idl55
-rw-r--r--comm/chat/components/public/imITagsService.idl81
-rw-r--r--comm/chat/components/public/imIUserStatusInfo.idl55
-rw-r--r--comm/chat/components/public/moz.build25
-rw-r--r--comm/chat/components/public/prplIConversation.idl274
-rw-r--r--comm/chat/components/public/prplIMessage.idl106
-rw-r--r--comm/chat/components/public/prplIPref.idl38
-rw-r--r--comm/chat/components/public/prplIProtocol.idl148
-rw-r--r--comm/chat/components/public/prplIRequest.idl115
-rw-r--r--comm/chat/components/public/prplITooltipInfo.idl29
-rw-r--r--comm/chat/components/src/components.conf50
-rw-r--r--comm/chat/components/src/imAccounts.sys.mjs1237
-rw-r--r--comm/chat/components/src/imCommands.sys.mjs289
-rw-r--r--comm/chat/components/src/imContacts.sys.mjs1809
-rw-r--r--comm/chat/components/src/imConversations.sys.mjs951
-rw-r--r--comm/chat/components/src/imCore.sys.mjs407
-rw-r--r--comm/chat/components/src/logger.sys.mjs971
-rw-r--r--comm/chat/components/src/moz.build19
-rw-r--r--comm/chat/components/src/test/test_accounts.js48
-rw-r--r--comm/chat/components/src/test/test_commands.js271
-rw-r--r--comm/chat/components/src/test/test_conversations.js239
-rw-r--r--comm/chat/components/src/test/test_init.js28
-rw-r--r--comm/chat/components/src/test/test_logger.js860
-rw-r--r--comm/chat/components/src/test/xpcshell.ini9
-rw-r--r--comm/chat/content/chat-account-richlistitem.js354
-rw-r--r--comm/chat/content/chat-tooltip.js604
-rw-r--r--comm/chat/content/conv.html4
-rw-r--r--comm/chat/content/conversation-browser.js906
-rw-r--r--comm/chat/content/imAccountOptionsHelper.js121
-rw-r--r--comm/chat/content/jar.mn18
-rw-r--r--comm/chat/content/moz.build6
-rw-r--r--comm/chat/content/otr-add-fingerprint.js84
-rw-r--r--comm/chat/content/otr-add-fingerprint.xhtml91
-rw-r--r--comm/chat/content/otr-auth.js198
-rw-r--r--comm/chat/content/otr-auth.xhtml163
-rw-r--r--comm/chat/content/otr-finger.js159
-rw-r--r--comm/chat/content/otr-finger.xhtml74
-rw-r--r--comm/chat/content/otrWorker.js61
-rw-r--r--comm/chat/locales/Makefile.in6
-rw-r--r--comm/chat/locales/en-US/accounts.dtd33
-rw-r--r--comm/chat/locales/en-US/accounts.properties9
-rw-r--r--comm/chat/locales/en-US/commands.properties27
-rw-r--r--comm/chat/locales/en-US/contacts.properties8
-rw-r--r--comm/chat/locales/en-US/conversations.properties80
-rw-r--r--comm/chat/locales/en-US/facebook.properties6
-rw-r--r--comm/chat/locales/en-US/imtooltip.properties10
-rw-r--r--comm/chat/locales/en-US/irc.properties209
-rw-r--r--comm/chat/locales/en-US/logger.properties7
-rw-r--r--comm/chat/locales/en-US/matrix.ftl24
-rw-r--r--comm/chat/locales/en-US/matrix.properties255
-rw-r--r--comm/chat/locales/en-US/status.properties23
-rw-r--r--comm/chat/locales/en-US/twitter.properties9
-rw-r--r--comm/chat/locales/en-US/xmpp.properties274
-rw-r--r--comm/chat/locales/en-US/yahoo.properties5
-rw-r--r--comm/chat/locales/jar.mn24
-rw-r--r--comm/chat/locales/moz.build6
-rw-r--r--comm/chat/modules/CLib.sys.mjs64
-rw-r--r--comm/chat/modules/IMServices.sys.mjs50
-rw-r--r--comm/chat/modules/InteractiveBrowser.sys.mjs138
-rw-r--r--comm/chat/modules/NormalizedMap.sys.mjs48
-rw-r--r--comm/chat/modules/OTR.sys.mjs1506
-rw-r--r--comm/chat/modules/OTRLib.sys.mjs1151
-rw-r--r--comm/chat/modules/OTRUI.sys.mjs998
-rw-r--r--comm/chat/modules/ToLocaleFormat.sys.mjs208
-rw-r--r--comm/chat/modules/imContentSink.sys.mjs495
-rw-r--r--comm/chat/modules/imSmileys.sys.mjs184
-rw-r--r--comm/chat/modules/imStatusUtils.sys.mjs57
-rw-r--r--comm/chat/modules/imTextboxUtils.sys.mjs19
-rw-r--r--comm/chat/modules/imThemes.sys.mjs1333
-rw-r--r--comm/chat/modules/imXPCOMUtils.sys.mjs249
-rw-r--r--comm/chat/modules/jsProtoHelper.sys.mjs1796
-rw-r--r--comm/chat/modules/moz.build25
-rw-r--r--comm/chat/modules/socket.sys.mjs644
-rw-r--r--comm/chat/modules/test/test_InteractiveBrowser.js280
-rw-r--r--comm/chat/modules/test/test_NormalizedMap.js80
-rw-r--r--comm/chat/modules/test/test_filtering.js479
-rw-r--r--comm/chat/modules/test/test_imThemes.js342
-rw-r--r--comm/chat/modules/test/test_jsProtoHelper.js159
-rw-r--r--comm/chat/modules/test/test_otrlib.js21
-rw-r--r--comm/chat/modules/test/xpcshell.ini10
-rw-r--r--comm/chat/moz.build28
-rw-r--r--comm/chat/protocols/facebook/components.conf15
-rw-r--r--comm/chat/protocols/facebook/facebook.sys.mjs56
-rw-r--r--comm/chat/protocols/facebook/icons/prpl-facebook-32.pngbin0 -> 1193 bytes
-rw-r--r--comm/chat/protocols/facebook/icons/prpl-facebook-48.pngbin0 -> 1521 bytes
-rw-r--r--comm/chat/protocols/facebook/icons/prpl-facebook.pngbin0 -> 552 bytes
-rw-r--r--comm/chat/protocols/facebook/jar.mn9
-rw-r--r--comm/chat/protocols/facebook/moz.build14
-rw-r--r--comm/chat/protocols/gtalk/components.conf15
-rw-r--r--comm/chat/protocols/gtalk/gtalk.sys.mjs60
-rw-r--r--comm/chat/protocols/gtalk/icons/prpl-gtalk-32.pngbin0 -> 2024 bytes
-rw-r--r--comm/chat/protocols/gtalk/icons/prpl-gtalk-48.pngbin0 -> 3168 bytes
-rw-r--r--comm/chat/protocols/gtalk/icons/prpl-gtalk.pngbin0 -> 865 bytes
-rw-r--r--comm/chat/protocols/gtalk/jar.mn9
-rw-r--r--comm/chat/protocols/gtalk/moz.build14
-rw-r--r--comm/chat/protocols/irc/components.conf15
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc-32.pngbin0 -> 695 bytes
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc-48.pngbin0 -> 1003 bytes
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc.pngbin0 -> 454 bytes
-rw-r--r--comm/chat/protocols/irc/irc.sys.mjs122
-rw-r--r--comm/chat/protocols/irc/ircAccount.sys.mjs2296
-rw-r--r--comm/chat/protocols/irc/ircBase.sys.mjs1768
-rw-r--r--comm/chat/protocols/irc/ircCAP.sys.mjs170
-rw-r--r--comm/chat/protocols/irc/ircCTCP.sys.mjs291
-rw-r--r--comm/chat/protocols/irc/ircCommands.sys.mjs599
-rw-r--r--comm/chat/protocols/irc/ircDCC.sys.mjs66
-rw-r--r--comm/chat/protocols/irc/ircEchoMessage.sys.mjs41
-rw-r--r--comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs16
-rw-r--r--comm/chat/protocols/irc/ircHandlers.sys.mjs306
-rw-r--r--comm/chat/protocols/irc/ircISUPPORT.sys.mjs246
-rw-r--r--comm/chat/protocols/irc/ircMultiPrefix.sys.mjs60
-rw-r--r--comm/chat/protocols/irc/ircNonStandard.sys.mjs262
-rw-r--r--comm/chat/protocols/irc/ircSASL.sys.mjs179
-rw-r--r--comm/chat/protocols/irc/ircServerTime.sys.mjs80
-rw-r--r--comm/chat/protocols/irc/ircServices.sys.mjs317
-rw-r--r--comm/chat/protocols/irc/ircUtils.sys.mjs303
-rw-r--r--comm/chat/protocols/irc/ircWatchMonitor.sys.mjs467
-rw-r--r--comm/chat/protocols/irc/jar.mn9
-rw-r--r--comm/chat/protocols/irc/moz.build33
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpColoring.js72
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpDequote.js55
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpFormatting.js59
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpQuote.js64
-rw-r--r--comm/chat/protocols/irc/test/test_ircCAP.js236
-rw-r--r--comm/chat/protocols/irc/test/test_ircChannel.js187
-rw-r--r--comm/chat/protocols/irc/test/test_ircCommands.js218
-rw-r--r--comm/chat/protocols/irc/test/test_ircMessage.js336
-rw-r--r--comm/chat/protocols/irc/test/test_ircNonStandard.js209
-rw-r--r--comm/chat/protocols/irc/test/test_ircProtocol.js20
-rw-r--r--comm/chat/protocols/irc/test/test_ircServerTime.js130
-rw-r--r--comm/chat/protocols/irc/test/test_sendBufferedCommand.js199
-rw-r--r--comm/chat/protocols/irc/test/test_setMode.js70
-rw-r--r--comm/chat/protocols/irc/test/test_splitLongMessages.js44
-rw-r--r--comm/chat/protocols/irc/test/test_tryNewNick.js148
-rw-r--r--comm/chat/protocols/irc/test/xpcshell.ini18
-rw-r--r--comm/chat/protocols/jsTest/components.conf15
-rw-r--r--comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs145
-rw-r--r--comm/chat/protocols/jsTest/moz.build13
-rw-r--r--comm/chat/protocols/matrix/components.conf15
-rw-r--r--comm/chat/protocols/matrix/icons/README5
-rw-r--r--comm/chat/protocols/matrix/icons/prpl-matrix-32.pngbin0 -> 693 bytes
-rw-r--r--comm/chat/protocols/matrix/icons/prpl-matrix-48.pngbin0 -> 1012 bytes
-rw-r--r--comm/chat/protocols/matrix/icons/prpl-matrix.pngbin0 -> 145 bytes
-rw-r--r--comm/chat/protocols/matrix/jar.mn9
-rw-r--r--comm/chat/protocols/matrix/lib/@matrix-org/olm/LICENSE177
-rw-r--r--comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.js163
-rwxr-xr-xcomm/chat/protocols/matrix/lib/@matrix-org/olm/olm.wasmbin0 -> 153573 bytes
-rw-r--r--comm/chat/protocols/matrix/lib/README.md174
-rw-r--r--comm/chat/protocols/matrix/lib/another-json/LICENSE177
-rw-r--r--comm/chat/protocols/matrix/lib/another-json/another-json.js93
-rw-r--r--comm/chat/protocols/matrix/lib/base-x/LICENSE.md22
-rw-r--r--comm/chat/protocols/matrix/lib/base-x/index.js119
-rw-r--r--comm/chat/protocols/matrix/lib/bs58/LICENSE21
-rw-r--r--comm/chat/protocols/matrix/lib/bs58/index.js4
-rw-r--r--comm/chat/protocols/matrix/lib/content-type/LICENSE22
-rw-r--r--comm/chat/protocols/matrix/lib/content-type/index.js225
-rw-r--r--comm/chat/protocols/matrix/lib/events/LICENSE22
-rw-r--r--comm/chat/protocols/matrix/lib/events/events.js497
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/ExtensibleEvents.js189
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/IPartialEvent.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/InvalidEventError.js69
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/LICENSE201
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedMap.js149
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedValue.js166
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/EmoteEvent.js99
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/ExtensibleEvent.js60
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/MessageEvent.js214
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/NoticeEvent.js99
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollEndEvent.js138
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollResponseEvent.js198
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollStartEvent.js287
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/message_types.js74
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/poll_types.js70
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/events/relationship_types.js34
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/index.js278
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/legacy/MRoomMessage.js62
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MMessage.js40
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MPoll.js41
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/types.js49
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/MessageMatchers.js59
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/events.js51
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js101
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js1
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js68
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js126
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js240
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js121
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js72
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js63
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js93
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js33
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js35
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js30
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js27
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js63
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE177
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js123
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js89
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js133
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js429
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js58
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/client.js7660
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js266
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js74
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js105
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js46
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js703
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js860
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js342
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js1162
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js406
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js60
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js199
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js119
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js127
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js226
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js18
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js1682
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js276
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js12
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js651
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js60
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js237
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js47
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js152
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js3427
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js69
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js480
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js60
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js913
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js599
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js329
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js439
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js345
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js100
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js46
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js269
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js454
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js39
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js349
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js322
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js870
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js261
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/errors.js62
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js86
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js63
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js31
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js138
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js93
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js140
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js191
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js40
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/feature.js78
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js171
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/filter.js212
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js83
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js265
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js240
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js27
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js29
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js39
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js143
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/index.js43
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js56
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js12
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js510
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/logger.js80
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js546
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js227
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js508
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js181
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js80
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js116
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js35
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js809
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js469
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js1442
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js358
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js237
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js260
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js41
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js135
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js336
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js363
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js931
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js34
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js3079
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js58
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js649
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js200
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js211
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js676
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js44
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js179
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js169
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js240
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js29
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js36
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js27
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js194
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js16
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js82
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js176
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js16
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js133
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js93
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js78
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js117
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js124
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js31
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js25
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js121
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js54
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js574
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js314
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js431
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js27
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js861
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js795
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js569
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js200
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js151
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js329
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js43
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js418
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js262
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js474
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/sync.js1594
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js462
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/utils.js754
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js52
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js2364
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js339
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js19
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js294
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js1213
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js181
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js395
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js194
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js34
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js33
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js127
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js80
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js62
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js69
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js150
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js82
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js28
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js36
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js103
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js172
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js5
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js40
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js31
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/ClientWidgetApi.js1126
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/LICENSE201
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/Symbols.js27
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/WidgetApi.js808
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/driver/WidgetDriver.js239
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/index.js512
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ApiVersion.js45
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/Capabilities.js69
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/CapabilitiesAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ContentLoadedAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/GetOpenIDAction.js29
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ICustomWidgetData.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IJitsiWidgetData.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IRoomEvent.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IStickerpickerWidgetData.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidget.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiErrorResponse.js30
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiRequest.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiResponse.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalButtonKind.js31
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalWidgetActions.js30
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/NavigateAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/OpenIDCredentialsAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadEventAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadRelationsAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ScreenshotAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendEventAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendToDeviceAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SetModalButtonEnabledAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickerAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickyAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SupportedVersionsAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/TurnServerActions.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/UserDirectorySearchAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/VisibilityAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiAction.js59
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiDirection.js38
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetConfigAction.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetKind.js29
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetType.js29
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/models/Widget.js142
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetEventCapability.js237
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetParser.js152
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/url.js39
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/utils.js28
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/templating/url-template.js59
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/transport/ITransport.js6
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/transport/PostmessageTransport.js222
-rw-r--r--comm/chat/protocols/matrix/lib/matrix-widget-api/util/SimpleObservable.js68
-rw-r--r--comm/chat/protocols/matrix/lib/moz.build365
-rw-r--r--comm/chat/protocols/matrix/lib/p-retry/index.js85
-rw-r--r--comm/chat/protocols/matrix/lib/p-retry/license9
-rw-r--r--comm/chat/protocols/matrix/lib/retry/License21
-rw-r--r--comm/chat/protocols/matrix/lib/retry/index.js1
-rw-r--r--comm/chat/protocols/matrix/lib/retry/lib/retry.js100
-rw-r--r--comm/chat/protocols/matrix/lib/retry/lib/retry_operation.js162
-rw-r--r--comm/chat/protocols/matrix/lib/sdp-transform/LICENSE22
-rw-r--r--comm/chat/protocols/matrix/lib/sdp-transform/grammar.js494
-rw-r--r--comm/chat/protocols/matrix/lib/sdp-transform/index.js11
-rw-r--r--comm/chat/protocols/matrix/lib/sdp-transform/parser.js124
-rw-r--r--comm/chat/protocols/matrix/lib/sdp-transform/writer.js114
-rw-r--r--comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE22
-rw-r--r--comm/chat/protocols/matrix/lib/unhomoglyph/data.json6313
-rw-r--r--comm/chat/protocols/matrix/lib/unhomoglyph/index.js20
-rw-r--r--comm/chat/protocols/matrix/matrix-sdk.sys.mjs220
-rw-r--r--comm/chat/protocols/matrix/matrix.sys.mjs93
-rw-r--r--comm/chat/protocols/matrix/matrixAccount.sys.mjs3495
-rw-r--r--comm/chat/protocols/matrix/matrixCommands.sys.mjs490
-rw-r--r--comm/chat/protocols/matrix/matrixMessageContent.sys.mjs377
-rw-r--r--comm/chat/protocols/matrix/matrixPowerLevels.sys.mjs82
-rw-r--r--comm/chat/protocols/matrix/matrixTextForEvent.sys.mjs330
-rw-r--r--comm/chat/protocols/matrix/moz.build29
-rw-r--r--comm/chat/protocols/matrix/shims/empty.js16
-rw-r--r--comm/chat/protocols/matrix/shims/loglevel.js73
-rw-r--r--comm/chat/protocols/matrix/shims/moz.build14
-rw-r--r--comm/chat/protocols/matrix/shims/safe-buffer.js48
-rw-r--r--comm/chat/protocols/matrix/shims/uuid.js13
-rw-r--r--comm/chat/protocols/matrix/test/head.js291
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixAccount.js399
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixCommands.js177
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixMessage.js441
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixMessageContent.js652
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixPowerLevels.js204
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixRoom.js928
-rw-r--r--comm/chat/protocols/matrix/test/test_matrixTextForEvent.js834
-rw-r--r--comm/chat/protocols/matrix/test/test_roomTypeChange.js54
-rw-r--r--comm/chat/protocols/matrix/test/xpcshell.ini12
-rw-r--r--comm/chat/protocols/odnoklassniki/components.conf15
-rw-r--r--comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-32.pngbin0 -> 2165 bytes
-rw-r--r--comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-48.pngbin0 -> 2649 bytes
-rw-r--r--comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki.pngbin0 -> 753 bytes
-rw-r--r--comm/chat/protocols/odnoklassniki/jar.mn9
-rw-r--r--comm/chat/protocols/odnoklassniki/moz.build14
-rw-r--r--comm/chat/protocols/odnoklassniki/odnoklassniki.sys.mjs83
-rw-r--r--comm/chat/protocols/twitter/components.conf15
-rw-r--r--comm/chat/protocols/twitter/icons/prpl-twitter-32.pngbin0 -> 554 bytes
-rw-r--r--comm/chat/protocols/twitter/icons/prpl-twitter-48.pngbin0 -> 721 bytes
-rw-r--r--comm/chat/protocols/twitter/icons/prpl-twitter-left.pngbin0 -> 563 bytes
-rw-r--r--comm/chat/protocols/twitter/icons/prpl-twitter.pngbin0 -> 319 bytes
-rw-r--r--comm/chat/protocols/twitter/jar.mn10
-rw-r--r--comm/chat/protocols/twitter/moz.build14
-rw-r--r--comm/chat/protocols/twitter/twitter.sys.mjs62
-rw-r--r--comm/chat/protocols/xmpp/.eslintrc.js12
-rw-r--r--comm/chat/protocols/xmpp/components.conf15
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber-32.pngbin0 -> 1725 bytes
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber-48.pngbin0 -> 2536 bytes
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber.pngbin0 -> 768 bytes
-rw-r--r--comm/chat/protocols/xmpp/jar.mn5
-rw-r--r--comm/chat/protocols/xmpp/lib/README.md6
-rw-r--r--comm/chat/protocols/xmpp/lib/moz.build8
-rw-r--r--comm/chat/protocols/xmpp/lib/sax/LICENSE41
-rw-r--r--comm/chat/protocols/xmpp/lib/sax/sax.js1648
-rw-r--r--comm/chat/protocols/xmpp/moz.build26
-rw-r--r--comm/chat/protocols/xmpp/sax.sys.mjs7
-rw-r--r--comm/chat/protocols/xmpp/test/test_authmechs.js160
-rw-r--r--comm/chat/protocols/xmpp/test/test_dnsSrv.js112
-rw-r--r--comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js104
-rw-r--r--comm/chat/protocols/xmpp/test/test_parseVCard.js139
-rw-r--r--comm/chat/protocols/xmpp/test/test_saslPrep.js66
-rw-r--r--comm/chat/protocols/xmpp/test/test_xmppParser.js135
-rw-r--r--comm/chat/protocols/xmpp/test/test_xmppXml.js103
-rw-r--r--comm/chat/protocols/xmpp/test/xpcshell.ini11
-rw-r--r--comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs561
-rw-r--r--comm/chat/protocols/xmpp/xmpp-base.sys.mjs3421
-rw-r--r--comm/chat/protocols/xmpp/xmpp-commands.sys.mjs347
-rw-r--r--comm/chat/protocols/xmpp/xmpp-session.sys.mjs764
-rw-r--r--comm/chat/protocols/xmpp/xmpp-xml.sys.mjs508
-rw-r--r--comm/chat/protocols/xmpp/xmpp.sys.mjs106
-rw-r--r--comm/chat/protocols/yahoo/components.conf15
-rw-r--r--comm/chat/protocols/yahoo/icons/prpl-yahoo-32.pngbin0 -> 1438 bytes
-rw-r--r--comm/chat/protocols/yahoo/icons/prpl-yahoo-48.pngbin0 -> 2439 bytes
-rw-r--r--comm/chat/protocols/yahoo/icons/prpl-yahoo.pngbin0 -> 531 bytes
-rw-r--r--comm/chat/protocols/yahoo/jar.mn9
-rw-r--r--comm/chat/protocols/yahoo/moz.build14
-rw-r--r--comm/chat/protocols/yahoo/yahoo.sys.mjs60
-rw-r--r--comm/chat/themes/chat-left.svg31
-rw-r--r--comm/chat/themes/chat.svg32
-rw-r--r--comm/chat/themes/conv.css41
-rw-r--r--comm/chat/themes/icons/otr-connection-encrypted.svg7
-rw-r--r--comm/chat/themes/icons/otr-connection-finished.svg7
-rw-r--r--comm/chat/themes/icons/prpl-generic-32.pngbin0 -> 622 bytes
-rw-r--r--comm/chat/themes/icons/prpl-generic-48.pngbin0 -> 992 bytes
-rw-r--r--comm/chat/themes/icons/prpl-generic.pngbin0 -> 364 bytes
-rw-r--r--comm/chat/themes/icons/prpl-unknown-32.pngbin0 -> 1093 bytes
-rw-r--r--comm/chat/themes/icons/prpl-unknown-48.pngbin0 -> 1692 bytes
-rw-r--r--comm/chat/themes/icons/prpl-unknown.pngbin0 -> 588 bytes
-rw-r--r--comm/chat/themes/imtooltip.css31
-rw-r--r--comm/chat/themes/jar.mn23
-rw-r--r--comm/chat/themes/mobile.svg27
-rw-r--r--comm/chat/themes/moz.build6
-rw-r--r--comm/chat/themes/otrFingerprintDialog.css76
-rw-r--r--comm/chat/themes/typed.svg18
-rw-r--r--comm/chat/themes/typing.svg17
-rw-r--r--comm/chat/themes/unknown.svg15
536 files changed, 126598 insertions, 0 deletions
diff --git a/comm/chat/chat-prefs.js b/comm/chat/chat-prefs.js
new file mode 100644
index 0000000000..2d536665f8
--- /dev/null
+++ b/comm/chat/chat-prefs.js
@@ -0,0 +1,123 @@
+#filter dumbComments emptyLines substitution
+
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+// What to do when starting up
+// 0 = do not connect / show the account manager
+// 1 = connect automatically
+// Other values will be added later, for example to start minimized
+pref("messenger.startup.action", 1);
+
+// The intervals in seconds between automatic reconnection attempts.
+// The last value will be reused for the rest of the reconnection attempts.
+// A value of 0 means that there will be no more reconnection attempts.
+pref("messenger.accounts.reconnectTimer", "1,5,30,60,90,300,600,1200,3600");
+
+// Maximum number of messages in debug logs.
+// 0 = keep all messages
+pref("messenger.accounts.maxDebugMessages", 200);
+
+// List of tags ids whose contacts should be shown in the special
+// "Other contacts" group.
+pref("messenger.buddies.hiddenTags", "");
+
+// 1 prompts the user about the invite,
+// 0 ignores the invitations,
+// -1 rejects the invitations.
+pref("messenger.conversations.autoAcceptChatInvitations", 1);
+
+// Indicates whether the core should always close conversations closed
+// by the UI or if they can be put on hold instead.
+pref("messenger.conversations.alwaysClose", false);
+
+// Put conversations with contacts on hold by default (i.e. match the default
+// behavior for MUCs) as long as .alwaysClose is not true.
+pref("messenger.conversations.holdByDefault", false);
+
+pref("messenger.conversations.selections.magicCopyEnabled", true);
+pref("messenger.conversations.selections.ellipsis", "chrome://chat/locale/conversations.properties");
+pref("messenger.conversations.selections.systemMessagesTemplate", "chrome://chat/locale/conversations.properties");
+pref("messenger.conversations.selections.contentMessagesTemplate", "chrome://chat/locale/conversations.properties");
+pref("messenger.conversations.selections.actionMessagesTemplate", "chrome://chat/locale/conversations.properties");
+
+pref("messenger.conversations.textbox.autoResize", true);
+pref("messenger.conversations.textbox.defaultMaxLines", 5);
+
+// this preference changes how we filter incoming messages
+// 0 = no formattings
+// 1 = basic formattings (bold, italic, underlined)
+// 2 = permissive mode (colors, font face, font size, ...)
+pref("messenger.options.filterMode", 2);
+
+// use "none" to disable
+pref("messenger.options.emoticonsTheme", "default");
+pref("messenger.options.messagesStyle.theme", "bubbles");
+pref("messenger.options.messagesStyle.variant", "default");
+pref("messenger.options.messagesStyle.combineConsecutive", true);
+// if the time interval in seconds between two messages is longer than
+// this value, the messages will not be combined
+// default 5 minutes
+pref("messenger.options.messagesStyle.combineConsecutiveInterval", 300);
+
+pref("messenger.status.reportIdle", true);
+// default 5 minutes
+pref("messenger.status.timeBeforeIdle", 300);
+pref("messenger.status.awayWhenIdle", true);
+pref("messenger.status.defaultIdleAwayMessage", "chrome://chat/locale/status.properties");
+pref("messenger.status.userIconFileName", "");
+pref("messenger.status.userDisplayName", "");
+
+// Default message used when quitting IRC. This is overridable per account.
+pref("chat.irc.defaultQuitMessage", "");
+// If this is true, requestRooomInfo will return LIST results when it is
+// called automatically by the awesometab. Otherwise, requestRoomInfo will
+// only do so when explicitly requested by the user, e.g. via the /list command.
+pref("chat.irc.automaticList", true);
+// Whether to enable or disable message carbons protocol (XEP-0280).
+pref("chat.xmpp.messageCarbons", true);
+// Disable Facebook and Google Talk as the XMPP gateways no longer exist.
+pref("chat.prpls.prpl-facebook.disable", true);
+pref("chat.prpls.prpl-gtalk.disable", true);
+// Disable Twitter as the streaming API was shut down.
+pref("chat.prpls.prpl-twitter.disable", true);
+// Disable Yahoo Messenger as legacy Yahoo was shut down.
+pref("chat.prpls.prpl-yahoo.disable", true);
+// Whether to disable SRV lookups that use the system DNS library.
+pref("chat.dns.srv.disable", false);
+
+// Remove deleted message contents from log files
+pref("chat.logging.cleanup", true);
+pref("chat.logging.cleanup.pending", "[]");
+
+// loglevel is the minimum severity level that a libpurple message
+// must have to be reported in the Error Console.
+//
+// The possible values are:
+// 0 Show all libpurple messages (PURPLE_DEBUG_ALL)
+// 1 Very verbose (PURPLE_DEBUG_MISC)
+// 2 Verbose (PURPLE_DEBUG_INFO)
+// 3 Show warnings (PURPLE_DEBUG_WARNING)
+// 4 Show errors (PURPLE_DEBUG_ERROR)
+// 5 Show only fatal errors (PURPLE_DEBUG_FATAL)
+
+// Setting the loglevel to a value smaller than 2 will cause messages
+// with an INFO or MISC severity to be displayed as warnings so that
+// their file URL is clickable
+#ifndef DEBUG
+// By default, show only warning and errors
+pref("purple.debug.loglevel", 3);
+#else
+// On debug builds, show warning, errors and debug information.
+pref("purple.debug.loglevel", 2);
+#endif
+
+pref("purple.logging.log_chats", true);
+pref("purple.logging.log_ims", true);
+
+// Send typing notification in private conversations.
+pref("purple.conversations.im.send_typing", true);
+
+// Send read receipts in conversations.
+pref("purple.conversations.im.send_read", true);
diff --git a/comm/chat/components/public/imIAccount.idl b/comm/chat/components/public/imIAccount.idl
new file mode 100644
index 0000000000..0fcf210d1c
--- /dev/null
+++ b/comm/chat/components/public/imIAccount.idl
@@ -0,0 +1,331 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "prplIConversation.idl"
+#include "imIUserStatusInfo.idl"
+
+interface imITag;
+interface imIBuddy;
+interface prplIAccountBuddy;
+interface imIAccount;
+interface prplIAccount;
+interface prplIProtocol;
+interface nsIScriptError;
+interface nsITransportSecurityInfo;
+
+/*
+ * Used to join chat rooms.
+ */
+
+[scriptable, uuid(7e91accd-f04c-4787-9954-c7db4fb235fb)]
+interface prplIChatRoomFieldValues: nsISupports {
+ AUTF8String getValue(in AUTF8String aIdentifier);
+ void setValue(in AUTF8String aIdentifier, in AUTF8String aValue);
+};
+
+[scriptable, uuid(19dff981-b125-4a70-bc1a-efc783d07137)]
+interface prplIChatRoomField: nsISupports {
+ readonly attribute AUTF8String label;
+ readonly attribute AUTF8String identifier;
+ readonly attribute boolean required;
+
+ const short TYPE_TEXT = 0;
+ const short TYPE_PASSWORD = 1;
+ const short TYPE_INT = 2;
+
+ readonly attribute short type;
+ readonly attribute long min;
+ readonly attribute long max;
+};
+
+/*
+ * Information about a chat room and the fields required to join it.
+ */
+[scriptable, uuid(017d5951-fdd0-4f26-b697-fcc138cd2861)]
+interface prplIRoomInfo: nsISupports {
+ readonly attribute AUTF8String name;
+ readonly attribute AUTF8String topic;
+
+ const long NO_PARTICIPANT_COUNT = -1;
+
+ readonly attribute long participantCount;
+ readonly attribute prplIChatRoomFieldValues chatRoomFieldValues;
+};
+
+/*
+ * Callback passed to an account's requestRoomInfo function.
+ */
+[scriptable, function, uuid(43102a36-883a-421d-a6ac-126aafee5a28)]
+interface prplIRoomInfoCallback: nsISupports {
+ /* aRooms is an array of chatroom names. This will be called
+ * multiple times as batches of chat rooms are received. The number of rooms
+ * in each batch is left for the prplIAccount implementation to decide.
+ * aCompleted will be true when aRooms is the last batch.
+ */
+ void onRoomInfoAvailable(in Array<AString> aRooms, in boolean aCompleted);
+};
+
+/**
+ * Encryption session of the prplIAccount. Usually every logged in device that
+ * can encrypt will have its own session.
+ */
+[scriptable, uuid(0254d011-44b3-40a1-8589-d2fd4a18a421)]
+interface prplISession: nsISupports {
+ /** ID of this session as displayed to the user. */
+ readonly attribute AUTF8String id;
+ /** Whether this session is trusted. */
+ readonly attribute boolean trusted;
+ /** Indicates that this is the session we're currently using */
+ readonly attribute boolean currentSession;
+ /**
+ * Verify the identity of this session.
+ *
+ * @returns {Promise<imISessionVerification>}
+ */
+ Promise verify();
+};
+
+/*
+ * This interface should be implemented by the protocol plugin.
+ */
+[scriptable, uuid(3ce02a3c-f38b-4a1e-9050-a19bea1cb6c1)]
+interface prplIAccount: nsISupports {
+ readonly attribute imIAccount imAccount;
+
+ // observe should only be called by the imIAccount
+ // implementation to report user status changes that affect this account.
+ void observe(in nsISupports aObj, in string aEvent,
+ [optional] in wstring aData);
+
+ // This should only be called by the imIAccountsService
+ // implementation, never directly. It will call
+ // imIContactsService.accountBuddyRemoved on each buddy of the
+ // account and close all prplIConversation instances of the account.
+ void remove();
+
+ /* Uninitialize the prplIAccount instance. This is typically done
+ automatically at shutdown (by the core service) or as part of
+ the 'remove' method. */
+ void unInit();
+
+ void connect();
+ void disconnect();
+
+ prplIConversation createConversation(in AUTF8String aName);
+
+ // Used when the user wants to add a buddy to the buddy list
+ void addBuddy(in imITag aTag, in AUTF8String aName);
+
+ // Used while loading the buddy list at startup.
+ prplIAccountBuddy loadBuddy(in imIBuddy aBuddy, in imITag aTag);
+
+ /* Request more info on a buddy (typically a chat buddy).
+ * The result (if any) will be provided by user-info-received
+ * notifications dispatched through the observer service:
+ * - aSubject will be an nsISimpleEnumerator of prplITooltipInfo.
+ * - aData will be aBuddyName.
+ * If multiple user-info-received are sent, subsequent notifications
+ * will update any previous data.
+ */
+ void requestBuddyInfo(in AUTF8String aBuddyName);
+
+ readonly attribute boolean canJoinChat;
+ Array<prplIChatRoomField> getChatRoomFields();
+ prplIChatRoomFieldValues getChatRoomDefaultFieldValues([optional] in AUTF8String aDefaultChatName);
+
+ /* Request information on available chat rooms, whose names are returned
+ * via the callback.
+ */
+ void requestRoomInfo(in prplIRoomInfoCallback aCallback);
+ prplIRoomInfo getRoomInfo(in AUTF8String aRoomName);
+ readonly attribute boolean isRoomInfoStale;
+
+ /*
+ * Create a new chat conversation if it doesn't already exist.
+ */
+ void joinChat(in prplIChatRoomFieldValues aComponents);
+
+ // A name that can be used to check for duplicates and is the basis
+ // for the directory name for log storage.
+ readonly attribute AUTF8String normalizedName;
+ // Request that the account normalizes a name. Use this only when an object
+ // providing a normalizedName doesn't exist yet or isn't accessible.
+ AUTF8String normalize(in AUTF8String aName);
+
+ // protocol specific options: those functions set the protocol
+ // specific options for the prplIAccount
+ void setBool(in string aName, in boolean aVal);
+ void setInt(in string aName, in long aVal);
+ void setString(in string aName, in AUTF8String aVal);
+
+ /* When a connection error occurred, this value indicates the type of error */
+ readonly attribute short connectionErrorReason;
+
+ /**
+ * When a certificate error occurs, the host/port that caused a
+ * SSL/certificate error when connecting to it. This is only valid when
+ * connectionErrorReason is one of ERROR_CERT_*
+ */
+ readonly attribute AUTF8String connectionTarget;
+ /**
+ * When a certificate error occurs, the nsITransportSecurityInfo error of
+ * the socket. This should only be set when connectionTarget is set.
+ */
+ readonly attribute nsITransportSecurityInfo securityInfo;
+
+ /* Possible connection error reasons:
+ ERROR_NETWORK_ERROR and ERROR_ENCRYPTION_ERROR are not fatal and
+ should enable the automatic reconnection feature. */
+ const short NO_ERROR = -1;
+ const short ERROR_NETWORK_ERROR = 0;
+ const short ERROR_INVALID_USERNAME = 1;
+ const short ERROR_AUTHENTICATION_FAILED = 2;
+ const short ERROR_AUTHENTICATION_IMPOSSIBLE = 3;
+ const short ERROR_NO_SSL_SUPPORT = 4;
+ const short ERROR_ENCRYPTION_ERROR = 5;
+ const short ERROR_NAME_IN_USE = 6;
+ const short ERROR_INVALID_SETTINGS = 7;
+ const short ERROR_CERT_NOT_PROVIDED = 8;
+ const short ERROR_CERT_UNTRUSTED = 9;
+ const short ERROR_CERT_EXPIRED = 10;
+ const short ERROR_CERT_NOT_ACTIVATED = 11;
+ const short ERROR_CERT_HOSTNAME_MISMATCH = 12;
+ const short ERROR_CERT_FINGERPRINT_MISMATCH = 13;
+ const short ERROR_CERT_SELF_SIGNED = 14;
+ const short ERROR_CERT_OTHER_ERROR = 15;
+ const short ERROR_OTHER_ERROR = 16;
+
+ /**
+ * Get a list of active encryption sessions for the account.
+ * The protocol sends a "account-sessions-changed" notification when
+ * the trust state of a session changes, or entries are added or removed.
+ */
+ Array<prplISession> getSessions();
+
+ /**
+ * Information as to the state of encryption capabilities of this account. For
+ * example Matrix surfaces the secret storage, key backup and cross-signing
+ * status info here.
+ * The protocol sends a "account-encryption-status-changed" notification when
+ * this chanes.
+ */
+ readonly attribute Array<AUTF8String> encryptionStatus;
+};
+
+
+[scriptable, uuid(488959b4-992e-4626-ae96-beaf6adc4a77)]
+interface imIDebugMessage: nsISupports {
+ const short LEVEL_DEBUG = 1;
+ const short LEVEL_LOG = 2;
+ const short LEVEL_WARNING = 3;
+ const short LEVEL_ERROR = 4;
+ readonly attribute short logLevel; // One of the above constants.
+ readonly attribute nsIScriptError message;
+};
+
+/* This interface should be implemented by the im core. It inherits
+from prplIAccount and in most cases will forward the calls for the
+inherited members to a prplIAccount account instance implemented by
+the protocol plugin. */
+[scriptable, uuid(20a85b44-e220-4f23-85bf-f8523d1a2b08)]
+interface imIAccount: prplIAccount {
+ /* Check if autologin is enabled for this account, connect it now. */
+ void checkAutoLogin();
+
+ /* Cancel the timer that automatically reconnects the account if it was
+ disconnected because of a non fatal error. */
+ void cancelReconnection();
+
+ readonly attribute AUTF8String name;
+ readonly attribute AUTF8String id;
+ readonly attribute unsigned long numericId;
+ readonly attribute prplIProtocol protocol;
+ readonly attribute prplIAccount prplAccount;
+
+ // Save account specific preferences to disk.
+ void save();
+
+ attribute boolean autoLogin;
+
+ /* This is the value when the preference firstConnectionState is not set.
+ It indicates that the account has already been successfully connected at
+ least once with the current parameters. */
+ const short FIRST_CONNECTION_OK = 0;
+ /* Set when the account has never had a successful connection
+ with the current parameters */
+ const short FIRST_CONNECTION_UNKNOWN = 1;
+ /* Set when the account is trying to connect for the first time
+ with the current parameters (removed after a successsful connection) */
+ const short FIRST_CONNECTION_PENDING = 2;
+ /* Set at startup when the previous state was pending */
+ const short FIRST_CONNECTION_CRASHED = 4;
+
+ attribute short firstConnectionState;
+
+ /* Passwords are stored in the toolkit Password Manager.
+ * Warning: Don't attempt to access passwords during startup before
+ * Services.login.initializationPromise has resolved.
+ */
+ attribute AUTF8String password;
+
+ attribute AUTF8String alias;
+
+ /* While an account is connecting, this attribute contains a message
+ indicating the current step of the connection */
+ readonly attribute AUTF8String connectionStateMsg;
+
+ /* Number of the reconnection attempt
+ * 0 means that no automatic reconnection currently pending
+ * n means the nth reconnection attempt is pending
+ */
+ readonly attribute unsigned short reconnectAttempt;
+
+ /* Time stamp of the next reconnection attempt */
+ readonly attribute long long timeOfNextReconnect;
+
+ /* Time stamp of the last connection (value not reliable if not connected) */
+ readonly attribute long long timeOfLastConnect;
+
+ /* Additional possible connection error reasons:
+ * (Use a big enough number that it can't conflict with error
+ * codes used in prplIAccount).
+ */
+ const short ERROR_UNKNOWN_PRPL = 42;
+ const short ERROR_CRASHED = 43;
+ const short ERROR_MISSING_PASSWORD = 44;
+
+ /* A message describing the connection error */
+ readonly attribute AUTF8String connectionErrorMessage;
+
+ /* Info about the connection state and flags */
+ const short STATE_DISCONNECTED = 0;
+ const short STATE_CONNECTED = 1;
+ const short STATE_CONNECTING = 2;
+ const short STATE_DISCONNECTING = 3;
+
+ readonly attribute short connectionState;
+
+ /* The following 4 properties use the above connectionState value. */
+ readonly attribute boolean disconnected;
+ readonly attribute boolean connected;
+ readonly attribute boolean connecting;
+ readonly attribute boolean disconnecting;
+
+ void logDebugMessage(in nsIScriptError aMessage, in short aLevel);
+
+ /* Get an array of the 50 most recent debug messages. */
+ Array<imIDebugMessage> getDebugMessages();
+
+ /* The imIUserStatusInfo instance this account should observe for
+ status changes. When this is null (the default value), the
+ account will observe the global status. */
+ attribute imIUserStatusInfo observedStatusInfo;
+ // Same as above, but never null (it fallbacks to the global status info).
+ attribute imIUserStatusInfo statusInfo;
+
+ // imIAccount also implements an observe method but this
+ // observe should only be called by the prplIAccount
+ // implementations to report connection status changes.
+};
diff --git a/comm/chat/components/public/imIAccountsService.idl b/comm/chat/components/public/imIAccountsService.idl
new file mode 100644
index 0000000000..38a2d52a12
--- /dev/null
+++ b/comm/chat/components/public/imIAccountsService.idl
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "imIAccount.idl"
+
+[scriptable, uuid(b3b6459a-5c26-47b8-8e9c-ba838b6f632a)]
+interface imIAccountsService: nsISupports {
+ void initAccounts();
+ void unInitAccounts();
+
+ /* This attribute is set to AUTOLOGIN_ENABLED by default. It can be set to
+ any other value before the initialization of this service to prevent
+ accounts with autoLogin enabled from being connected when libpurple is
+ initialized.
+ Any value other than the ones listed below will disable autoLogin and
+ display a generic message in the Account Manager. */
+ attribute short autoLoginStatus;
+
+ const short AUTOLOGIN_ENABLED = 0;
+ const short AUTOLOGIN_USER_DISABLED = 1;
+ const short AUTOLOGIN_SAFE_MODE = 2;
+ const short AUTOLOGIN_CRASH = 3;
+ const short AUTOLOGIN_START_OFFLINE = 4;
+
+ /* The method should be used to connect all accounts with autoLogin enabled.
+ Some use cases:
+ - if the autologin was disabled at startup
+ - after a loss of internet connectivity that disconnected all accounts.
+ */
+ void processAutoLogin();
+
+ imIAccount getAccountById(in AUTF8String aAccountId);
+
+ /* will throw NS_ERROR_FAILURE if not found */
+ imIAccount getAccountByNumericId(in unsigned long aAccountId);
+
+ Array<imIAccount> getAccounts();
+
+ /* will fire the event account-added */
+ imIAccount createAccount(in AUTF8String aName, in AUTF8String aPrpl);
+
+ /* will fire the event account-removed */
+ void deleteAccount(in AUTF8String aAccountId);
+};
+
+/*
+ account related notifications sent to nsIObserverService:
+ - account-added: a new account has been created
+ - account-removed: the account has been deleted
+ - account-connecting: the account is being connected
+ - account-connected: the account is now connected
+ - account-connect-error: the account is disconnect with an error.
+ (before account-disconnecting)
+ - account-disconnecting: the account is being disconnected
+ - account-disconnected: the account is now disconnected
+ - account-updated: when some settings have changed
+ - account-list-updated: when the list of account is reordered.
+ These events can be watched using an nsIObserver.
+ The associated imIAccount will be given as a parameter
+ (except for account-list-updated).
+*/
diff --git a/comm/chat/components/public/imICommandsService.idl b/comm/chat/components/public/imICommandsService.idl
new file mode 100644
index 0000000000..9011a673b0
--- /dev/null
+++ b/comm/chat/components/public/imICommandsService.idl
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+interface prplIConversation;
+
+[scriptable, uuid(b12b0d89-0e5b-499c-9567-37f2deacc182)]
+interface imICommand: nsISupports {
+ readonly attribute AUTF8String name;
+
+ // Help message displayed when the user types /help <name>.
+ // Format: <command name> <parameters>: <help message>
+ // Example: "help &lt;name&gt;: show the help message for the &lt;name&gt;
+ // command, or the list of possible commands when used without
+ // parameter."
+ readonly attribute AUTF8String helpString;
+
+ const short CMD_CONTEXT_IM = 1;
+ const short CMD_CONTEXT_CHAT = 2;
+ const short CMD_CONTEXT_ALL = CMD_CONTEXT_IM | CMD_CONTEXT_CHAT;
+ readonly attribute long usageContext;
+
+ const short CMD_PRIORITY_LOW = -1000;
+ const short CMD_PRIORITY_DEFAULT = 0;
+ const short CMD_PRIORITY_PRPL = 1000;
+ const short CMD_PRIORITY_HIGH = 4000;
+ // Any integer value is usable as a priority.
+ // 0 is the default priority.
+ // < 0 is lower priority.
+ // > 0 is higher priority.
+ // Commands registered by protocol plugins will usually use PRIORITY_PRPL.
+ readonly attribute long priority;
+
+ // Will return true if the command handled the message (it should not be sent).
+ // The leading slash, the command name and the following space are not included
+ // in the aMessage parameter.
+ // If a conversation is returned as a result of executing the command,
+ // the caller should consider focusing it.
+ boolean run(in AUTF8String aMessage,
+ [optional] in prplIConversation aConversation,
+ [optional] out prplIConversation aReturnedConv);
+};
+
+[scriptable, uuid(9a1accfd-9bd8-4548-aef7-e8107fc7839f)]
+interface imICommandsService: nsISupports {
+ void initCommands();
+ void unInitCommands();
+
+ // Commands registered without a protocol id will work for all protocols.
+ // Registering several commands of the same name with the same
+ // protocol id or no protocol id will replace the former command
+ // with the latter.
+ void registerCommand(in imICommand aCommand,
+ [optional] in AUTF8String aPrplId);
+
+ // aPrplId should be the same as what was used for the command registration.
+ void unregisterCommand(in AUTF8String aCommandName,
+ [optional] in AUTF8String aPrplId);
+
+ Array<imICommand> listCommandsForConversation(
+ [optional] in prplIConversation aConversation);
+
+ Array<imICommand> listCommandsForProtocol(in AUTF8String aPrplId);
+
+ // Will return true if a command handled the message (it should not be sent).
+ // The aConversation parameters is required to execute protocol specific
+ // commands. Application global commands will work without it.
+ // If a conversation is returned as a result of executing the command,
+ // the caller should consider focusing it.
+ boolean executeCommand(in AUTF8String aMessage,
+ [optional] in prplIConversation aConversation,
+ [optional] out prplIConversation aReturnedConv);
+};
+
+%{ C++
+#define IM_COMMANDS_SERVICE_CONTRACTID \
+ "@mozilla.org/chat/commands-service;1"
+%}
diff --git a/comm/chat/components/public/imIContactsService.idl b/comm/chat/components/public/imIContactsService.idl
new file mode 100644
index 0000000000..d0f42dbac0
--- /dev/null
+++ b/comm/chat/components/public/imIContactsService.idl
@@ -0,0 +1,290 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "imIStatusInfo.idl"
+#include "imITagsService.idl"
+#include "nsISupports.idl"
+#include "nsIObserver.idl"
+
+interface imIContact;
+interface imIBuddy;
+interface prplIAccountBuddy;
+interface imIAccount;
+interface prplIProtocol;
+
+[scriptable, uuid(45ce33d9-d335-4cce-b904-44821987e048)]
+interface imIContactsService: nsISupports {
+ void initContacts();
+ void unInitContacts();
+
+ imIContact getContactById(in long aId);
+ // Get an array of all existing contacts.
+ Array<imIContact> getContacts();
+ imIBuddy getBuddyById(in long aId);
+ imIBuddy getBuddyByNameAndProtocol(in AUTF8String aNormalizedName,
+ in prplIProtocol aPrpl);
+ prplIAccountBuddy getAccountBuddyByNameAndAccount(in AUTF8String aNormalizedName,
+ in imIAccount aAccount);
+
+ // These 3 functions are called by the protocol plugins when
+ // synchronizing the buddy list with the server stored list,
+ // or after user operations have been performed.
+ void accountBuddyAdded(in prplIAccountBuddy aAccountBuddy);
+ void accountBuddyRemoved(in prplIAccountBuddy aAccountBuddy);
+ void accountBuddyMoved(in prplIAccountBuddy aAccountBuddy,
+ in imITag aOldTag, in imITag aNewTag);
+
+ // These methods are called by the imIAccountsService implementation
+ // to keep the accounts table in sync with accounts stored in the
+ // preferences.
+
+ // Called when an account is created or loaded to store the new
+ // account or ensure it doesn't conflict with an existing account
+ // (to detect database corruption).
+ // Will throw if a stored account has the id aId but a different
+ // username or prplId.
+ void storeAccount(in unsigned long aId, in AUTF8String aUserName,
+ in AUTF8String aPrplId);
+ // Check if an account id already exists in the database.
+ boolean accountIdExists(in unsigned long aId);
+ // Called when deleting an account to remove it from blist.sqlite.
+ void forgetAccount(in unsigned long aId);
+};
+
+/**
+ * An imIContact represents a person, e.g. our friend Alice. This person might
+ * have multiple means of contacting them.
+ *
+ * Remember that an imIContact can have multiple buddies (imIBuddy instances),
+ * each imIBuddy can have multiple account-buddies (prplIAccountBuddy instances)
+ * referencing it. To be explicit, the difference is that an imIBuddy represents
+ * a contact's account on a network, while a prplIAccountBuddy represents the
+ * link between your account and your contact's account.
+ *
+ * Each of these implement imIStatusInfo: imIContact and imIBuddy should merge
+ * the status info based on the information available in their instances of
+ * imIBuddy and prplIAccountBuddy, respectively.
+ */
+[scriptable, uuid(f585b0df-f6ad-40d5-9de4-c58b14af13e4)]
+interface imIContact: imIStatusInfo {
+ // The id will be positive if the contact is real (stored in the
+ // SQLite database) and negative if the instance is a dummy contact
+ // holding only a single buddy without aliases or additional tags.
+ readonly attribute long id;
+ attribute AUTF8String alias;
+
+ Array<imITag> getTags();
+
+ // Will do nothing if the contact already has aTag.
+ void addTag(in imITag aTag);
+ // Will throw if the contact doesn't have aTag or doesn't have any other tag.
+ void removeTag(in imITag aTag);
+
+ readonly attribute imIBuddy preferredBuddy;
+ Array<imIBuddy> getBuddies();
+
+ // Move all the buddies of aContact into the current contact,
+ // and copy all its tags.
+ void mergeContact(in imIContact aContact);
+
+ // Change the position of aBuddy in the current contact.
+ // The new position is the current position of aBeforeBuddy if it is
+ // specified, or at the end otherwise.
+ void moveBuddyBefore(in imIBuddy aBuddy, [optional] in imIBuddy aBeforeBuddy);
+
+ // Remove aBuddy from its current contact and append it to the list
+ // of buddies of the current contact.
+ // aBuddy should not already be attached to the current contact.
+ void adoptBuddy(in imIBuddy aBuddy);
+
+ // Returns a new contact that contains only aBuddy, and has the same
+ // list of tags.
+ // Will throw if aBuddy is not a buddy of the contact.
+ imIContact detachBuddy(in imIBuddy aBuddy);
+
+ // remove the contact from the buddy list. Will also remove the
+ // associated buddies.
+ void remove();
+
+ void addObserver(in nsIObserver aObserver);
+ void removeObserver(in nsIObserver aObserver);
+ /* Observers will be notified of changes related to the contact.
+ * aSubject will point to the imIContact object
+ * (with some exceptions for contact-moved-* notifications).
+ *
+ * Fired notifications:
+ * contact-availability-changed
+ * when either statusType or availabilityDetails has changed.
+ * contact-signed-on
+ * contact-signed-off
+ * contact-status-changed
+ * when either statusType or statusText has changed.
+ * contact-display-name-changed
+ * when the alias (or serverAlias of the most available buddy if
+ * no alias is set) has changed.
+ * The old display name is provided in aData.
+ * contact-preferred-buddy-changed
+ * The buddy that would be favored to start a conversation has changed.
+ * contact-moved, contact-moved-in, contact-moved-out
+ * contact-moved is notified through the observer service
+ * contact-moved-in is notified to
+ * - the contact observers (aSubject is the new tag)
+ * - the new tag (aSubject is the contact instance)
+ * contact-moved-out is notified to
+ * - the contact observers (aSubject is the old tag)
+ * - the old tag (aSubject is the contact instance)
+ * contact-no-longer-dummy
+ * When a real contact is created to replace a dummy contact.
+ * The old (negative) id will be given in aData.
+ * See also the comment above the 'id' attribute.
+ * contact-icon-changed
+ *
+ * Observers will also receive all the (forwarded) notifications
+ * from the linked buddies (imIBuddy instances) and their account
+ * buddies (prplIAccountBuddy instances).
+ */
+
+ // Exposed for add-on authors. All internal calls will come from the
+ // imIContact implementation itself so it wasn't required to expose this.
+ // This can be used to dispatch custom notifications to the
+ // observers of the contact and its tags.
+ // The notification will also be forwarded to the observer service.
+ void notifyObservers(in nsISupports aObj, in string aEvent,
+ [optional] in wstring aData);
+};
+
+/**
+ * An imIBuddy represents a person's account on a particular network. Note that
+ * what a network is depends on the implementation of the prpl, e.g. for AIM
+ * there is only a single network, but both GTalk and XMPP are the same network.
+ *
+ * E.g. Our contact Alice has two accounts on the Foo network: @lic4 and
+ * alice88; and she has a single account on the Bar network: _alice_. This would
+ * result in an imIBuddy instance for each of these: @lic4, alice88, and _alice_
+ * that would all exist as part of the same imIContact.
+ */
+[scriptable, uuid(c56520ba-d923-4b95-8416-ca6733c4a38e)]
+interface imIBuddy: imIStatusInfo {
+ readonly attribute long id;
+ readonly attribute prplIProtocol protocol;
+ readonly attribute AUTF8String userName; // may be formatted
+ // A name that can be used to check for duplicates and is the basis
+ // for the directory name for log storage.
+ readonly attribute AUTF8String normalizedName;
+ // The optional server alias is in displayName (inherited from imIStatusInfo)
+ // displayName = serverAlias || userName.
+
+ readonly attribute imIContact contact;
+ readonly attribute prplIAccountBuddy preferredAccountBuddy;
+ Array<prplIAccountBuddy> getAccountBuddies();
+
+ // remove the buddy from the buddy list. If the contact becomes empty, it will be removed too.
+ void remove();
+
+ void addObserver(in nsIObserver aObserver);
+ void removeObserver(in nsIObserver aObserver);
+ /* Observers will be notified of changes related to the buddy.
+ * aSubject will point to the imIBuddy object.
+ * Fired notifications:
+ * buddy-availability-changed
+ * when either statusType or availabilityDetails has changed.
+ * buddy-signed-on
+ * buddy-signed-off
+ * buddy-status-changed
+ * when either statusType or statusText has changed.
+ * buddy-display-name-changed
+ * when the serverAlias has changed.
+ * The old display name is provided in aData.
+ * buddy-preferred-account-changed
+ * The account that would be favored to start a conversation has changed.
+ * buddy-icon-changed
+ *
+ * Observers will also receive all the (forwarded) notifications
+ * from the linked account buddies (prplIAccountBuddy instances).
+ */
+
+ // Exposed for add-on authors. All internal calls will come from the
+ // imIBuddy implementation itself so it wasn't required to expose this.
+ // This can be used to dispatch custom notifications to the
+ // observers of the buddy, its contact and its tags.
+ // The contact will forward the notifications to the observer service.
+ void notifyObservers(in nsISupports aObj, in string aEvent,
+ [optional] in wstring aData);
+
+ // observe should only be called by the prplIAccountBuddy
+ // implementations to report changes.
+ void observe(in nsISupports aObj, in string aEvent,
+ [optional] in wstring aData);
+};
+
+/**
+ * A prplIAccountBuddy represents the connection on a network between one of the
+ * current user's accounts and a persons's account. E.g. if we're logged into
+ * the Foo network as BobbyBoy91 and want to talk to Alice, there may be two
+ * prplIAccountBuddy instances: @lic4 as seen by BobbyBoy91 or alice88 as seen
+ * by BobbyBoy91. Additionally, if we also login as 8ob, there could be @lic4 as
+ * seen by 8ob and alice88 as seen by 8ob; but these (now four)
+ * prplIAccountBuddy instances would link to only TWO imIBuddy instances (one
+ * each for @lic4 and alice88). Note that the above uses "may be" and "could"
+ * because it depends on whether the contacts are on the contact list (and
+ * therefore have imIContact / imIBuddy instances).
+ *
+ * prplIAccountBuddy implementations send notifications to their buddy:
+ *
+ * For all of them, aSubject points to the prplIAccountBuddy object.
+ *
+ * Supported notifications:
+ * account-buddy-availability-changed
+ * when either statusType or availabilityDetails has changed.
+ * account-buddy-signed-on
+ * account-buddy-signed-off
+ * account-buddy-status-changed
+ * when either statusType or statusText has changed.
+ * account-buddy-display-name-changed
+ * when the serverAlias has changed.
+ * The old display name is provided in aData.
+ * account-buddy-icon-changed
+ *
+ * All notifications (even unsupported ones) will be forwarded to the contact,
+ * its tags and nsObserverService.
+ */
+[scriptable, uuid(0c5021ac-7acd-4118-bf4f-c0dd9cb3ddef)]
+interface prplIAccountBuddy: imIStatusInfo {
+ // The setter is for internal use only. buddy will be set by the
+ // Contacts service when accountBuddyAdded is called on this
+ // instance of prplIAccountBuddy.
+ attribute imIBuddy buddy;
+ readonly attribute imIAccount account;
+ // Setting the tag will move the buddy to a different group on the
+ // server-stored buddy list.
+ attribute imITag tag;
+ readonly attribute AUTF8String userName;
+ // A name that can be used to check for duplicates and is the basis
+ // for the directory name for log storage.
+ readonly attribute AUTF8String normalizedName;
+ attribute AUTF8String serverAlias;
+
+ /** Whether we can verify the identity of this buddy. */
+ readonly attribute boolean canVerifyIdentity;
+
+ /**
+ * True if we trust the encryption with this buddy in E2EE conversations. Can
+ * only be true if |canVerifyIdentity| is true.
+ */
+ readonly attribute boolean identityVerified;
+
+ /**
+ * Initialize identity verification with this buddy.
+ * @returns {Promise<imISessionVerification>}
+ */
+ Promise verifyIdentity();
+
+ // remove the buddy from the buddy list of this account.
+ void remove();
+
+ // Called by the contacts service during its uninitialization to
+ // notify that all references kept to imIBuddy or imIAccount
+ // instances should be released now.
+ void unInit();
+};
diff --git a/comm/chat/components/public/imIConversationsService.idl b/comm/chat/components/public/imIConversationsService.idl
new file mode 100644
index 0000000000..67affbdfcb
--- /dev/null
+++ b/comm/chat/components/public/imIConversationsService.idl
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "prplIConversation.idl"
+#include "prplIMessage.idl"
+#include "imIContactsService.idl"
+
+interface imIMessage;
+
+[scriptable, uuid(81b8d9a9-4715-4109-b522-84b9d31493a3)]
+interface imIConversation: prplIConversation {
+ // Will be null for MUCs and IMs from people not in the contacts list.
+ readonly attribute imIContact contact;
+
+ // Write a system message into the conversation.
+ // Note: this will not be logged.
+ void systemMessage(in AUTF8String aMessage,
+ [optional] in boolean aIsError,
+ [optional] in boolean aNoCollapse);
+
+ // Write a system message into the conversation and trigger the update of the
+ // notification counter during an off-the-record authentication request.
+ // Note: this will not be logged.
+ void notifyVerifyOTR(in AUTF8String aMessage);
+
+ attribute prplIConversation target;
+
+ // Number of unread messages (all messages, including system
+ // messages are counted).
+ readonly attribute unsigned long unreadMessageCount;
+ // Number of unread incoming messages targeted at the user (= IMs or
+ // message containing the user's nick in MUCs).
+ readonly attribute unsigned long unreadTargetedMessageCount;
+ // Number of unread incoming messages (both targeted and untargeted
+ // messages are counted).
+ readonly attribute unsigned long unreadIncomingMessageCount;
+ // Number of unread off-the-record authentication requests.
+ readonly attribute unsigned long unreadOTRNotificationCount;
+ // Reset all unread message counts.
+ void markAsRead();
+
+ // Can be used instead of the topic when no topic is set.
+ readonly attribute AUTF8String noTopicString;
+
+ // Call this to give the core an opportunity to close an inactive
+ // conversation. If the conversation is a left MUC or an IM
+ // conversation without unread message, the implementation will call
+ // close().
+ // The returned value indicates if the conversation was closed.
+ boolean checkClose();
+
+ // Get an array of all messages of the conversation.
+ Array<imIMessage> getMessages();
+};
+
+[scriptable, uuid(984e182c-d395-4fba-ba6e-cc80c71f57bf)]
+interface imIConversationsService: nsISupports {
+ void initConversations();
+ void unInitConversations();
+
+ // Register a conversation. This will create a unique id for the
+ // conversation and set it.
+ void addConversation(in prplIConversation aConversation);
+ void removeConversation(in prplIConversation aConversation);
+
+ Array<imIConversation> getUIConversations();
+ imIConversation getUIConversation(in prplIConversation aConversation);
+ imIConversation getUIConversationByContactId(in long aId);
+
+ Array<prplIConversation> getConversations();
+ prplIConversation getConversationById(in unsigned long aId);
+ prplIConversation getConversationByNameAndAccount(in AUTF8String aName,
+ in imIAccount aAccount,
+ in boolean aIsChat);
+};
+
+// Because of limitations in libpurple (write_conv is called without context),
+// there's an implicit contract that whatever message string the conversation
+// service passes to a protocol, it'll get back as the originalMessage when
+// "new-text" is notified. This is required for the OTR extensions to work.
+
+// A cancellable outgoing message. Before handing a message off to a protocol,
+// the conversation service notifies observers of `preparing-message` and
+// `sending-message` (typically add-ons) of an outgoing message, which can be
+// transformed or cancelled.
+[scriptable, uuid(f88535b1-0b99-433b-a6de-c1a4bf8b43ea)]
+interface imIOutgoingMessage: nsISupports {
+ attribute AUTF8String message;
+ attribute boolean cancelled;
+ /** Outgoing message is an action command. */
+ readonly attribute boolean action;
+ /** Outgoing message is a notice */
+ readonly attribute boolean notification;
+ readonly attribute prplIConversation conversation;
+};
+
+// A cancellable message to be displayed. When the conversation service is
+// notified of a `new-text` (ie. an incoming or outgoing message to be
+// displayed), it in turn notifies observers of `received-message`
+// (again, typically add-ons), which have the opportunity to swap or cancel
+// the message.
+[scriptable, uuid(3f88cc5c-6940-4eb5-a576-c65770f49ce9)]
+interface imIMessage: prplIMessage {
+ attribute boolean cancelled;
+ // Holds the sender color for Chats.
+ // Empty string by default, it is set by the conversation binding.
+ attribute AUTF8String color;
+
+ // What eventually gets shown to the user.
+ attribute AUTF8String displayMessage;
+
+ // The related incoming or outgoing message is transmitted
+ // with encryption through OTR.
+ attribute boolean otrEncrypted;
+};
diff --git a/comm/chat/components/public/imICoreService.idl b/comm/chat/components/public/imICoreService.idl
new file mode 100644
index 0000000000..08ae1d2fbe
--- /dev/null
+++ b/comm/chat/components/public/imICoreService.idl
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "imIUserStatusInfo.idl"
+#include "prplIProtocol.idl"
+
+[scriptable, uuid(205d4b2b-1ccf-4879-9ef1-f08942566151)]
+interface imICoreService: nsISupports {
+ readonly attribute boolean initialized;
+
+ // This will emit a prpl-init notification. After this point the 'initialized'
+ // attribute will be 'true' and it's safe to access the services for accounts,
+ // contacts, conversations and commands.
+ void init();
+
+ // This will emit a prpl-quit notification. This is the last opportunity to
+ // use the aforementioned services before they are uninitialized.
+ void quit();
+
+ // Returns the available protocols.
+ Array<prplIProtocol> getProtocols();
+
+ prplIProtocol getProtocolById(in AUTF8String aProtocolId);
+
+ readonly attribute imIUserStatusInfo globalUserStatus;
+};
diff --git a/comm/chat/components/public/imILogger.idl b/comm/chat/components/public/imILogger.idl
new file mode 100644
index 0000000000..fd8e632d5d
--- /dev/null
+++ b/comm/chat/components/public/imILogger.idl
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsIFile.idl"
+
+interface imIAccount;
+interface prplIAccountBuddy;
+interface imIBuddy;
+interface imIContact;
+interface imIMessage;
+interface prplIConversation;
+
+[scriptable, uuid(7771402c-ff55-41f5-86b4-59b93f9b0693)]
+interface imILogConversation: nsISupports {
+ readonly attribute AUTF8String title;
+ readonly attribute AUTF8String name;
+ // Value in microseconds.
+ readonly attribute PRTime startDate;
+
+ // Simplified account implementation:
+ // - alias will always be empty
+ // - name (always the normalizedName)
+ // - statusInfo will return IMServices.core.globalUserStatus
+ // - protocol will only contain a "name" attribute, with the prpl's normalized name.
+ // Other methods/attributes aren't implemented.
+ readonly attribute imIAccount account;
+
+ readonly attribute boolean isChat; // always false (compatibility with prplIConversation).
+ readonly attribute prplIAccountBuddy buddy; // always null (compatibility with prplIConvIM).
+
+ Array<imIMessage> getMessages();
+};
+
+[scriptable, uuid(27712ece-ad2c-4504-87d5-9e2c16d40fef)]
+interface imILog: nsISupports {
+ readonly attribute AUTF8String path;
+ // Value in seconds.
+ readonly attribute PRTime time;
+ readonly attribute AUTF8String format;
+ // Returns a promise that resolves to an imILogConversation instance, or null
+ // if the log format isn't JSON.
+ jsval getConversation();
+};
+
+[scriptable, function, uuid(2ab5f8ac-4b89-4954-9a4a-7c167f1e3b0d)]
+interface imIProcessLogsCallback: nsISupports {
+ // The callback can return a promise. If it does, then it will not be called
+ // on the next log until this promise resolves. If it throws (or rejects),
+ // iteration will stop.
+ jsval processLog(in AUTF8String aLogPath);
+};
+
+[scriptable, uuid(7e2476dc-8199-4454-9661-b78ee73fa49e)]
+interface imILogger: nsISupports {
+ // Returns a promise that resolves to an imILog instance.
+ jsval getLogFromFile(in AUTF8String aFilePath, [optional] in boolean aGroupByDay);
+ // Returns a promise that resolves to the log file paths if a log writer
+ // exists for the conversation, or null otherwise. The promise resolves
+ // after any pending I/O operations on the files complete.
+ jsval getLogPathsForConversation(in prplIConversation aConversation);
+
+ // Below methods return promises that resolve to {imILog[]}.
+
+ // Get logs for a contact.
+ jsval getLogsForContact(in imIContact aContact);
+ // Get logs for a conversation.
+ jsval getLogsForConversation(in prplIConversation aConversation);
+ // Get logs that are from the same conversation.
+ jsval getSimilarLogs(in imILog aLog);
+
+ // Asynchronously iterates through log folders for all prpls and accounts and
+ // invokes the callback on every log file. Returns a promise that resolves when
+ // iteration is complete. If the callback returns a promise, iteration pauses
+ // until the promise resolves. If the callback throws (or rejects), iteration
+ // will stop and the returned promise will reject with the same error.
+ jsval forEach(in imIProcessLogsCallback aCallback);
+
+ // Returns the folder storing all logs for aAccount.
+ AUTF8String getLogFolderPathForAccount(in imIAccount aAccount);
+
+ // Removes the folder storing all logs for aAccount.
+ // Be sure the account is disconnected before using this.
+ jsval deleteLogFolderForAccount(in imIAccount aAccount);
+};
diff --git a/comm/chat/components/public/imIStatusInfo.idl b/comm/chat/components/public/imIStatusInfo.idl
new file mode 100644
index 0000000000..0338886923
--- /dev/null
+++ b/comm/chat/components/public/imIStatusInfo.idl
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "prplIConversation.idl"
+
+interface prplITooltipInfo;
+
+[scriptable, uuid(f13dc4fc-5334-45cb-aa58-a92851955e55)]
+interface imIStatusInfo: nsISupports {
+ // Name suitable for display in the UI. Can either be the username,
+ // the server side alias, or the user set local alias of the contact.
+ readonly attribute AUTF8String displayName;
+ readonly attribute AUTF8String buddyIconFilename;
+
+ const short STATUS_UNKNOWN = 0;
+ const short STATUS_OFFLINE = 1;
+ const short STATUS_INVISIBLE = 2;
+ const short STATUS_MOBILE = 3;
+ const short STATUS_IDLE = 4;
+ const short STATUS_AWAY = 5;
+ const short STATUS_UNAVAILABLE = 6;
+ const short STATUS_AVAILABLE = 7;
+
+ // numerical value used to compare the availability of two buddies
+ // based on their current status.
+ // Use it only for immediate comparisons, do not store the value,
+ // it can change between versions for a same status of the buddy.
+ readonly attribute long statusType;
+
+ readonly attribute boolean online; // (statusType > STATUS_OFFLINE)
+ readonly attribute boolean available; // (statusType == STATUS_AVAILABLE)
+ readonly attribute boolean idle; // (statusType == STATUS_IDLE)
+ readonly attribute boolean mobile; // (statusType == STATUS_MOBILE)
+
+ readonly attribute AUTF8String statusText;
+
+ // Gives more detail to compare the availability of two buddies with the same
+ // status type.
+ // Example: 2 buddies may have been idle for various amounts of times.
+ readonly attribute long availabilityDetails;
+
+ // True if the buddy is online or if the account supports sending
+ // offline messages to the buddy.
+ readonly attribute boolean canSendMessage;
+
+ // Array of prplITooltipInfo components.
+ Array<prplITooltipInfo> getTooltipInfo();
+
+ // Will select the buddy automatically based on availability, and
+ // the account (if needed) based on the account order in the account
+ // manager.
+ prplIConversation createConversation();
+};
diff --git a/comm/chat/components/public/imITagsService.idl b/comm/chat/components/public/imITagsService.idl
new file mode 100644
index 0000000000..e438c971c1
--- /dev/null
+++ b/comm/chat/components/public/imITagsService.idl
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsIObserver.idl"
+
+interface imIContact;
+
+[scriptable, uuid(c211e5e2-f0a4-4a86-9e4c-3f6b905628a5)]
+interface imITag: nsISupports {
+ readonly attribute long id;
+ attribute AUTF8String name;
+
+ /**
+ * Get an array of all the contacts associated with this tag.
+ *
+ * Contacts can either "have the tag" (added by user action) or
+ * have inherited the tag because it was the server side group for
+ * one of the AccountBuddy of the contact.
+ */
+ Array<imIContact> getContacts();
+
+ void addObserver(in nsIObserver aObserver);
+ void removeObserver(in nsIObserver aObserver);
+ /* Observers will be notified of changes related to the contacts
+ * that have the tag: contact-*, buddy-*, account-buddy-*
+ * notifications forwarded respectively from the imIContact,
+ * imIBuddy and prplIAccountBuddy instances.
+ */
+
+ // Exposed for add-on authors. All internal calls will come from the
+ // imITag implementation itself so it wasn't required to expose this.
+ // This can be used to dispatch custom notifications to the
+ // observers of the tag.
+ void notifyObservers(in nsISupports aObj, in string aEvent,
+ [optional] in wstring aData);
+};
+
+[scriptable, uuid(993aa8c7-8193-4354-8ee1-d2fd9fca692d)]
+interface imITagsService: nsISupports {
+ // Get the default tag (ie. "Contacts" for en-US).
+ readonly attribute imITag defaultTag;
+
+ /**
+ * Creates a new tag or gets an existing tag if one already exists.
+ *
+ * @param aName the name of the new tag.
+ * @returns imITag
+ */
+ imITag createTag(in AUTF8String aName);
+
+ /**
+ * Get an existing tag by ID.
+ *
+ * @param aId the numeric tag ID.
+ * @returns the tag or null if the tag doesn't exist.
+ */
+ imITag getTagById(in long aId);
+
+ /**
+ * Get an existing tag by name (note that this will do an SQL query).
+ *
+ * @param aName the tag name.
+ * @returns the tag or null if the tag doesn't exist.
+ */
+ imITag getTagByName(in AUTF8String aName);
+
+ /**
+ * Get an array of all existing tags.
+ *
+ * @returns imITag[]
+ */
+ Array<imITag> getTags();
+
+ boolean isTagHidden(in imITag aTag);
+ void hideTag(in imITag aTag);
+ void showTag(in imITag aTag);
+
+ readonly attribute imITag otherContactsTag;
+};
diff --git a/comm/chat/components/public/imIUserStatusInfo.idl b/comm/chat/components/public/imIUserStatusInfo.idl
new file mode 100644
index 0000000000..dba07c3190
--- /dev/null
+++ b/comm/chat/components/public/imIUserStatusInfo.idl
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsIObserver.idl"
+
+//forward declarations
+interface nsIFile;
+interface nsIFileURL;
+
+[scriptable, uuid(817918fa-1f4b-4254-9cdb-f906da91c45d)]
+interface imIUserStatusInfo: nsISupports {
+
+ readonly attribute AUTF8String statusText;
+
+ // See imIStatusInfo for the values.
+ readonly attribute short statusType;
+
+ /**
+ * Set the user's current status (e.g. available or away).
+ *
+ * When called with the status type STATUS_UNSET, only the status
+ * message will be changed.
+ *
+ * @param aStatus the new status to use. Only works with STATUS_OFFLINE,
+ * STATUS_UNAVAILABLE, STATUS_AWAY, STATUS_AVAILABLE and STATUS_INVISIBLE.
+ * @param aMessage the new status message. Ignored when aStatus is STATUS_OFFLINE.
+ */
+ void setStatus(in short aStatus, in AUTF8String aMessage);
+
+ /**
+ * Sets the user icon, or removes it if null is passed as a parameter.
+ *
+ * Calling this will fire a user-icon-changed notification.
+ */
+ void setUserIcon(in nsIFile aIconFile);
+
+ /**
+ * Returns the location of the current user icon, or null if no icon is set.
+ */
+ nsIFileURL getUserIcon();
+
+ /* The setter will fire a user-display-name-changed notification. */
+ attribute AUTF8String displayName;
+
+ void addObserver(in nsIObserver aObserver);
+ void removeObserver(in nsIObserver aObserver);
+ /* Observers will receive the following notifications:
+ * status-changed (when either the status type or text has changed)
+ * user-icon-changed
+ * user-display-name-changed
+ * idle-time-changed
+ */
+};
diff --git a/comm/chat/components/public/moz.build b/comm/chat/components/public/moz.build
new file mode 100644
index 0000000000..71758d842d
--- /dev/null
+++ b/comm/chat/components/public/moz.build
@@ -0,0 +1,25 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "imIAccount.idl",
+ "imIAccountsService.idl",
+ "imICommandsService.idl",
+ "imIContactsService.idl",
+ "imIConversationsService.idl",
+ "imICoreService.idl",
+ "imILogger.idl",
+ "imIStatusInfo.idl",
+ "imITagsService.idl",
+ "imIUserStatusInfo.idl",
+ "prplIConversation.idl",
+ "prplIMessage.idl",
+ "prplIPref.idl",
+ "prplIProtocol.idl",
+ "prplIRequest.idl",
+ "prplITooltipInfo.idl",
+]
+
+XPIDL_MODULE = "chat"
diff --git a/comm/chat/components/public/prplIConversation.idl b/comm/chat/components/public/prplIConversation.idl
new file mode 100644
index 0000000000..dd947337bb
--- /dev/null
+++ b/comm/chat/components/public/prplIConversation.idl
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+#include "nsIObserver.idl"
+
+interface prplIAccountBuddy;
+interface imIAccount;
+interface imIOutgoingMessage;
+interface imIMessage;
+interface nsIURI;
+interface prplIChatRoomFieldValues;
+
+/**
+ * This interface represents a conversation as implemented by a protocol. It
+ * contains the properties and methods shared between direct (IM) and multi
+ * user (chat) conversations.
+ */
+[scriptable, uuid(f71c58d6-2c47-4468-934b-b1c61462c01a)]
+interface prplIConversation: nsISupports {
+
+ /**
+ * Indicate if this conversation implements prplIConvIM or prplIConvChat. If
+ * this ever changes at runtime, the conversation should emit a
+ * "chat-update-type" notification. */
+ readonly attribute boolean isChat;
+
+ /* The account used for this conversation */
+ readonly attribute imIAccount account;
+
+ /* The name of the conversation, typically in English */
+ readonly attribute AUTF8String name;
+
+ /* A name that can be used to check for duplicates and is the basis
+ for the directory name for log storage. */
+ readonly attribute AUTF8String normalizedName;
+
+ /* The title of the conversation, typically localized */
+ readonly attribute AUTF8String title;
+
+ /* The time and date of the conversation's creation, in microseconds */
+ readonly attribute PRTime startDate;
+ /* Unique identifier of the conversation */
+ /* Setable only once by purpleCoreService while calling addConversation. */
+ attribute unsigned long id;
+
+ /** URI of the icon for the conversation */
+ readonly attribute AUTF8String convIconFilename;
+
+ /**
+ * The user can not enable encryption for this room (another participant may
+ * be able to enable encryption however)
+ */
+ const short ENCRYPTION_NOT_SUPPORTED = 0;
+ /**
+ * Encryption can be initialized in this conversation.
+ */
+ const short ENCRYPTION_AVAILABLE = 1;
+ /**
+ * New messages in this conversation are end-to-end encrypted.
+ */
+ const short ENCRYPTION_ENABLED = 2;
+ /**
+ * Indicates that the encryption with the other side should be trusted, for
+ * example because the user has verified their public keys. Implies
+ * ENCRYPTION_ENABLED.
+ */
+ const short ENCRYPTION_TRUSTED = 3;
+
+ /**
+ * State of encryption for this conversation, as available via the protocol.
+ * update-conv-encryption is observed when this changes.
+ */
+ readonly attribute short encryptionState;
+
+ /**
+ * When encryptionState is ENCRYPTION_AVAILABLE this tries to initialize
+ * encryption for all new messages in the conversation.
+ */
+ void initializeEncryption();
+
+ /**
+ * Send a message in the conversation. Protocols should consider resetting
+ * the typing state with this call, similar to |sendTyping("")|.
+ */
+ void sendMsg(in AUTF8String aMsg, in boolean aAction, in boolean aNotice);
+
+ /**
+ * Preprocess messages before they are sent (eg. split long messages).
+ *
+ * @returns the potentially modified message(s).
+ */
+ Array<AString> prepareForSending(in imIOutgoingMessage aMsg);
+
+ /**
+ * Postprocess messages before they are displayed (eg. escaping). The
+ * implementation can set aMsg.displayMessage, otherwise the originalMessage
+ * is used.
+ */
+ void prepareForDisplaying(in imIMessage aMsg);
+
+ /**
+ * Send information about the current typing state to the server.
+ *
+ * @param aString should contain the content currently in the text field.
+ * @returns the number of characters that can still be typed.
+ */
+ long sendTyping(in AUTF8String aString);
+ const long NO_TYPING_LIMIT = 2147483647; // max int = 2 ^ 31 - 1
+
+ /**
+ * Un-initialize the conversation.
+ *
+ * This will be called by purpleCoreService::RemoveConversation
+ * when the conversation is closed or by purpleCoreService::Quit
+ * while exiting.
+ */
+ void unInit();
+
+ /**
+ * Called when the conversation is closed from the UI.
+ */
+ void close();
+
+ /**
+ * Method to add or remove an observer.
+ */
+ void addObserver(in nsIObserver aObserver);
+ void removeObserver(in nsIObserver aObserver);
+
+ /**
+ * Observers will all receive new-text and update-text notifications.
+ * aSubject will contain the message (prplIMessage). For update-text the
+ * update applies to any message with the same |remoteId| in the same
+ * conversation.
+ * The remove-text notification has no subject, but a remote ID as data.
+ * It indicates that the message should be removed from the conversation.
+ * Neither update-text nor remove-text affect unread counts.
+ */
+};
+
+[scriptable, uuid(c0b5b647-b0ec-4dc6-9e53-31a762a30a6e)]
+interface prplIConvIM: prplIConversation {
+
+ /* The buddy at the remote end of the conversation */
+ readonly attribute prplIAccountBuddy buddy;
+
+ /* The remote buddy is not currently typing */
+ const short NOT_TYPING = 0;
+
+ /* The remote buddy is currently typing */
+ const short TYPING = 1;
+
+ /* The remote buddy started typing, but has stopped typing */
+ const short TYPED = 2;
+
+ /* The typing state of the remote buddy.
+ The value is NOT_TYPING, TYPING or TYPED. */
+ readonly attribute short typingState;
+};
+
+/** This represents a participant in a chat room */
+[scriptable, uuid(b0e9177b-40f6-420b-9918-04bbbb9ce44f)]
+interface prplIConvChatBuddy: nsISupports {
+
+ /* The name of the buddy */
+ readonly attribute AUTF8String name;
+
+ /* The alias (FIXME: can this be non-null if buddy is null?) */
+ readonly attribute AUTF8String alias;
+
+ /* Indicates if this chat buddy corresponds to a buddy in our buddy list */
+ readonly attribute boolean buddy;
+
+ /** URI of the user icon for the buddy */
+ readonly attribute AUTF8String buddyIconFilename;
+
+ /* The role of the participant in the room. */
+
+ /* Voiced users can send messages to the room. */
+ readonly attribute boolean voiced;
+ /* Moderators can manage other participants. */
+ readonly attribute boolean moderator;
+ /* Admins have additional powers. */
+ readonly attribute boolean admin;
+ /* Founders have complete control of a room. */
+ readonly attribute boolean founder;
+
+ /* Whether the participant is currently typing. */
+ readonly attribute boolean typing;
+
+ /** Whether we can verify the identity of this participant. */
+ readonly attribute boolean canVerifyIdentity;
+
+ /**
+ * True if we trust the encryption with this participant in E2EE chats. Can
+ * only be true if |canVerifyIdentity| is true.
+ */
+ readonly attribute boolean identityVerified;
+
+ /**
+ * Initialize identity verification with this participant.
+ * @returns {Promise<imISessionVerification>}
+ */
+ Promise verifyIdentity();
+};
+
+[scriptable, uuid(72c17398-639f-4141-a19c-78cbdeb39fba)]
+interface prplIConvChat: prplIConversation {
+
+ /**
+ * Get the prplIConvChatBuddy of a participant.
+ *
+ * @param aName the participant's nick in the conversation exists
+ * @returns prplIConvChatBuddy if the participant exists, otherwise null
+ */
+ prplIConvChatBuddy getParticipant(in AUTF8String aName);
+
+ /**
+ * Get the list of people participating in this chat.
+ *
+ * @returns an array of prplIConvChatBuddy objects.
+ */
+ Array<prplIConvChatBuddy> getParticipants();
+
+ /**
+ * Normalize the name of a chat buddy. This will be suitable for calling
+ * createConversation to start a private conversation or calling
+ * requestBuddyInfo.
+ *
+ * @returns the normalized chat buddy name.
+ */
+ AUTF8String getNormalizedChatBuddyName(in AUTF8String aChatBuddyName);
+
+ /* The topic of this chat room */
+ attribute AUTF8String topic;
+
+ /* The name/nick of the person who set the topic */
+ readonly attribute AUTF8String topicSetter;
+
+ /* Whether the protocol plugin can set a topic. Doesn't check that
+ the user has the necessary rights in the current conversation. */
+ readonly attribute boolean topicSettable;
+
+ /* The nick seen by other people in the room */
+ readonly attribute AUTF8String nick;
+
+ /* This is true when we left the chat but kept the conversation open */
+ readonly attribute boolean left;
+
+ /* This is true if we are in the process of joining the channel */
+ readonly attribute boolean joining;
+
+ /* This stores the data required to join the chat with joinChat().
+ If null, the chat will not be rejoined automatically when the
+ account reconnects after a disconnect.
+ Should be set to null by the prpl if the user parts the chat. */
+ readonly attribute prplIChatRoomFieldValues chatRoomFields;
+
+ /* Observers will receive chat-buddy-add, chat-buddy-update,
+ chat-buddy-remove and chat-update-topic notifications.
+
+ aSubject will be of type:
+ nsISimpleEnumerator of prplIConvChatBuddy for chat-buddy-add,
+ nsISimpleEnumerator of nsISupportsString for chat-buddy-remove,
+ prplIConvChatBuddy for chat-buddy-update,
+ null for chat-update-topic.
+
+ aData will contain the old nick for chat-buddy-update if the name
+ has changed.
+ */
+};
diff --git a/comm/chat/components/public/prplIMessage.idl b/comm/chat/components/public/prplIMessage.idl
new file mode 100644
index 0000000000..610c6a477d
--- /dev/null
+++ b/comm/chat/components/public/prplIMessage.idl
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsIRunnable.idl"
+#include "prplIConversation.idl"
+
+/**
+ * An action that the user may perform in relation to a particular message.
+ */
+[scriptable, uuid(7e470f0e-d948-4d9a-b8dc-4beecf6554b9)]
+interface prplIMessageAction: nsIRunnable
+{
+ /**
+ * The protocol plugins need to provide a localized label suitable
+ * for being shown in the user interface (for example as a context
+ * menu item).
+ */
+ readonly attribute AUTF8String label;
+};
+
+[scriptable, uuid(d6accb66-cdd2-4a91-8854-1156e65d5a43)]
+interface prplIMessage: nsISupports {
+ /**
+ * The uniqueness of the message id is only guaranteed across
+ * messages of a conversation, not across all messages created
+ * during the execution of the application.
+ */
+ readonly attribute unsigned long id;
+ /**
+ * An ID for this message provided by the protocol. Used for finding the
+ * message in the conversation for actions like editing. This is expected to
+ * be absolute per conversation, meaning if two prplIMessages in the same
+ * conversation have identical |remoteId|s they refer to the same message in
+ * the conversation as far as the protocol is concerned.
+ */
+ readonly attribute AUTF8String remoteId;
+ /** The name of the message sender. */
+ readonly attribute AUTF8String who;
+ /** The alias of the message sender (frequently the same as who). */
+ readonly attribute AUTF8String alias;
+ /** The original message, if it was modified, e.g. via OTR. */
+ readonly attribute AUTF8String originalMessage;
+ /** The message that will be sent over the wire. */
+ attribute AUTF8String message;
+ /** An icon to associate with the message sender. */
+ readonly attribute AUTF8String iconURL;
+ /** The time the message was sent, in seconds. */
+ readonly attribute PRTime time;
+ /** The conversation the message was sent to. */
+ readonly attribute prplIConversation conversation;
+
+ /** Outgoing message. */
+ readonly attribute boolean outgoing;
+ /** Incoming message. */
+ readonly attribute boolean incoming;
+ /** System message, i.e. a message from the server or client (not from another user). */
+ readonly attribute boolean system;
+ /** Auto response. */
+ readonly attribute boolean autoResponse;
+ /** Contains your nick, e.g. if you were pinged. */
+ readonly attribute boolean containsNick;
+ /** This message should not be logged. */
+ readonly attribute boolean noLog;
+ /** Error message. */
+ readonly attribute boolean error;
+ /** Delayed message, e.g. it was received from a queue of historical messages on the server. */
+ readonly attribute boolean delayed;
+ /** "Raw" message - don't apply formatting. */
+ readonly attribute boolean noFormat;
+ /** Message contains images. */
+ readonly attribute boolean containsImages;
+ /** Message is a notification. */
+ readonly attribute boolean notification;
+ /** Message should not be auto-linkified. */
+ readonly attribute boolean noLinkification;
+ /** Do not collapse the message. */
+ readonly attribute boolean noCollapse;
+ /** Message is encrypted. */
+ readonly attribute boolean isEncrypted;
+ /** The message should be displayed as an action/emote. */
+ readonly attribute boolean action;
+ /** Message was deleted, this is a placeholder for it */
+ readonly attribute boolean deleted;
+
+ /**
+ * Get an array of actions the user may perform on this message.
+ *
+ * @returns prplIMessageAction[]
+ */
+ Array<prplIMessageAction> getActions();
+
+ /**
+ * Called when the message is first displayed to the user. Only invoked for
+ * the latest message in a conversation.
+ */
+ void whenDisplayed();
+
+ /**
+ * Called when the message has been read by the user, as defined by it being
+ * above the unread marker in the conversation. Only called for the message
+ * immediately above the marker.
+ */
+ void whenRead();
+};
diff --git a/comm/chat/components/public/prplIPref.idl b/comm/chat/components/public/prplIPref.idl
new file mode 100644
index 0000000000..7f3f827952
--- /dev/null
+++ b/comm/chat/components/public/prplIPref.idl
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "nsISimpleEnumerator.idl"
+
+[scriptable, uuid(8fc16882-ba8e-432a-999f-0d4dc104234b)]
+interface prplIKeyValuePair: nsISupports {
+ readonly attribute AUTF8String name;
+ readonly attribute AUTF8String value;
+};
+
+/*
+ * This is a proxy for libpurple PurpleAccountOption
+ */
+
+[scriptable, uuid(e781563f-9088-4a96-93e3-4fb6f5ce6a77)]
+interface prplIPref: nsISupports {
+ const short typeBool = 1;
+ const short typeInt = 2;
+ const short typeString = 3;
+ const short typeList = 4;
+
+ readonly attribute AUTF8String name;
+ readonly attribute AUTF8String label;
+ readonly attribute short type;
+ readonly attribute boolean masked;
+
+ boolean getBool();
+ long getInt();
+ AUTF8String getString();
+ /**
+ * @returns array of prplIKeyValuePair
+ */
+ Array<prplIKeyValuePair> getList();
+ AUTF8String getListDefault();
+};
diff --git a/comm/chat/components/public/prplIProtocol.idl b/comm/chat/components/public/prplIProtocol.idl
new file mode 100644
index 0000000000..f6d30826f4
--- /dev/null
+++ b/comm/chat/components/public/prplIProtocol.idl
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "imIAccount.idl"
+
+interface prplIPref;
+interface prplIUsernameSplit;
+
+/**
+ * This must be implemented for every protocol.
+ *
+ * See jsProtoHelper.jsm for a base class.
+ */
+[scriptable, uuid(7d302db0-3813-4c51-8372-c7eb5fc9f3d3)]
+interface prplIProtocol: nsISupports {
+ /**
+ * This method is used so that classes implementing several protocol
+ * plugins can know which protocol is desired for this instance.
+ *
+ * @param aId The prpl id.
+ */
+ void init(in AUTF8String aId);
+
+ /**
+ * A human readable (potentially localized) name for the protocol.
+ */
+ readonly attribute AUTF8String name;
+ /**
+ * A unique ID for the protocol, should start with the prefix 'prpl-'.
+ */
+ readonly attribute AUTF8String id;
+ /**
+ * A unique name for this protocol, it must consist of only lowercase letters
+ * & numbers.
+ *
+ * It can be used to check for duplicates and is the basis for the directory
+ * name for log storage.
+ */
+ readonly attribute AUTF8String normalizedName;
+
+ /**
+ * A chrome URI pointing to a folder that contains the icon files:
+ * icon.png icon32.png and icon48.png
+ */
+ readonly attribute AUTF8String iconBaseURI;
+
+ /**
+ * @returns an array of prplIPref
+ */
+ Array<prplIPref> getOptions();
+
+ /**
+ * String to put in front of the full account username identifier. Usually
+ * an empty string.
+ */
+ readonly attribute AUTF8String usernamePrefix;
+
+ /**
+ * @returns an array of prplIUsernameSplit
+ */
+ Array<prplIUsernameSplit> getUsernameSplit();
+
+ /**
+ * Split a username into its parts without separators (or prefix).
+ * Returns an empty array if the username can not be split.
+ */
+ Array<AUTF8String> splitUsername(in AUTF8String aName);
+
+ /**
+ * Descriptive text used in the account wizard to describe the username.
+ */
+ readonly attribute AUTF8String usernameEmptyText;
+
+ /**
+ * Use this function to avoid attempting to create duplicate accounts.
+ */
+ boolean accountExists(in AUTF8String aName);
+
+ // The following should all be flags that describe whether a protocol has a
+ // particular feature.
+
+ /**
+ * Whether chat rooms have topics.
+ */
+ readonly attribute boolean chatHasTopic;
+
+ /**
+ * True if passwords are unused for this protocol.
+ *
+ * Passwords are unused for some protocols, e.g. Bonjour.
+ */
+ readonly attribute boolean noPassword;
+
+ /**
+ * True if a password is not required for sign-in.
+ *
+ * Passwords in IRC are optional, and are needed for certain functionality.
+ */
+ readonly attribute boolean passwordOptional;
+
+ /**
+ * Indicates that slash commands are native to this protocol.
+ * Used as a hint that unknown commands should not be sent as messages.
+ */
+ readonly attribute boolean slashCommandsNative;
+
+ /**
+ * True if the protocol can provide end-to-end message encryption in
+ * conversations.
+ */
+ readonly attribute boolean canEncrypt;
+
+ /**
+ * Get the protocol specific part of an already initialized
+ * imIAccount instance.
+ */
+ prplIAccount getAccount(in imIAccount aImAccount);
+};
+
+/**
+ * The chat account wizards requests the sign-in information as a series of
+ * fields generated by a list of prplIUsernameSplit.
+ *
+ * The result of these is composed into a string and stored as the account name.
+ * It is the responsibity of the prplIAccount to re-parse this back to usable
+ * connection data.
+ *
+ * TODO Replace this with storing account data as separate fields.
+ */
+[scriptable, uuid(20c4971a-f7c2-4781-8e85-69fee7b83a3d)]
+interface prplIUsernameSplit: nsISupports {
+ /**
+ * The field name presented in the account wizard, e.g. server.
+ */
+ readonly attribute AUTF8String label;
+ /**
+ * The default value that is presented in the account wizard.
+ */
+ readonly attribute AUTF8String defaultValue;
+ /**
+ * The string used to compose the account name.
+ *
+ * E.g. an "@" would cause "@" to be appended before this field.
+ */
+ readonly attribute char separator;
+};
diff --git a/comm/chat/components/public/prplIRequest.idl b/comm/chat/components/public/prplIRequest.idl
new file mode 100644
index 0000000000..2e9b58584f
--- /dev/null
+++ b/comm/chat/components/public/prplIRequest.idl
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface imIAccount;
+interface nsIDOMWindow;
+interface nsIWebProgress;
+
+/**
+ * This interface is for use in the browser-request notification, to
+ * let protocol plugins open a browser window. This is an unfortunate
+ * necessity for protocols that require an OAuth authentication.
+ */
+[scriptable, uuid(b89dbb38-0de4-11e0-b3d0-0002e304243c)]
+interface prplIRequestBrowser: nsISupports {
+ readonly attribute AUTF8String promptText;
+ readonly attribute AUTF8String url;
+ void cancelled();
+ void loaded(in nsIDOMWindow aWindow,
+ in nsIWebProgress aWebProgress);
+};
+
+/**
+ * This interface is used for buddy authorization requests, when the
+ * user needs to confirm if a remote contact should be allowed to see
+ * his presence information. It is implemented by the aSubject
+ * parameter of the buddy-authorization-request and
+ * buddy-authorization-request-canceled notifications.
+ */
+[scriptable, uuid(a55c1e24-17cc-4ddc-8c64-3bc315a3c3b1)]
+interface prplIBuddyRequest: nsISupports {
+ readonly attribute imIAccount account;
+ readonly attribute AUTF8String userName;
+ void grant();
+ void deny();
+};
+
+/**
+ * This is used with chat room invitation requests, so the user can accept or
+ * reject an invitation. It is implemented by the aSubject parameter of the
+ * conv-authorization-request notification.
+ */
+[scriptable, uuid(44ac9606-711b-40f6-9031-94a9c60c938d)]
+interface prplIChatRequest: nsISupports {
+ readonly attribute imIAccount account;
+ readonly attribute AUTF8String conversationName;
+ /**
+ * Resolves when the request is completed, with a boolean indicating if it
+ * was granted. Rejected if the request is cancelled.
+ *
+ * @type {Promise<boolean>}
+ */
+ readonly attribute Promise completePromise;
+ readonly attribute boolean canDeny;
+ void grant();
+ void deny();
+};
+
+/**
+ * Verification information for an encryption session (for example prplISession).
+ * Used to present a verification flow to the user.
+ */
+[scriptable, uuid(48c1748d-ba51-44c0-aa3c-e979d4d4bdf3)]
+interface imISessionVerification: nsISupports {
+ /**
+ * Challenge mode where a text string is presented to the user and they have
+ * to confirm it matches with the other user/device's.
+ */
+ const short CHALLENGE_TEXT = 1;
+ /** Verification mode */
+ readonly attribute short challengeType;
+ /** Challenge string to present to the user for CHALLENGE_TEXT */
+ readonly attribute AUTF8String challenge;
+ /**
+ * Optional description of the challenge contents. For example text
+ * representation of emoji.
+ */
+ readonly attribute AUTF8String challengeDescription;
+ /**
+ * User readable name for the entity the verification is about (so the
+ * user/device on the other side of the flow).
+ */
+ readonly attribute AUTF8String subject;
+ /**
+ * resolves with the result from the challenge, rejects if the action was
+ * cancelled.
+ *
+ * @type {Promise<boolean>}
+ */
+ readonly attribute Promise completePromise;
+ /**
+ * Submit result of the challenge, completing the verification on this side.
+ */
+ void submitResponse(in boolean challengeMatches);
+ /**
+ * Cancel the verification.
+ */
+ void cancel();
+};
+
+/**
+ * Incoming verification request, sent to the UI via buddy-verification-request
+ * notification. Can be canelled with buddy-verification-request-cancelled.
+ */
+[scriptable, uuid(c46d426f-6e99-4713-b0aa-0b404db5a40d)]
+interface imIIncomingSessionVerification: imISessionVerification {
+ readonly attribute imIAccount account;
+ /**
+ * Method to accept the verification. Resolves once |challenge| is
+ * populated.
+ */
+ Promise verify();
+};
diff --git a/comm/chat/components/public/prplITooltipInfo.idl b/comm/chat/components/public/prplITooltipInfo.idl
new file mode 100644
index 0000000000..baa62b89a7
--- /dev/null
+++ b/comm/chat/components/public/prplITooltipInfo.idl
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+/*
+ * This interface provides access to the content of a
+ * PurpleNotifyUserInfoEntry structure.
+ */
+
+[scriptable, uuid(e4c1def4-d1fe-4449-b195-51f137d1f215)]
+interface prplITooltipInfo: nsISupports {
+ const short pair = 0;
+ const short sectionBreak = 1;
+ const short sectionHeader = 2;
+ const short status = 3;
+ const short icon = 4;
+
+ readonly attribute short type;
+
+ /*
+ * When type == status, the label holds the statusType (a short
+ * converted to a string), while the value holds the statusText.
+ * When type == icon, the value holds the user icon URI.
+ */
+ readonly attribute AUTF8String label;
+ readonly attribute AUTF8String value;
+};
diff --git a/comm/chat/components/src/components.conf b/comm/chat/components/src/components.conf
new file mode 100644
index 0000000000..cec63d9801
--- /dev/null
+++ b/comm/chat/components/src/components.conf
@@ -0,0 +1,50 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ "cid": "{a94b5427-cd8d-40cf-b47e-b67671953e70}",
+ "contract_ids": ["@mozilla.org/chat/accounts-service;1"],
+ 'esModule': "resource:///modules/imAccounts.sys.mjs",
+ "constructor": "AccountsService",
+ },
+ {
+ "cid": "{7cb20c68-ccc8-4a79-b6f1-0b4771ed6c23}",
+ "contract_ids": ["@mozilla.org/chat/commands-service;1"],
+ 'esModule': "resource:///modules/imCommands.sys.mjs",
+ "constructor": "CommandsService",
+ },
+ {
+ "cid": "{8c3725dd-ee26-489d-8135-736015af8c7f}",
+ "contract_ids": ["@mozilla.org/chat/contacts-service;1"],
+ 'esModule': "resource:///modules/imContacts.sys.mjs",
+ "constructor": "ContactsService",
+ },
+ {
+ "cid": "{1fa92237-4303-4384-b8ac-4e65b50810a5}",
+ "contract_ids": ["@mozilla.org/chat/tags-service;1"],
+ 'esModule': "resource:///modules/imContacts.sys.mjs",
+ "constructor": "TagsService",
+ },
+ {
+ "cid": "{b2397cd5-c76d-4618-8410-f344c7c6443a}",
+ "contract_ids": ["@mozilla.org/chat/conversations-service;1"],
+ 'esModule': "resource:///modules/imConversations.sys.mjs",
+ "constructor": "ConversationsService",
+ },
+ {
+ "cid": "{073f5953-853c-4a38-bd81-255510c31c2e}",
+ "contract_ids": ["@mozilla.org/chat/core-service;1"],
+ 'esModule': "resource:///modules/imCore.sys.mjs",
+ "constructor": "CoreService",
+ },
+ {
+ "cid": "{fb0dc220-2c7a-4216-9f19-6b8f3480eae9}",
+ "contract_ids": ["@mozilla.org/chat/logger;1"],
+ 'esModule': "resource:///modules/logger.sys.mjs",
+ "constructor": "Logger",
+ },
+]
diff --git a/comm/chat/components/src/imAccounts.sys.mjs b/comm/chat/components/src/imAccounts.sys.mjs
new file mode 100644
index 0000000000..f06b503fa6
--- /dev/null
+++ b/comm/chat/components/src/imAccounts.sys.mjs
@@ -0,0 +1,1237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ ClassInfo,
+ executeSoon,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericAccountBuddyPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/accounts.properties")
+);
+XPCOMUtils.defineLazyGetter(lazy, "_maxDebugMessages", () =>
+ Services.prefs.getIntPref("messenger.accounts.maxDebugMessages")
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "HttpProtocolHandler",
+ "@mozilla.org/network/protocol;1?name=http",
+ "nsIHttpProtocolHandler"
+);
+
+var kPrefAutologinPending = "messenger.accounts.autoLoginPending";
+let kPrefAccountOrder = "mail.accountmanager.accounts";
+var kPrefAccountPrefix = "messenger.account.";
+var kAccountKeyPrefix = "account";
+var kAccountOptionPrefPrefix = "options.";
+var kPrefAccountName = "name";
+var kPrefAccountPrpl = "prpl";
+var kPrefAccountAutoLogin = "autoLogin";
+var kPrefAccountAutoJoin = "autoJoin";
+var kPrefAccountAlias = "alias";
+var kPrefAccountFirstConnectionState = "firstConnectionState";
+
+var gUserCanceledPrimaryPasswordPrompt = false;
+
+var SavePrefTimer = {
+ saveNow() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ Services.prefs.savePrefFile(null);
+ },
+ _timer: null,
+ unInitTimer() {
+ if (this._timer) {
+ this.saveNow();
+ }
+ },
+ initTimer() {
+ if (!this._timer) {
+ this._timer = setTimeout(this.saveNow.bind(this), 5000);
+ }
+ },
+};
+
+var AutoLoginCounter = {
+ _count: 0,
+ startAutoLogin() {
+ ++this._count;
+ if (this._count != 1) {
+ return;
+ }
+ Services.prefs.setIntPref(kPrefAutologinPending, Date.now() / 1000);
+ SavePrefTimer.saveNow();
+ },
+ finishedAutoLogin() {
+ --this._count;
+ if (this._count != 0) {
+ return;
+ }
+ Services.prefs.clearUserPref(kPrefAutologinPending);
+ SavePrefTimer.initTimer();
+ },
+};
+
+function UnknownProtocol(aPrplId) {
+ this.id = aPrplId;
+}
+UnknownProtocol.prototype = {
+ __proto__: ClassInfo("prplIProtocol", "Unknown protocol"),
+ get name() {
+ return "";
+ },
+ get normalizedName() {
+ // Use the ID, but remove the 'prpl-' prefix.
+ return this.id.replace(/^prpl-/, "");
+ },
+ get iconBaseURI() {
+ return "chrome://chat/skin/prpl-unknown/";
+ },
+ getOptions() {
+ return [];
+ },
+ get usernamePrefix() {
+ return "";
+ },
+ getUsernameSplit() {
+ return [];
+ },
+ get usernameEmptyText() {
+ return "";
+ },
+
+ getAccount(aKey, aName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ accountExists() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ // false seems an acceptable default for all options
+ // (they should never be called anyway).
+ get chatHasTopic() {
+ return false;
+ },
+ get noPassword() {
+ return false;
+ },
+ get passwordOptional() {
+ return true;
+ },
+ get slashCommandsNative() {
+ return false;
+ },
+ get canEncrypt() {
+ return false;
+ },
+};
+
+// An unknown prplIAccount.
+function UnknownAccount(aAccount) {
+ this._init(aAccount.protocol, aAccount);
+}
+UnknownAccount.prototype = GenericAccountPrototype;
+
+function UnknownAccountBuddy(aAccount, aBuddy, aTag) {
+ this._init(new UnknownAccount(aAccount), aBuddy, aTag);
+}
+UnknownAccountBuddy.prototype = GenericAccountBuddyPrototype;
+
+/**
+ * @param {string} aKey - Account key for preferences.
+ * @param {string} [aName] - Name of the account if it is new. Will be stored
+ * in account preferences. If not provided, the value from the account
+ * preferences is used instead.
+ * @param {string} [aPrplId] - Protocol ID for this account if it is new. Will
+ * be stored in account preferences. If not provided, the value from the
+ * account preferences is used instead.
+ */
+function imAccount(aKey, aName, aPrplId) {
+ if (!aKey.startsWith(kAccountKeyPrefix)) {
+ throw Components.Exception(`Invalid key: ${aKey}`, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this.id = aKey;
+ this.numericId = parseInt(aKey.substr(kAccountKeyPrefix.length));
+ gAccountsService._keepAccount(this);
+ this.prefBranch = Services.prefs.getBranch(kPrefAccountPrefix + aKey + ".");
+
+ if (aName) {
+ this.name = aName;
+ this.prefBranch.setStringPref(kPrefAccountName, aName);
+
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ } else {
+ this.name = this.prefBranch.getStringPref(kPrefAccountName);
+ }
+
+ let prplId = aPrplId;
+ if (prplId) {
+ this.prefBranch.setCharPref(kPrefAccountPrpl, prplId);
+ } else {
+ prplId = this.prefBranch.getCharPref(kPrefAccountPrpl);
+ }
+
+ // Get the protocol plugin, or fallback to an UnknownProtocol instance.
+ this.protocol = IMServices.core.getProtocolById(prplId);
+ if (!this.protocol) {
+ this.protocol = new UnknownProtocol(prplId);
+ this._connectionErrorReason = Ci.imIAccount.ERROR_UNKNOWN_PRPL;
+ return;
+ }
+
+ // Ensure the account is correctly stored in blist.sqlite.
+ IMServices.contacts.storeAccount(this.numericId, this.name, prplId);
+
+ // Get the prplIAccount from the protocol plugin.
+ this.prplAccount = this.protocol.getAccount(this);
+
+ // Send status change notifications to the account.
+ this.observedStatusInfo = null; // (To execute the setter).
+
+ // If we have never finished the first connection attempt for this account,
+ // mark the account as having caused a crash.
+ if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_CRASHED;
+ }
+
+ Services.logins.initializationPromise.then(() => {
+ // If protocol is falsy remove() was called on this instance while waiting
+ // for the promise to resolve. Since the instance was disposed there is
+ // nothing to do.
+ if (!this.protocol) {
+ return;
+ }
+
+ // Check for errors that should prevent connection attempts.
+ if (this._passwordRequired && !this.password) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD;
+ } else if (
+ this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_CRASHED
+ ) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_CRASHED;
+ }
+ });
+}
+
+imAccount.prototype = {
+ __proto__: ClassInfo(["imIAccount", "prplIAccount"], "im account object"),
+
+ name: "",
+ id: "",
+ numericId: 0,
+ protocol: null,
+ prplAccount: null,
+ connectionState: Ci.imIAccount.STATE_DISCONNECTED,
+ connectionStateMsg: "",
+ connectionErrorMessage: "",
+ _connectionErrorReason: Ci.prplIAccount.NO_ERROR,
+ get connectionErrorReason() {
+ if (
+ this._connectionErrorReason != Ci.prplIAccount.NO_ERROR &&
+ (this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD ||
+ !this._password)
+ ) {
+ return this._connectionErrorReason;
+ }
+ return this.prplAccount.connectionErrorReason;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "account-connect-progress") {
+ this.connectionStateMsg = aData;
+ } else if (aTopic == "account-connecting") {
+ if (this.prplAccount.connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ delete this.connectionErrorMessage;
+ if (this.timeOfNextReconnect - Date.now() > 1000) {
+ // This is a manual reconnection, reset the auto-reconnect stuff
+ this.timeOfLastConnect = 0;
+ this._cancelReconnection();
+ }
+ }
+ if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_PENDING;
+ }
+ this.connectionState = Ci.imIAccount.STATE_CONNECTING;
+ } else if (aTopic == "account-connected") {
+ this.connectionState = Ci.imIAccount.STATE_CONNECTED;
+ this._finishedAutoLogin();
+ this.timeOfLastConnect = Date.now();
+ if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_OK;
+ }
+ delete this.connectionStateMsg;
+
+ if (
+ this.canJoinChat &&
+ this.prefBranch.prefHasUserValue(kPrefAccountAutoJoin)
+ ) {
+ let autojoin = this.prefBranch.getStringPref(kPrefAccountAutoJoin);
+ if (autojoin) {
+ for (let room of autojoin.trim().split(/,\s*/)) {
+ if (room) {
+ this.joinChat(this.getChatRoomDefaultFieldValues(room));
+ }
+ }
+ }
+ }
+ } else if (aTopic == "account-disconnecting") {
+ this.connectionState = Ci.imIAccount.STATE_DISCONNECTING;
+ this.connectionErrorMessage = aData;
+ delete this.connectionStateMsg;
+ this._finishedAutoLogin();
+
+ let firstConnectionState = this.firstConnectionState;
+ if (
+ firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK &&
+ firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_CRASHED
+ ) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ }
+
+ let connectionErrorReason = this.prplAccount.connectionErrorReason;
+ if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ if (
+ connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR
+ ) {
+ this._startReconnectTimer();
+ }
+ this._sendNotification("account-connect-error");
+ }
+ } else if (aTopic == "account-disconnected") {
+ this.connectionState = Ci.imIAccount.STATE_DISCONNECTED;
+ let connectionErrorReason = this.prplAccount.connectionErrorReason;
+ if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ // If the account was disconnected with an error, save the debug messages.
+ this._omittedDebugMessagesBeforeError += this._omittedDebugMessages;
+ if (this._debugMessagesBeforeError) {
+ this._omittedDebugMessagesBeforeError +=
+ this._debugMessagesBeforeError.length;
+ }
+ this._debugMessagesBeforeError = this._debugMessages;
+ } else {
+ // After a clean disconnection, drop the debug messages that
+ // could have been left by a previous error.
+ delete this._omittedDebugMessagesBeforeError;
+ delete this._debugMessagesBeforeError;
+ }
+ delete this._omittedDebugMessages;
+ delete this._debugMessages;
+ if (
+ this._statusObserver &&
+ connectionErrorReason == Ci.prplIAccount.NO_ERROR &&
+ this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ // If the status changed back to online while an account was still
+ // disconnecting, it was not reconnected automatically at that point,
+ // so we must do it now. (This happens for protocols like IRC where
+ // disconnection is not immediate.)
+ this._sendNotification(aTopic, aData);
+ this.connect();
+ return;
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ this._sendNotification(aTopic, aData);
+ },
+
+ _debugMessages: null,
+ _omittedDebugMessages: 0,
+ _debugMessagesBeforeError: null,
+ _omittedDebugMessagesBeforeError: 0,
+ logDebugMessage(aMessage, aLevel) {
+ if (!this._debugMessages) {
+ this._debugMessages = [];
+ }
+ if (
+ lazy._maxDebugMessages &&
+ this._debugMessages.length >= lazy._maxDebugMessages
+ ) {
+ this._debugMessages.shift();
+ ++this._omittedDebugMessages;
+ }
+ this._debugMessages.push({ logLevel: aLevel, message: aMessage });
+ },
+ _createDebugMessage(aMessage) {
+ let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.init(
+ aMessage,
+ "",
+ "",
+ 0,
+ null,
+ Ci.nsIScriptError.warningFlag,
+ "component javascript"
+ );
+ return { logLevel: 0, message: scriptError };
+ },
+ getDebugMessages() {
+ let messages = [];
+ if (this._omittedDebugMessagesBeforeError) {
+ let text = this._omittedDebugMessagesBeforeError + " messages omitted";
+ messages.push(this._createDebugMessage(text));
+ }
+ if (this._debugMessagesBeforeError) {
+ messages = messages.concat(this._debugMessagesBeforeError);
+ }
+ if (this._omittedDebugMessages) {
+ let text = this._omittedDebugMessages + " messages omitted";
+ messages.push(this._createDebugMessage(text));
+ }
+ if (this._debugMessages) {
+ messages = messages.concat(this._debugMessages);
+ }
+ if (messages.length) {
+ let appInfo = Services.appinfo;
+ let header =
+ `${appInfo.name} ${appInfo.version} (${appInfo.appBuildID}), ` +
+ `Gecko ${appInfo.platformVersion} (${appInfo.platformBuildID}) ` +
+ `on ${lazy.HttpProtocolHandler.oscpu}`;
+ messages.unshift(this._createDebugMessage(header));
+ }
+
+ return messages;
+ },
+
+ _observedStatusInfo: null,
+ get observedStatusInfo() {
+ return this._observedStatusInfo;
+ },
+ _statusObserver: null,
+ set observedStatusInfo(aUserStatusInfo) {
+ if (!this.prplAccount) {
+ return;
+ }
+ if (this._statusObserver) {
+ this.statusInfo.removeObserver(this._statusObserver);
+ }
+ this._observedStatusInfo = aUserStatusInfo;
+ if (this._statusObserver) {
+ this.statusInfo.addObserver(this._statusObserver);
+ }
+ },
+ _removeStatusObserver() {
+ if (this._statusObserver) {
+ this.statusInfo.removeObserver(this._statusObserver);
+ delete this._statusObserver;
+ }
+ },
+ get statusInfo() {
+ return this._observedStatusInfo || IMServices.core.globalUserStatus;
+ },
+
+ reconnectAttempt: 0,
+ timeOfLastConnect: 0,
+ timeOfNextReconnect: 0,
+ _reconnectTimer: null,
+ _startReconnectTimer() {
+ if (Services.io.offline) {
+ console.error("_startReconnectTimer called while offline");
+ return;
+ }
+
+ /* If the last successful connection is older than 10 seconds, reset the
+ number of reconnection attempts. */
+ const kTimeBeforeSuccessfulConnection = 10;
+ if (
+ this.timeOfLastConnect &&
+ this.timeOfLastConnect + kTimeBeforeSuccessfulConnection * 1000 <
+ Date.now()
+ ) {
+ delete this.reconnectAttempt;
+ delete this.timeOfLastConnect;
+ }
+
+ let timers = Services.prefs
+ .getCharPref("messenger.accounts.reconnectTimer")
+ .split(",");
+ let delay = timers[Math.min(this.reconnectAttempt, timers.length - 1)];
+ let msDelay = parseInt(delay) * 1000;
+ ++this.reconnectAttempt;
+ this.timeOfNextReconnect = Date.now() + msDelay;
+ this._reconnectTimer = setTimeout(this.connect.bind(this), msDelay);
+ },
+
+ _sendNotification(aTopic, aData) {
+ Services.obs.notifyObservers(this, aTopic, aData);
+ },
+
+ get firstConnectionState() {
+ try {
+ return this.prefBranch.getIntPref(kPrefAccountFirstConnectionState);
+ } catch (e) {
+ return Ci.imIAccount.FIRST_CONNECTION_OK;
+ }
+ },
+ set firstConnectionState(aState) {
+ if (aState == Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.prefBranch.clearUserPref(kPrefAccountFirstConnectionState);
+ } else {
+ this.prefBranch.setIntPref(kPrefAccountFirstConnectionState, aState);
+ // We want to save this pref immediately when trying to connect.
+ if (aState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ SavePrefTimer.saveNow();
+ } else {
+ SavePrefTimer.initTimer();
+ }
+ }
+ },
+
+ _pendingReconnectForConnectionInfoChange: false,
+ _connectionInfoChanged() {
+ // The next connection will be the first connection with these parameters.
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+
+ // We want to attempt to reconnect with the new settings only if a
+ // previous attempt failed or a connection attempt is currently
+ // pending (so we can return early if the account is currently
+ // connected or disconnected without error).
+ // The code doing the reconnection attempt is wrapped within an
+ // executeSoon call so that when multiple settings are changed at
+ // once we don't attempt to reconnect until they are all saved.
+ // If a reconnect attempt is already scheduled, we can also return early.
+ if (
+ this._pendingReconnectForConnectionInfoChange ||
+ this.connected ||
+ (this.disconnected &&
+ this.connectionErrorReason == Ci.prplIAccount.NO_ERROR)
+ ) {
+ return;
+ }
+
+ this._pendingReconnectForConnectionInfoChange = true;
+ executeSoon(
+ function () {
+ delete this._pendingReconnectForConnectionInfoChange;
+ // If the connection parameters have changed while we were
+ // trying to connect, cancel the ongoing connection attempt and
+ // try again with the new parameters.
+ if (this.connecting) {
+ this.disconnect();
+ this.connect();
+ return;
+ }
+ // If the account was disconnected because of a non-fatal
+ // connection error, retry now that we have new parameters.
+ let errorReason = this.connectionErrorReason;
+ if (
+ this.disconnected &&
+ errorReason != Ci.prplIAccount.NO_ERROR &&
+ errorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD &&
+ errorReason != Ci.imIAccount.ERROR_CRASHED &&
+ errorReason != Ci.imIAccount.ERROR_UNKNOWN_PRPL
+ ) {
+ this.connect();
+ }
+ }.bind(this)
+ );
+ },
+
+ // If the protocol plugin is missing, we can't access the normalizedName,
+ // but in lots of cases this.name is equivalent.
+ get normalizedName() {
+ return this.prplAccount ? this.prplAccount.normalizedName : this.name;
+ },
+ normalize(aName) {
+ return this.prplAccount ? this.prplAccount.normalize(aName) : aName;
+ },
+
+ _sendUpdateNotification() {
+ this._sendNotification("account-updated");
+ },
+
+ set alias(val) {
+ if (val) {
+ this.prefBranch.setStringPref(kPrefAccountAlias, val);
+ } else {
+ this.prefBranch.clearUserPref(kPrefAccountAlias);
+ }
+ this._sendUpdateNotification();
+ },
+ get alias() {
+ try {
+ return this.prefBranch.getStringPref(kPrefAccountAlias);
+ } catch (e) {
+ return "";
+ }
+ },
+
+ _password: "",
+ get password() {
+ if (this._password) {
+ return this._password;
+ }
+
+ // Avoid prompting the user for the primary password more than once at startup.
+ if (gUserCanceledPrimaryPasswordPrompt) {
+ return "";
+ }
+
+ let passwordURI = "im://" + this.protocol.id;
+ let logins;
+ try {
+ logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ } catch (e) {
+ this._handlePrimaryPasswordException(e);
+ return "";
+ }
+ let normalizedName = this.normalizedName;
+ for (let login of logins) {
+ if (login.username == normalizedName) {
+ this._password = login.password;
+ if (
+ this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD
+ ) {
+ // We have found a password for an account marked as missing password,
+ // re-check all others accounts missing a password. But first,
+ // remove the error on our own account to avoid re-checking it.
+ delete this._connectionErrorReason;
+ gAccountsService._checkIfPasswordStillMissing();
+ }
+ return this._password;
+ }
+ }
+ return "";
+ },
+ _checkIfPasswordStillMissing() {
+ if (
+ this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD ||
+ !this.password
+ ) {
+ return;
+ }
+
+ delete this._connectionErrorReason;
+ this._sendUpdateNotification();
+ },
+ get _passwordRequired() {
+ return !this.protocol.noPassword && !this.protocol.passwordOptional;
+ },
+ set password(aPassword) {
+ this._password = aPassword;
+ if (gUserCanceledPrimaryPasswordPrompt) {
+ return;
+ }
+ let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ let passwordURI = "im://" + this.protocol.id;
+ newLogin.init(
+ passwordURI,
+ null,
+ passwordURI,
+ this.normalizedName,
+ aPassword,
+ "",
+ ""
+ );
+ try {
+ let logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ let saved = false;
+ for (let login of logins) {
+ if (newLogin.matches(login, true)) {
+ if (aPassword) {
+ Services.logins.modifyLogin(login, newLogin);
+ } else {
+ Services.logins.removeLogin(login);
+ }
+ saved = true;
+ break;
+ }
+ }
+ if (!saved && aPassword) {
+ Services.logins.addLogin(newLogin);
+ }
+ } catch (e) {
+ this._handlePrimaryPasswordException(e);
+ }
+
+ this._connectionInfoChanged();
+ if (
+ aPassword &&
+ this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD
+ ) {
+ this._connectionErrorReason = Ci.imIAccount.NO_ERROR;
+ } else if (!aPassword && this._passwordRequired) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD;
+ }
+ this._sendUpdateNotification();
+ },
+ _handlePrimaryPasswordException(aException) {
+ if (aException.result != Cr.NS_ERROR_ABORT) {
+ throw aException;
+ }
+
+ gUserCanceledPrimaryPasswordPrompt = true;
+ executeSoon(function () {
+ gUserCanceledPrimaryPasswordPrompt = false;
+ });
+ },
+
+ get autoLogin() {
+ return this.prefBranch.getBoolPref(kPrefAccountAutoLogin, true);
+ },
+ set autoLogin(val) {
+ this.prefBranch.setBoolPref(kPrefAccountAutoLogin, val);
+ SavePrefTimer.initTimer();
+ this._sendUpdateNotification();
+ },
+ _autoLoginPending: false,
+ checkAutoLogin() {
+ // No auto-login if: the account has an error at the imIAccount level
+ // (unknown protocol, missing password, first connection crashed),
+ // the account is already connected or connecting, or autoLogin is off.
+ if (
+ this._connectionErrorReason != Ci.prplIAccount.NO_ERROR ||
+ this.connecting ||
+ this.connected ||
+ !this.autoLogin
+ ) {
+ return;
+ }
+
+ this._autoLoginPending = true;
+ AutoLoginCounter.startAutoLogin();
+ try {
+ this.connect();
+ } catch (e) {
+ console.error(e);
+ this._finishedAutoLogin();
+ }
+ },
+ _finishedAutoLogin() {
+ if (!this.hasOwnProperty("_autoLoginPending")) {
+ return;
+ }
+ delete this._autoLoginPending;
+ AutoLoginCounter.finishedAutoLogin();
+ },
+
+ // Delete the account (from the preferences, mozStorage, and call unInit).
+ remove() {
+ let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ let passwordURI = "im://" + this.protocol.id;
+ // Note: the normalizedName may not be exactly right if the
+ // protocol plugin is missing.
+ login.init(passwordURI, null, passwordURI, this.normalizedName, "", "", "");
+ let logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ for (let l of logins) {
+ if (login.matches(l, true)) {
+ Services.logins.removeLogin(l);
+ break;
+ }
+ }
+ if (this.connected || this.connecting) {
+ this.disconnect();
+ }
+ if (this.prplAccount) {
+ this.prplAccount.remove();
+ }
+ this.unInit();
+ IMServices.contacts.forgetAccount(this.numericId);
+ for (let prefName of this.prefBranch.getChildList("")) {
+ this.prefBranch.clearUserPref(prefName);
+ }
+ },
+ unInit() {
+ // remove any pending reconnection timer.
+ this._cancelReconnection();
+
+ // Keeping a status observer could cause an immediate reconnection.
+ this._removeStatusObserver();
+
+ // remove any pending autologin preference used for crash detection.
+ this._finishedAutoLogin();
+
+ // If the first connection was pending on quit, we set it back to unknown.
+ if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ }
+
+ // and make sure we cleanup the save pref timer.
+ SavePrefTimer.unInitTimer();
+
+ if (this.prplAccount) {
+ this.prplAccount.unInit();
+ }
+
+ delete this.protocol;
+ delete this.prplAccount;
+ },
+
+ get _ensurePrplAccount() {
+ if (this.prplAccount) {
+ return this.prplAccount;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ connect() {
+ if (!this.prplAccount) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ if (this._passwordRequired) {
+ // If the previous connection attempt failed because we have a wrong password,
+ // clear the passwor cache so that if there's no password in the password
+ // manager the user gets prompted again.
+ if (
+ this.connectionErrorReason ==
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED
+ ) {
+ delete this._password;
+ }
+
+ let password = this.password;
+ if (!password) {
+ let prompts = Services.prompt;
+ let shouldSave = { value: false };
+ password = { value: "" };
+ if (
+ !prompts.promptPassword(
+ null,
+ lazy._("passwordPromptTitle", this.name),
+ lazy._("passwordPromptText", this.name),
+ password,
+ lazy._("passwordPromptSaveCheckbox"),
+ shouldSave
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSave.value) {
+ this.password = password.value;
+ } else {
+ this._password = password.value;
+ }
+ }
+ }
+
+ if (!this._statusObserver) {
+ this._statusObserver = {
+ observe: function (aSubject, aTopic, aData) {
+ // Disconnect or reconnect the account automatically, otherwise notify
+ // the prplAccount instance.
+ let statusType = aSubject.statusType;
+ let connectionErrorReason = this.connectionErrorReason;
+ if (statusType == Ci.imIStatusInfo.STATUS_OFFLINE) {
+ if (this.connected || this.connecting) {
+ this.prplAccount.disconnect();
+ }
+ this._cancelReconnection();
+ } else if (
+ statusType > Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.disconnected &&
+ (connectionErrorReason == Ci.prplIAccount.NO_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR)
+ ) {
+ this.prplAccount.connect();
+ } else if (this.connected) {
+ this.prplAccount.observe(aSubject, aTopic, aData);
+ }
+ }.bind(this),
+ };
+
+ this.statusInfo.addObserver(this._statusObserver);
+ }
+
+ if (
+ !Services.io.offline &&
+ this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.disconnected
+ ) {
+ this.prplAccount.connect();
+ }
+ },
+ disconnect() {
+ this._removeStatusObserver();
+ if (!this.disconnected) {
+ this._ensurePrplAccount.disconnect();
+ }
+ },
+
+ get disconnected() {
+ return this.connectionState == Ci.imIAccount.STATE_DISCONNECTED;
+ },
+ get connected() {
+ return this.connectionState == Ci.imIAccount.STATE_CONNECTED;
+ },
+ get connecting() {
+ return this.connectionState == Ci.imIAccount.STATE_CONNECTING;
+ },
+ get disconnecting() {
+ return this.connectionState == Ci.imIAccount.STATE_DISCONNECTING;
+ },
+
+ _cancelReconnection() {
+ if (this._reconnectTimer) {
+ clearTimeout(this._reconnectTimer);
+ delete this._reconnectTimer;
+ }
+ delete this.reconnectAttempt;
+ delete this.timeOfNextReconnect;
+ },
+ cancelReconnection() {
+ if (!this.disconnected) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Ensure we don't keep a status observer that could re-enable the
+ // auto-reconnect timers.
+ this.disconnect();
+
+ this._cancelReconnection();
+ },
+ createConversation(aName) {
+ return this._ensurePrplAccount.createConversation(aName);
+ },
+ addBuddy(aTag, aName) {
+ this._ensurePrplAccount.addBuddy(aTag, aName);
+ },
+ loadBuddy(aBuddy, aTag) {
+ if (this.prplAccount) {
+ return this.prplAccount.loadBuddy(aBuddy, aTag);
+ }
+ // Generate dummy account buddies for unknown protocols.
+ return new UnknownAccountBuddy(this, aBuddy, aTag);
+ },
+ requestBuddyInfo(aBuddyName) {
+ this._ensurePrplAccount.requestBuddyInfo(aBuddyName);
+ },
+ getChatRoomFields() {
+ return this._ensurePrplAccount.getChatRoomFields();
+ },
+ getChatRoomDefaultFieldValues(aDefaultChatName) {
+ return this._ensurePrplAccount.getChatRoomDefaultFieldValues(
+ aDefaultChatName
+ );
+ },
+ get canJoinChat() {
+ return this.prplAccount ? this.prplAccount.canJoinChat : false;
+ },
+ joinChat(aComponents) {
+ this._ensurePrplAccount.joinChat(aComponents);
+ },
+ setBool(aName, aVal) {
+ this.prefBranch.setBoolPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setBool(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ setInt(aName, aVal) {
+ this.prefBranch.setIntPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setInt(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ setString(aName, aVal) {
+ this.prefBranch.setStringPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setString(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ save() {
+ SavePrefTimer.saveNow();
+ },
+
+ getSessions() {
+ return this._ensurePrplAccount.getSessions();
+ },
+ get encryptionStatus() {
+ return this._ensurePrplAccount.encryptionStatus;
+ },
+};
+
+var gAccountsService = null;
+
+export function AccountsService() {}
+AccountsService.prototype = {
+ initAccounts() {
+ this._initAutoLoginStatus();
+ this._accounts = [];
+ this._accountsById = {};
+ gAccountsService = this;
+ let accountIdArray = MailServices.accounts.accounts
+ .map(account => account.incomingServer.getCharValue("imAccount"))
+ .filter(accountKey => accountKey?.startsWith(kAccountKeyPrefix));
+ for (let account of accountIdArray) {
+ new imAccount(account);
+ }
+
+ this._prefObserver = this.observe.bind(this);
+ Services.prefs.addObserver(kPrefAccountOrder, this._prefObserver);
+ },
+
+ _prefObserver: null,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "nsPref:changed" || aData != kPrefAccountOrder) {
+ return;
+ }
+
+ const imAccounts = MailServices.accounts.accounts
+ .map(account => account.incomingServer.getCharValue("imAccount"))
+ .filter(k => k?.startsWith(kAccountKeyPrefix))
+ .map(k =>
+ this.getAccountByNumericId(parseInt(k.substr(kAccountKeyPrefix.length)))
+ )
+ .filter(a => a);
+
+ // Only update _accounts if it's a reorder operation
+ if (imAccounts.length == this._accounts.length) {
+ this._accounts = imAccounts;
+ Services.obs.notifyObservers(this, "account-list-updated");
+ }
+ },
+
+ unInitAccounts() {
+ for (let account of this._accounts) {
+ account.unInit();
+ }
+ gAccountsService = null;
+ delete this._accounts;
+ delete this._accountsById;
+ Services.prefs.removeObserver(kPrefAccountOrder, this._prefObserver);
+ delete this._prefObserver;
+ },
+
+ autoLoginStatus: Ci.imIAccountsService.AUTOLOGIN_ENABLED,
+ _initAutoLoginStatus() {
+ /* If auto-login is already disabled, do nothing */
+ if (this.autoLoginStatus != Ci.imIAccountsService.AUTOLOGIN_ENABLED) {
+ return;
+ }
+
+ let prefs = Services.prefs;
+ if (!prefs.getIntPref("messenger.startup.action")) {
+ // the value 0 means that we start without connecting the accounts
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_USER_DISABLED;
+ return;
+ }
+
+ /* Disable auto-login if we are running in safe mode */
+ if (Services.appinfo.inSafeMode) {
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_SAFE_MODE;
+ return;
+ }
+
+ /* Check if we crashed at the last startup during autologin */
+ let autoLoginPending;
+ if (
+ prefs.getPrefType(kPrefAutologinPending) == prefs.PREF_INVALID ||
+ !(autoLoginPending = prefs.getIntPref(kPrefAutologinPending))
+ ) {
+ // if the pref isn't set, then we haven't crashed: keep autologin enabled
+ return;
+ }
+
+ // Last autologin hasn't finished properly.
+ // For now, assume it's because of a crash.
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_CRASH;
+ prefs.deleteBranch(kPrefAutologinPending);
+
+ // If the crash reporter isn't built, we can't know anything more.
+ if (!("nsICrashReporter" in Ci)) {
+ return;
+ }
+
+ try {
+ // Try to get more info with breakpad
+ let lastCrashTime = 0;
+
+ /* Locate the LastCrash file */
+ let lastCrash = Services.dirsvc.get("UAppData", Ci.nsIFile);
+ lastCrash.append("Crash Reports");
+ lastCrash.append("LastCrash");
+ if (lastCrash.exists()) {
+ /* Ok, the file exists, now let's try to read it */
+ let is = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ is.init(lastCrash, -1, 0, 0);
+ sis.init(sis);
+
+ lastCrashTime = parseInt(sis.read(lastCrash.fileSize));
+
+ sis.close();
+ }
+ // The file not existing is totally acceptable, it just means that
+ // either we never crashed or breakpad is not enabled.
+ // In this case, lastCrashTime will keep its 0 initialization value.
+
+ /* dump("autoLoginPending = " + autoLoginPending +
+ ", lastCrash = " + lastCrashTime +
+ ", difference = " + lastCrashTime - autoLoginPending + "\n");*/
+
+ if (lastCrashTime < autoLoginPending) {
+ // the last crash caught by breakpad is older than our last autologin
+ // attempt.
+ // If breakpad is currently enabled, we can be confident that
+ // autologin was interrupted for an exterior reason
+ // (application killed by the user, power outage, ...)
+ try {
+ Services.appinfo
+ .QueryInterface(Ci.nsICrashReporter)
+ .annotateCrashReport("=", "");
+ } catch (e) {
+ // This should fail with NS_ERROR_INVALID_ARG if breakpad is enabled,
+ // and NS_ERROR_NOT_INITIALIZED if it is not.
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED;
+ }
+ }
+ }
+ } catch (e) {
+ // if we failed to get the last crash time, then keep the
+ // AUTOLOGIN_CRASH value in mAutoLoginStatus and return.
+ }
+ },
+
+ processAutoLogin() {
+ if (!this._accounts) {
+ // if we're already shutting down
+ return;
+ }
+
+ for (let account of this._accounts) {
+ account.checkAutoLogin();
+ }
+
+ // Make sure autologin is now enabled, so that we don't display a
+ // message stating that it is disabled and asking the user if it
+ // should be processed now.
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED;
+
+ // Notify observers so that any message stating that autologin is
+ // disabled can be removed
+ Services.obs.notifyObservers(this, "autologin-processed");
+ },
+
+ _checkingIfPasswordStillMissing: false,
+ _checkIfPasswordStillMissing() {
+ // Avoid recursion.
+ if (this._checkingIfPasswordStillMissing) {
+ return;
+ }
+
+ this._checkingIfPasswordStillMissing = true;
+ for (let account of this._accounts) {
+ account._checkIfPasswordStillMissing();
+ }
+ delete this._checkingIfPasswordStillMissing;
+ },
+
+ getAccountById(aAccountId) {
+ if (!aAccountId.startsWith(kAccountKeyPrefix)) {
+ throw Components.Exception(
+ `Invalid id: ${aAccountId}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let id = parseInt(aAccountId.substr(kAccountKeyPrefix.length));
+ return this.getAccountByNumericId(id);
+ },
+
+ _keepAccount(aAccount) {
+ this._accounts.push(aAccount);
+ this._accountsById[aAccount.numericId] = aAccount;
+ },
+ getAccountByNumericId(aAccountId) {
+ return this._accountsById[aAccountId];
+ },
+ getAccounts() {
+ return this._accounts;
+ },
+
+ createAccount(aName, aPrpl) {
+ // Ensure an account with the same name and protocol doesn't already exist.
+ let prpl = IMServices.core.getProtocolById(aPrpl);
+ if (!prpl) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ if (prpl.accountExists(aName)) {
+ console.error("Attempted to create a duplicate account!");
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+
+ /* First get a unique id for the new account. */
+ let id;
+ for (id = 1; ; ++id) {
+ if (this._accountsById.hasOwnProperty(id)) {
+ continue;
+ }
+
+ /* id isn't used by a known account, double check it isn't
+ already used in the sqlite database. This should never
+ happen, except if we have a corrupted profile. */
+ if (!IMServices.contacts.accountIdExists(id)) {
+ break;
+ }
+ Services.console.logStringMessage(
+ "No account " +
+ id +
+ " but there is some data in the buddy list for an account with this number. Your profile may be corrupted."
+ );
+ }
+
+ /* Actually create the new account. */
+ let key = kAccountKeyPrefix + id;
+ let account = new imAccount(key, aName, aPrpl);
+
+ Services.obs.notifyObservers(account, "account-added");
+ return account;
+ },
+
+ deleteAccount(aAccountId) {
+ let account = this.getAccountById(aAccountId);
+ if (!account) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let index = this._accounts.indexOf(account);
+ if (index == -1) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let id = account.numericId;
+ account.remove();
+ this._accounts.splice(index, 1);
+ delete this._accountsById[id];
+ Services.obs.notifyObservers(account, "account-removed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIAccountsService"]),
+ classDescription: "Accounts",
+};
diff --git a/comm/chat/components/src/imCommands.sys.mjs b/comm/chat/components/src/imCommands.sys.mjs
new file mode 100644
index 0000000000..d28bd9a592
--- /dev/null
+++ b/comm/chat/components/src/imCommands.sys.mjs
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/commands.properties")
+);
+
+export function CommandsService() {}
+CommandsService.prototype = {
+ initCommands() {
+ this._commands = {};
+ // The say command is directly implemented in the UI layer, but has a
+ // dummy command registered here so it shows up as a command (e.g. when
+ // using the /help command).
+ this.registerCommand({
+ name: "say",
+ get helpString() {
+ return lazy._("sayHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_HIGH,
+ run(aMsg, aConv) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ });
+
+ this.registerCommand({
+ name: "raw",
+ get helpString() {
+ return lazy._("rawHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_DEFAULT,
+ run(aMsg, aConv) {
+ let conv = IMServices.conversations.getUIConversation(aConv);
+ if (!conv) {
+ return false;
+ }
+ conv.sendMsg(aMsg);
+ return true;
+ },
+ });
+
+ this.registerCommand({
+ // Reference the command service so we can use the internal properties
+ // directly.
+ cmdSrv: this,
+
+ name: "help",
+ get helpString() {
+ return lazy._("helpHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_DEFAULT,
+ run(aMsg, aConv) {
+ aMsg = aMsg.trim();
+ let conv = IMServices.conversations.getUIConversation(aConv);
+ if (!conv) {
+ return false;
+ }
+
+ // Handle when no command is given, list all possible commands that are
+ // available for this conversation (alphabetically).
+ if (!aMsg) {
+ let commands = this.cmdSrv.listCommandsForConversation(aConv);
+ if (!commands.length) {
+ return false;
+ }
+
+ // Concatenate the command names (separated by a comma and space).
+ let cmds = commands
+ .map(aCmd => aCmd.name)
+ .sort()
+ .join(", ");
+ let message = lazy._("commands", cmds);
+
+ // Display the message
+ conv.systemMessage(message);
+ return true;
+ }
+
+ // A command name was given, find the commands that match.
+ let cmdArray = this.cmdSrv._findCommands(aConv, aMsg);
+
+ if (!cmdArray.length) {
+ // No command that matches.
+ let message = lazy._("noCommand", aMsg);
+ conv.systemMessage(message);
+ return true;
+ }
+
+ // Only show the help for the one of the highest priority.
+ let cmd = cmdArray[0];
+
+ let text = cmd.helpString;
+ if (!text) {
+ text = lazy._("noHelp", cmd.name);
+ }
+
+ // Display the message.
+ conv.systemMessage(text);
+ return true;
+ },
+ });
+
+ // Status commands
+ let status = {
+ back: "AVAILABLE",
+ away: "AWAY",
+ busy: "UNAVAILABLE",
+ dnd: "UNAVAILABLE",
+ offline: "OFFLINE",
+ };
+ for (let cmd in status) {
+ let statusValue = Ci.imIStatusInfo["STATUS_" + status[cmd]];
+ this.registerCommand({
+ name: cmd,
+ get helpString() {
+ return lazy._("statusCommand", this.name, lazy._(this.name));
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_HIGH,
+ run(aMsg) {
+ IMServices.core.globalUserStatus.setStatus(statusValue, aMsg);
+ return true;
+ },
+ });
+ }
+ },
+ unInitCommands() {
+ delete this._commands;
+ },
+
+ registerCommand(aCommand, aPrplId) {
+ let name = aCommand.name;
+ if (!name) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (!this._commands.hasOwnProperty(name)) {
+ this._commands[name] = {};
+ }
+ this._commands[name][aPrplId || ""] = aCommand;
+ },
+ unregisterCommand(aCommandName, aPrplId) {
+ if (this._commands.hasOwnProperty(aCommandName)) {
+ let prplId = aPrplId || "";
+ let commands = this._commands[aCommandName];
+ if (commands.hasOwnProperty(prplId)) {
+ delete commands[prplId];
+ }
+ if (!Object.keys(commands).length) {
+ delete this._commands[aCommandName];
+ }
+ }
+ },
+ listCommandsForConversation(aConversation) {
+ let result = [];
+ let prplId = aConversation && aConversation.account.protocol.id;
+ for (let name in this._commands) {
+ let commands = this._commands[name];
+ if (commands.hasOwnProperty("")) {
+ result.push(commands[""]);
+ }
+ if (prplId && commands.hasOwnProperty(prplId)) {
+ result.push(commands[prplId]);
+ }
+ }
+ if (aConversation) {
+ result = result.filter(this._usageContextFilter(aConversation));
+ }
+ return result;
+ },
+ // List only the commands for a protocol (excluding the global commands).
+ listCommandsForProtocol(aPrplId) {
+ if (!aPrplId) {
+ throw new Error("You must provide a prpl ID.");
+ }
+
+ let result = [];
+ for (let name in this._commands) {
+ let commands = this._commands[name];
+ if (commands.hasOwnProperty(aPrplId)) {
+ result.push(commands[aPrplId]);
+ }
+ }
+ return result;
+ },
+ _usageContextFilter(aConversation) {
+ let usageContext =
+ Ci.imICommand["CMD_CONTEXT_" + (aConversation.isChat ? "CHAT" : "IM")];
+ return c => c.usageContext & usageContext;
+ },
+ _findCommands(aConversation, aName) {
+ let prplId = null;
+ if (aConversation) {
+ let account = aConversation.account;
+ if (account.connected) {
+ prplId = account.protocol.id;
+ }
+ }
+
+ let commandNames;
+ // If there is an exact match for the given command name,
+ // don't look at any other commands.
+ if (this._commands.hasOwnProperty(aName)) {
+ commandNames = [aName];
+ } else {
+ // Otherwise, check if there is a partial match.
+ commandNames = Object.keys(this._commands).filter(command =>
+ command.startsWith(aName)
+ );
+ }
+
+ // If a single full command name matches the given (partial)
+ // command name, return the results for that command name. Otherwise,
+ // return an empty array (don't assume a certain command).
+ let cmdArray = [];
+ for (let commandName of commandNames) {
+ let matches = [];
+
+ // Get the 2 possible commands (the global and the proto specific).
+ let commands = this._commands[commandName];
+ if (commands.hasOwnProperty("")) {
+ matches.push(commands[""]);
+ }
+ if (prplId && commands.hasOwnProperty(prplId)) {
+ matches.push(commands[prplId]);
+ }
+
+ // Remove the commands that can't apply in this context.
+ if (aConversation) {
+ matches = matches.filter(this._usageContextFilter(aConversation));
+ }
+
+ if (!matches.length) {
+ continue;
+ }
+
+ // If we have found a second matching command name, return the empty array.
+ if (cmdArray.length) {
+ return [];
+ }
+
+ cmdArray = matches;
+ }
+
+ // Sort the matching commands by priority before returning the array.
+ return cmdArray.sort((a, b) => b.priority - a.priority);
+ },
+ executeCommand(aMessage, aConversation, aReturnedConv) {
+ if (!aMessage) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let matchResult;
+ if (
+ aMessage[0] != "/" ||
+ !(matchResult = /^\/([a-z0-9]+)(?: |$)([\s\S]*)/.exec(aMessage))
+ ) {
+ return false;
+ }
+
+ let [, name, args] = matchResult;
+
+ let cmdArray = this._findCommands(aConversation, name);
+ if (!cmdArray.length) {
+ return false;
+ }
+
+ // cmdArray contains commands sorted by priority, attempt to apply
+ // them in order until one succeeds.
+ if (!cmdArray.some(aCmd => aCmd.run(args, aConversation, aReturnedConv))) {
+ // If they all failed, print help message.
+ this.executeCommand("/help " + name, aConversation);
+ }
+ return true;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imICommandsService"]),
+ classDescription: "Commands",
+};
diff --git a/comm/chat/components/src/imContacts.sys.mjs b/comm/chat/components/src/imContacts.sys.mjs
new file mode 100644
index 0000000000..c902cf4623
--- /dev/null
+++ b/comm/chat/components/src/imContacts.sys.mjs
@@ -0,0 +1,1809 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ executeSoon,
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/contacts.properties")
+);
+
+var gDBConnection = null;
+
+function executeAsyncThenFinalize(statement) {
+ statement.executeAsync();
+ statement.finalize();
+}
+
+function getDBConnection() {
+ const NS_APP_USER_PROFILE_50_DIR = "ProfD";
+ let dbFile = Services.dirsvc.get(NS_APP_USER_PROFILE_50_DIR, Ci.nsIFile);
+ dbFile.append("blist.sqlite");
+
+ let conn = Services.storage.openDatabase(dbFile);
+ if (!conn.connectionReady) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Grow blist db in 512KB increments.
+ try {
+ conn.setGrowthIncrement(512 * 1024, "");
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FILE_TOO_BIG) {
+ Services.console.logStringMessage(
+ "Not setting growth increment on " +
+ "blist.sqlite because the available " +
+ "disk space is limited"
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // Create tables and indexes.
+ [
+ "CREATE TABLE IF NOT EXISTS accounts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "name VARCHAR, " +
+ "prpl VARCHAR)",
+
+ "CREATE TABLE IF NOT EXISTS contacts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "firstname VARCHAR, " +
+ "lastname VARCHAR, " +
+ "alias VARCHAR)",
+
+ "CREATE TABLE IF NOT EXISTS buddies (" +
+ "id INTEGER PRIMARY KEY, " +
+ "key VARCHAR NOT NULL, " +
+ "name VARCHAR NOT NULL, " +
+ "srv_alias VARCHAR, " +
+ "position INTEGER, " +
+ "icon BLOB, " +
+ "contact_id INTEGER)",
+ "CREATE INDEX IF NOT EXISTS buddies_contactindex " +
+ "ON buddies (contact_id)",
+
+ "CREATE TABLE IF NOT EXISTS tags (" +
+ "id INTEGER PRIMARY KEY, " +
+ "name VARCHAR UNIQUE NOT NULL, " +
+ "position INTEGER)",
+
+ "CREATE TABLE IF NOT EXISTS contact_tag (" +
+ "contact_id INTEGER NOT NULL, " +
+ "tag_id INTEGER NOT NULL)",
+ "CREATE INDEX IF NOT EXISTS contact_tag_contactindex " +
+ "ON contact_tag (contact_id)",
+ "CREATE INDEX IF NOT EXISTS contact_tag_tagindex " +
+ "ON contact_tag (tag_id)",
+
+ "CREATE TABLE IF NOT EXISTS account_buddy (" +
+ "account_id INTEGER NOT NULL, " +
+ "buddy_id INTEGER NOT NULL, " +
+ "status VARCHAR, " +
+ "tag_id INTEGER)",
+ "CREATE INDEX IF NOT EXISTS account_buddy_accountindex " +
+ "ON account_buddy (account_id)",
+ "CREATE INDEX IF NOT EXISTS account_buddy_buddyindex " +
+ "ON account_buddy (buddy_id)",
+ ].forEach(conn.executeSimpleSQL);
+
+ return conn;
+}
+
+// Wrap all the usage of DBConn inside a transaction that will be
+// committed automatically at the end of the event loop spin so that
+// we flush buddy list data to disk only once per event loop spin.
+var gDBConnWithPendingTransaction = null;
+Object.defineProperty(lazy, "DBConn", {
+ configurable: true,
+ enumerable: true,
+
+ get() {
+ if (gDBConnWithPendingTransaction) {
+ return gDBConnWithPendingTransaction;
+ }
+
+ if (!gDBConnection) {
+ gDBConnection = getDBConnection();
+ Services.obs.addObserver(function dbClose(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(dbClose, aTopic);
+ if (gDBConnection) {
+ gDBConnection.asyncClose();
+ gDBConnection = null;
+ }
+ }, "profile-before-change");
+ }
+ gDBConnWithPendingTransaction = gDBConnection;
+ gDBConnection.beginTransaction();
+ executeSoon(function () {
+ gDBConnWithPendingTransaction.commitTransaction();
+ gDBConnWithPendingTransaction = null;
+ });
+ return gDBConnection;
+ },
+});
+
+export function TagsService() {}
+TagsService.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+ get defaultTag() {
+ return this.createTag(lazy._("defaultGroup"));
+ },
+ createTag(aName) {
+ // If the tag already exists, we don't want to create a duplicate.
+ let tag = this.getTagByName(aName);
+ if (tag) {
+ return tag;
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO tags (name, position) VALUES(:name, 0)"
+ );
+ try {
+ statement.params.name = aName;
+ statement.executeStep();
+ } finally {
+ statement.finalize();
+ }
+
+ tag = new Tag(lazy.DBConn.lastInsertRowID, aName);
+ Tags.push(tag);
+ return tag;
+ },
+ // Get an existing tag by (numeric) id. Returns null if not found.
+ getTagById: aId => TagsById[aId],
+ // Get an existing tag by name (will do an SQL query). Returns null
+ // if not found.
+ getTagByName(aName) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT id FROM tags where name = :name"
+ );
+ statement.params.name = aName;
+ try {
+ if (!statement.executeStep()) {
+ return null;
+ }
+ return this.getTagById(statement.row.id);
+ } finally {
+ statement.finalize();
+ }
+ },
+ // Get an array of all existing tags.
+ getTags() {
+ if (Tags.length) {
+ Tags.sort((a, b) =>
+ a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+ );
+ } else {
+ this.defaultTag;
+ }
+
+ return Tags;
+ },
+
+ isTagHidden: aTag => aTag.id in otherContactsTag._hiddenTags,
+ hideTag(aTag) {
+ otherContactsTag.hideTag(aTag);
+ },
+ showTag(aTag) {
+ otherContactsTag.showTag(aTag);
+ },
+ get otherContactsTag() {
+ otherContactsTag._initContacts();
+ return otherContactsTag;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imITagsService"]),
+ classDescription: "Tags",
+};
+
+// TODO move into the tagsService
+var Tags = [];
+var TagsById = {};
+
+function Tag(aId, aName) {
+ this._id = aId;
+ this._name = aName;
+ this._contacts = [];
+ this._observers = [];
+
+ TagsById[this.id] = this;
+}
+Tag.prototype = {
+ __proto__: ClassInfo("imITag", "Tag"),
+ get id() {
+ return this._id;
+ },
+ get name() {
+ return this._name;
+ },
+ set name(aNewName) {
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE tags SET name = :name WHERE id = :id"
+ );
+ try {
+ statement.params.name = aNewName;
+ statement.params.id = this._id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // FIXME move the account buddies if some use this tag as their group
+ },
+ getContacts() {
+ return this._contacts.filter(c => !c._empty);
+ },
+ _addContact(aContact) {
+ this._contacts.push(aContact);
+ },
+ _removeContact(aContact) {
+ let index = this._contacts.indexOf(aContact);
+ if (index != -1) {
+ this._contacts.splice(index, 1);
+ }
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ },
+};
+
+var otherContactsTag = {
+ __proto__: ClassInfo(["nsIObserver", "imITag"], "Other Contacts Tag"),
+ hiddenTagsPref: "messenger.buddies.hiddenTags",
+ _hiddenTags: {},
+ _contactsInitialized: false,
+ _saveHiddenTagsPref() {
+ Services.prefs.setCharPref(
+ this.hiddenTagsPref,
+ Object.keys(this._hiddenTags).join(",")
+ );
+ },
+ showTag(aTag) {
+ let id = aTag.id;
+ delete this._hiddenTags[id];
+ let contacts = Object.keys(this._contacts).map(id => this._contacts[id]);
+ for (let contact of contacts) {
+ if (contact.getTags().some(t => t.id == id)) {
+ this._removeContact(contact);
+ }
+ }
+
+ aTag.notifyObservers(aTag, "tag-shown");
+ Services.obs.notifyObservers(aTag, "tag-shown");
+ this._saveHiddenTagsPref();
+ },
+ hideTag(aTag) {
+ if (aTag.id < 0 || aTag.id in otherContactsTag._hiddenTags) {
+ return;
+ }
+
+ this._hiddenTags[aTag.id] = aTag;
+ if (this._contactsInitialized) {
+ this._hideTag(aTag);
+ }
+
+ aTag.notifyObservers(aTag, "tag-hidden");
+ Services.obs.notifyObservers(aTag, "tag-hidden");
+ this._saveHiddenTagsPref();
+ },
+ _hideTag(aTag) {
+ for (let contact of aTag.getContacts()) {
+ if (
+ !(contact.id in this._contacts) &&
+ contact.getTags().every(t => t.id in this._hiddenTags)
+ ) {
+ this._addContact(contact);
+ }
+ }
+ },
+ observe(aSubject, aTopic, aData) {
+ aSubject.QueryInterface(Ci.imIContact);
+ if (aTopic == "contact-tag-removed" || aTopic == "contact-added") {
+ if (
+ !(aSubject.id in this._contacts) &&
+ !(parseInt(aData) in this._hiddenTags) &&
+ aSubject.getTags().every(t => t.id in this._hiddenTags)
+ ) {
+ this._addContact(aSubject);
+ }
+ } else if (
+ aSubject.id in this._contacts &&
+ (aTopic == "contact-removed" ||
+ (aTopic == "contact-tag-added" &&
+ !(parseInt(aData) in this._hiddenTags)))
+ ) {
+ this._removeContact(aSubject);
+ }
+ },
+
+ _initHiddenTags() {
+ let pref = Services.prefs.getCharPref(this.hiddenTagsPref);
+ if (!pref) {
+ return;
+ }
+ for (let tagId of pref.split(",")) {
+ this._hiddenTags[tagId] = TagsById[tagId];
+ }
+ },
+ _initContacts() {
+ if (this._contactsInitialized) {
+ return;
+ }
+ this._observers = [];
+ this._observer = {
+ self: this,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "contact-moved-in" && !(aSubject instanceof Contact)) {
+ return;
+ }
+
+ this.self.notifyObservers(aSubject, aTopic, aData);
+ },
+ };
+ this._contacts = {};
+ this._contactsInitialized = true;
+ for (let id in this._hiddenTags) {
+ let tag = this._hiddenTags[id];
+ this._hideTag(tag);
+ }
+ Services.obs.addObserver(this, "contact-tag-added");
+ Services.obs.addObserver(this, "contact-tag-removed");
+ Services.obs.addObserver(this, "contact-added");
+ Services.obs.addObserver(this, "contact-removed");
+ },
+
+ // imITag implementation
+ get id() {
+ return -1;
+ },
+ get name() {
+ return "__others__";
+ },
+ set name(aNewName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ },
+ getContacts() {
+ return Object.keys(this._contacts).map(id => this._contacts[id]);
+ },
+ _addContact(aContact) {
+ this._contacts[aContact.id] = aContact;
+ this.notifyObservers(aContact, "contact-moved-in");
+ for (let observer of ContactsById[aContact.id]._observers) {
+ observer.observe(this, "contact-moved-in", null);
+ }
+ aContact.addObserver(this._observer);
+ },
+ _removeContact(aContact) {
+ delete this._contacts[aContact.id];
+ aContact.removeObserver(this._observer);
+ this.notifyObservers(aContact, "contact-moved-out");
+ for (let observer of ContactsById[aContact.id]._observers) {
+ observer.observe(this, "contact-moved-out", null);
+ }
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ },
+};
+
+var ContactsById = {};
+var LastDummyContactId = 0;
+function Contact(aId, aAlias) {
+ // Assign a negative id to dummy contacts that have a single buddy
+ this._id = aId || --LastDummyContactId;
+ this._alias = aAlias;
+ this._tags = [];
+ this._buddies = [];
+ this._observers = [];
+
+ ContactsById[this._id] = this;
+}
+Contact.prototype = {
+ __proto__: ClassInfo("imIContact", "Contact"),
+ _id: 0,
+ get id() {
+ return this._id;
+ },
+ get alias() {
+ return this._alias;
+ },
+ set alias(aNewAlias) {
+ this._ensureNotDummy();
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE contacts SET alias = :alias WHERE id = :id"
+ );
+ statement.params.alias = aNewAlias;
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+
+ let oldDisplayName = this.displayName;
+ this._alias = aNewAlias;
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ for (let buddy of this._buddies) {
+ for (let accountBuddy of buddy._accounts) {
+ accountBuddy.serverAlias = aNewAlias;
+ }
+ }
+ },
+ _ensureNotDummy() {
+ if (this._id >= 0) {
+ return;
+ }
+
+ // Create a real contact for this dummy contact
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO contacts DEFAULT VALUES"
+ );
+ try {
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ delete ContactsById[this._id];
+ let oldId = this._id;
+ this._id = lazy.DBConn.lastInsertRowID;
+ ContactsById[this._id] = this;
+ this._notifyObservers("no-longer-dummy", oldId.toString());
+ // Update the contact_id for the single existing buddy of this contact
+ statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET contact_id = :id WHERE id = :buddy_id"
+ );
+ statement.params.id = this._id;
+ statement.params.buddy_id = this._buddies[0].id;
+ executeAsyncThenFinalize(statement);
+ },
+
+ getTags() {
+ return this._tags;
+ },
+ addTag(aTag, aInherited) {
+ if (this.hasTag(aTag)) {
+ return;
+ }
+
+ if (!aInherited) {
+ this._ensureNotDummy();
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO contact_tag (contact_id, tag_id) " +
+ "VALUES(:contactId, :tagId)"
+ );
+ statement.params.contactId = this.id;
+ statement.params.tagId = aTag.id;
+ executeAsyncThenFinalize(statement);
+ }
+
+ aTag = TagsById[aTag.id];
+ this._tags.push(aTag);
+ aTag._addContact(this);
+
+ aTag.notifyObservers(this, "contact-moved-in");
+ for (let observer of this._observers) {
+ observer.observe(aTag, "contact-moved-in", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-added", aTag.id);
+ },
+ /* Remove a tag from the local tags of the contact. */
+ _removeTag(aTag) {
+ if (!this.hasTag(aTag) || this._isTagInherited(aTag)) {
+ return;
+ }
+
+ this._removeContactTagRow(aTag);
+
+ this._tags = this._tags.filter(tag => tag.id != aTag.id);
+ aTag = TagsById[aTag.id];
+ aTag._removeContact(this);
+
+ aTag.notifyObservers(this, "contact-moved-out");
+ for (let observer of this._observers) {
+ observer.observe(aTag, "contact-moved-out", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-removed", aTag.id);
+ },
+ _removeContactTagRow(aTag) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contact_tag " +
+ "WHERE contact_id = :contactId " +
+ "AND tag_id = :tagId"
+ );
+ statement.params.contactId = this.id;
+ statement.params.tagId = aTag.id;
+ executeAsyncThenFinalize(statement);
+ },
+ hasTag(aTag) {
+ return this._tags.some(t => t.id == aTag.id);
+ },
+ _massMove: false,
+ removeTag(aTag) {
+ if (!this.hasTag(aTag)) {
+ throw new Error(
+ "Attempting to remove a tag that the contact doesn't have"
+ );
+ }
+ if (this._tags.length == 1) {
+ throw new Error("Attempting to remove the last tag of a contact");
+ }
+
+ this._massMove = true;
+ let hasTag = this.hasTag.bind(this);
+ let newTag = this._tags[this._tags[0].id != aTag.id ? 0 : 1];
+ let moved = false;
+ this._buddies.forEach(function (aBuddy) {
+ aBuddy._accounts.forEach(function (aAccountBuddy) {
+ if (aAccountBuddy.tag.id == aTag.id) {
+ if (
+ aBuddy._accounts.some(
+ ab =>
+ ab.account.numericId == aAccountBuddy.account.numericId &&
+ ab.tag.id != aTag.id &&
+ hasTag(ab.tag)
+ )
+ ) {
+ // A buddy that already has an accountBuddy of the same
+ // account with another tag of the contact shouldn't be
+ // moved to newTag, just remove the accountBuddy
+ // associated to the tag we are removing.
+ aAccountBuddy.remove();
+ moved = true;
+ } else {
+ try {
+ aAccountBuddy.tag = newTag;
+ moved = true;
+ } catch (e) {
+ // Ignore failures. Some protocol plugins may not implement this.
+ }
+ }
+ }
+ });
+ });
+ this._massMove = false;
+ if (moved) {
+ this._moved(aTag, newTag);
+ } else {
+ // If we are here, the old tag is not inherited from a buddy, so
+ // just remove the local tag.
+ this._removeTag(aTag);
+ }
+ },
+ _isTagInherited(aTag) {
+ for (let buddy of this._buddies) {
+ for (let accountBuddy of buddy._accounts) {
+ if (accountBuddy.tag.id == aTag.id) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ _moved(aOldTag, aNewTag) {
+ if (this._massMove) {
+ return;
+ }
+
+ // Avoid xpconnect wrappers.
+ aNewTag = aNewTag && TagsById[aNewTag.id];
+ aOldTag = aOldTag && TagsById[aOldTag.id];
+
+ // Decide what we need to do. Return early if nothing to do.
+ let shouldRemove =
+ aOldTag && this.hasTag(aOldTag) && !this._isTagInherited(aOldTag);
+ let shouldAdd =
+ aNewTag && !this.hasTag(aNewTag) && this._isTagInherited(aNewTag);
+ if (!shouldRemove && !shouldAdd) {
+ return;
+ }
+
+ // Apply the changes.
+ let tags = this._tags;
+ if (shouldRemove) {
+ tags = tags.filter(aTag => aTag.id != aOldTag.id);
+ aOldTag._removeContact(this);
+ }
+ if (shouldAdd) {
+ tags.push(aNewTag);
+ aNewTag._addContact(this);
+ }
+ this._tags = tags;
+
+ // Finally, notify of the changes.
+ if (shouldRemove) {
+ aOldTag.notifyObservers(this, "contact-moved-out");
+ for (let observer of this._observers) {
+ observer.observe(aOldTag, "contact-moved-out", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-removed", aOldTag.id);
+ }
+ if (shouldAdd) {
+ aNewTag.notifyObservers(this, "contact-moved-in");
+ for (let observer of this._observers) {
+ observer.observe(aNewTag, "contact-moved-in", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-added", aNewTag.id);
+ }
+ Services.obs.notifyObservers(this, "contact-moved");
+ },
+
+ getBuddies() {
+ return this._buddies;
+ },
+ get _empty() {
+ return this._buddies.length == 0 || this._buddies.every(b => b._empty);
+ },
+
+ mergeContact(aContact) {
+ // Avoid merging the contact with itself or merging into an
+ // already removed contact.
+ if (aContact.id == this.id || !(this.id in ContactsById)) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this._ensureNotDummy();
+ let contact = ContactsById[aContact.id]; // remove XPConnect wrapper
+
+ // Copy all the contact-only tags first, otherwise they would be lost.
+ for (let tag of contact.getTags()) {
+ if (!contact._isTagInherited(tag)) {
+ this.addTag(tag);
+ }
+ }
+
+ // Adopt each buddy. Removing the last one will delete the contact.
+ for (let buddy of contact.getBuddies()) {
+ buddy.contact = this;
+ }
+ this._updatePreferredBuddy();
+ },
+ moveBuddyBefore(aBuddy, aBeforeBuddy) {
+ let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
+ let oldPosition = this._buddies.indexOf(buddy);
+ if (oldPosition == -1) {
+ throw new Error("aBuddy isn't attached to this contact");
+ }
+
+ let newPosition = -1;
+ if (aBeforeBuddy) {
+ newPosition = this._buddies.indexOf(BuddiesById[aBeforeBuddy.id]);
+ }
+ if (newPosition == -1) {
+ newPosition = this._buddies.length - 1;
+ }
+
+ if (oldPosition == newPosition) {
+ return;
+ }
+
+ this._buddies.splice(oldPosition, 1);
+ this._buddies.splice(newPosition, 0, buddy);
+ this._updatePositions(
+ Math.min(oldPosition, newPosition),
+ Math.max(oldPosition, newPosition)
+ );
+ buddy._notifyObservers("position-changed", String(newPosition));
+ this._updatePreferredBuddy(buddy);
+ },
+ adoptBuddy(aBuddy) {
+ if (aBuddy.contact.id == this.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
+ buddy.contact = this;
+ this._updatePreferredBuddy(buddy);
+ },
+ _massRemove: false,
+ _removeBuddy(aBuddy) {
+ if (this._buddies.length == 1) {
+ if (this._id > 0) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contacts WHERE id = :id"
+ );
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+ }
+ this._notifyObservers("removed");
+ delete ContactsById[this._id];
+
+ for (let tag of this._tags) {
+ tag._removeContact(this);
+ }
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contact_tag WHERE contact_id = :id"
+ );
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+
+ delete this._tags;
+ delete this._buddies;
+ delete this._observers;
+ } else {
+ let index = this._buddies.indexOf(aBuddy);
+ if (index == -1) {
+ throw new Error("Removing an unknown buddy from contact " + this._id);
+ }
+
+ this._buddies = this._buddies.filter(b => b !== aBuddy);
+
+ // If we are actually removing the whole contact, don't bother updating
+ // the positions or the preferred buddy.
+ if (this._massRemove) {
+ return;
+ }
+
+ // No position to update if the removed buddy is at the last position.
+ if (index < this._buddies.length) {
+ this._updatePositions(index);
+ }
+
+ if (this._preferredBuddy.id == aBuddy.id) {
+ this._updatePreferredBuddy();
+ }
+ }
+ },
+ _updatePositions(aIndexBegin, aIndexEnd) {
+ if (aIndexEnd === undefined) {
+ aIndexEnd = this._buddies.length - 1;
+ }
+ if (aIndexBegin > aIndexEnd) {
+ throw new Error("_updatePositions: Invalid indexes");
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET position = :position WHERE id = :buddyId"
+ );
+ for (let i = aIndexBegin; i <= aIndexEnd; ++i) {
+ statement.params.position = i;
+ statement.params.buddyId = this._buddies[i].id;
+ statement.executeAsync();
+ }
+ statement.finalize();
+ },
+
+ detachBuddy(aBuddy) {
+ // Should return a new contact with the same list of tags.
+ let buddy = BuddiesById[aBuddy.id];
+ if (buddy.contact.id != this.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (buddy.contact._buddies.length == 1) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Save the list of tags, it may be destroyed if the buddy was the last one.
+ let tags = buddy.contact.getTags();
+
+ // Create a new dummy contact and use it for the detached buddy.
+ buddy.contact = new Contact();
+ buddy.contact._notifyObservers("added");
+
+ // The first tag was inherited during the contact setter.
+ // This will copy the remaining tags.
+ for (let tag of tags) {
+ buddy.contact.addTag(tag);
+ }
+
+ return buddy.contact;
+ },
+ remove() {
+ this._massRemove = true;
+ for (let buddy of this._buddies) {
+ buddy.remove();
+ }
+ },
+
+ // imIStatusInfo implementation
+ _preferredBuddy: null,
+ get preferredBuddy() {
+ if (!this._preferredBuddy) {
+ this._updatePreferredBuddy();
+ }
+ return this._preferredBuddy;
+ },
+ set preferredBuddy(aBuddy) {
+ let shouldNotify = this._preferredBuddy != null;
+ let oldDisplayName =
+ this._preferredBuddy && this._preferredBuddy.displayName;
+ this._preferredBuddy = aBuddy;
+ if (shouldNotify) {
+ this._notifyObservers("preferred-buddy-changed");
+ }
+ if (oldDisplayName && this._preferredBuddy.displayName != oldDisplayName) {
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ }
+ this._updateStatus();
+ },
+ // aBuddy indicate which buddy's availability has changed.
+ _updatePreferredBuddy(aBuddy) {
+ if (aBuddy) {
+ aBuddy = BuddiesById[aBuddy.id]; // remove potential XPConnect wrapper
+
+ if (!this._preferredBuddy) {
+ this.preferredBuddy = aBuddy;
+ return;
+ }
+
+ if (aBuddy.id == this._preferredBuddy.id) {
+ // The suggested buddy is already preferred, check if its
+ // availability has changed.
+ if (
+ aBuddy.statusType > this._statusType ||
+ (aBuddy.statusType == this._statusType &&
+ aBuddy.availabilityDetails >= this._availabilityDetails)
+ ) {
+ // keep the currently preferred buddy, only update the status.
+ this._updateStatus();
+ return;
+ }
+ // We aren't sure that the currently preferred buddy should
+ // still be preferred. Let's go through the list!
+ } else {
+ // The suggested buddy is not currently preferred. If it is
+ // more available or at a better position, prefer it!
+ if (
+ aBuddy.statusType > this._statusType ||
+ (aBuddy.statusType == this._statusType &&
+ (aBuddy.availabilityDetails > this._availabilityDetails ||
+ (aBuddy.availabilityDetails == this._availabilityDetails &&
+ this._buddies.indexOf(aBuddy) <
+ this._buddies.indexOf(this.preferredBuddy))))
+ ) {
+ this.preferredBuddy = aBuddy;
+ }
+ return;
+ }
+ }
+
+ let preferred;
+ // |this._buddies| is ordered by user preference, so in case of
+ // equal availability, keep the current value of |preferred|.
+ for (let buddy of this._buddies) {
+ if (
+ !preferred ||
+ preferred.statusType < buddy.statusType ||
+ (preferred.statusType == buddy.statusType &&
+ preferred.availabilityDetails < buddy.availabilityDetails)
+ ) {
+ preferred = buddy;
+ }
+ }
+ if (
+ preferred &&
+ (!this._preferredBuddy || preferred.id != this._preferredBuddy.id)
+ ) {
+ this.preferredBuddy = preferred;
+ }
+ },
+ _updateStatus() {
+ let buddy = this._preferredBuddy; // for convenience
+
+ // Decide which notifications should be fired.
+ let notifications = [];
+ if (
+ this._statusType != buddy.statusType ||
+ this._availabilityDetails != buddy.availabilityDetails
+ ) {
+ notifications.push("availability-changed");
+ }
+ if (
+ this._statusType != buddy.statusType ||
+ this._statusText != buddy.statusText
+ ) {
+ notifications.push("status-changed");
+ if (this.online && buddy.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-off");
+ }
+ if (!this.online && buddy.statusType > Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-on");
+ }
+ }
+
+ // Actually change the stored status.
+ [this._statusType, this._statusText, this._availabilityDetails] = [
+ buddy.statusType,
+ buddy.statusText,
+ buddy.availabilityDetails,
+ ];
+
+ // Fire the notifications.
+ notifications.forEach(function (aTopic) {
+ this._notifyObservers(aTopic);
+ }, this);
+ },
+ get displayName() {
+ return this._alias || this.preferredBuddy.displayName;
+ },
+ get buddyIconFilename() {
+ return this.preferredBuddy.buddyIconFilename;
+ },
+ _statusType: 0,
+ get statusType() {
+ return this._statusType;
+ },
+ get online() {
+ return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
+ },
+ get available() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
+ },
+ get idle() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
+ },
+ get mobile() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
+ },
+ _statusText: "",
+ get statusText() {
+ return this._statusText;
+ },
+ _availabilityDetails: 0,
+ get availabilityDetails() {
+ return this._availabilityDetails;
+ },
+ get canSendMessage() {
+ return this.preferredBuddy.canSendMessage;
+ },
+ // XXX should we list the buddies in the tooltip?
+ getTooltipInfo() {
+ return this.preferredBuddy.getTooltipInfo();
+ },
+ createConversation() {
+ let uiConv = IMServices.conversations.getUIConversationByContactId(this.id);
+ if (uiConv) {
+ return uiConv.target;
+ }
+ return this.preferredBuddy.createConversation();
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ if (!this.hasOwnProperty("_observers")) {
+ return;
+ }
+
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ // internal calls + calls from add-ons
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ if ("observe" in observer) {
+ // avoid failing on destructed XBL bindings...
+ observer.observe(aSubject, aTopic, aData);
+ }
+ }
+ for (let tag of this._tags) {
+ tag.notifyObservers(aSubject, aTopic, aData);
+ }
+ Services.obs.notifyObservers(aSubject, aTopic, aData);
+ },
+ _notifyObservers(aTopic, aData) {
+ this.notifyObservers(this, "contact-" + aTopic, aData);
+ },
+
+ // This is called by the imIBuddy implementations.
+ _observe(aSubject, aTopic, aData) {
+ // Forward the notification.
+ this.notifyObservers(aSubject, aTopic, aData);
+
+ let isPreferredBuddy =
+ aSubject instanceof Buddy && aSubject.id == this.preferredBuddy.id;
+ switch (aTopic) {
+ case "buddy-availability-changed":
+ this._updatePreferredBuddy(aSubject);
+ break;
+ case "buddy-status-changed":
+ if (isPreferredBuddy) {
+ this._updateStatus();
+ }
+ break;
+ case "buddy-display-name-changed":
+ if (isPreferredBuddy && !this._alias) {
+ this._notifyObservers("display-name-changed", aData);
+ }
+ break;
+ case "buddy-icon-changed":
+ if (isPreferredBuddy) {
+ this._notifyObservers("icon-changed");
+ }
+ break;
+ case "buddy-added":
+ // Currently buddies are always added in dummy empty contacts,
+ // later we may want to check this._buddies.length == 1.
+ this._notifyObservers("added");
+ break;
+ case "buddy-removed":
+ this._removeBuddy(aSubject);
+ }
+ },
+};
+
+var BuddiesById = {};
+function Buddy(aId, aKey, aName, aSrvAlias, aContactId) {
+ this._id = aId;
+ this._key = aKey;
+ this._name = aName;
+ if (aSrvAlias) {
+ this._srvAlias = aSrvAlias;
+ }
+ this._accounts = [];
+ this._observers = [];
+
+ if (aContactId) {
+ this._contact = ContactsById[aContactId];
+ }
+ // Avoid failure if aContactId was invalid.
+ if (!this._contact) {
+ this._contact = new Contact(null, null);
+ }
+
+ this._contact._buddies.push(this);
+
+ BuddiesById[this._id] = this;
+}
+Buddy.prototype = {
+ __proto__: ClassInfo("imIBuddy", "Buddy"),
+ get id() {
+ return this._id;
+ },
+ destroy() {
+ for (let ab of this._accounts) {
+ ab.unInit();
+ }
+ delete this._accounts;
+ delete this._observers;
+ delete this._preferredAccount;
+ },
+ get protocol() {
+ return this._accounts[0].account.protocol;
+ },
+ get userName() {
+ return this._name;
+ },
+ get normalizedName() {
+ return this._key;
+ },
+ _srvAlias: "",
+ _contact: null,
+ get contact() {
+ return this._contact;
+ },
+ set contact(aContact) /* not in imIBuddy */ {
+ if (aContact.id == this._contact.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this._notifyObservers("moved-out-of-contact");
+ this._contact._removeBuddy(this);
+
+ this._contact = aContact;
+ this._contact._buddies.push(this);
+
+ // Ensure all the inherited tags are in the new contact.
+ for (let accountBuddy of this._accounts) {
+ this._contact.addTag(TagsById[accountBuddy.tag.id], true);
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET contact_id = :contactId, " +
+ "position = :position " +
+ "WHERE id = :buddyId"
+ );
+ statement.params.contactId = aContact.id > 0 ? aContact.id : 0;
+ statement.params.position = aContact._buddies.length - 1;
+ statement.params.buddyId = this.id;
+ executeAsyncThenFinalize(statement);
+
+ this._notifyObservers("moved-into-contact");
+ },
+ _hasAccountBuddy(aAccountId, aTagId) {
+ for (let ab of this._accounts) {
+ if (ab.account.numericId == aAccountId && ab.tag.id == aTagId) {
+ return true;
+ }
+ }
+ return false;
+ },
+ getAccountBuddies() {
+ return this._accounts;
+ },
+
+ _addAccount(aAccountBuddy, aTag) {
+ this._accounts.push(aAccountBuddy);
+ let contact = this._contact;
+ if (!this._contact._tags.includes(aTag)) {
+ this._contact._tags.push(aTag);
+ aTag._addContact(contact);
+ }
+
+ if (!this._preferredAccount) {
+ this._preferredAccount = aAccountBuddy;
+ }
+ },
+ get _empty() {
+ return this._accounts.length == 0;
+ },
+
+ remove() {
+ for (let account of this._accounts) {
+ account.remove();
+ }
+ },
+
+ // imIStatusInfo implementation
+ _preferredAccount: null,
+ get preferredAccountBuddy() {
+ return this._preferredAccount;
+ },
+ _isPreferredAccount(aAccountBuddy) {
+ if (
+ aAccountBuddy.account.numericId !=
+ this._preferredAccount.account.numericId
+ ) {
+ return false;
+ }
+
+ // In case we have more than one accountBuddy for the same buddy
+ // and account (possible if the buddy is in several groups on the
+ // server), the protocol plugin may be broken and not update all
+ // instances, so ensure we handle the notifications on the instance
+ // that is currently being notified of a change:
+ this._preferredAccount = aAccountBuddy;
+
+ return true;
+ },
+ set preferredAccount(aAccount) {
+ let oldDisplayName =
+ this._preferredAccount && this._preferredAccount.displayName;
+ this._preferredAccount = aAccount;
+ this._notifyObservers("preferred-account-changed");
+ if (
+ oldDisplayName &&
+ this._preferredAccount.displayName != oldDisplayName
+ ) {
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ }
+ this._updateStatus();
+ },
+ // aAccount indicate which account's availability has changed.
+ _updatePreferredAccount(aAccount) {
+ if (aAccount) {
+ if (
+ aAccount.account.numericId == this._preferredAccount.account.numericId
+ ) {
+ // The suggested account is already preferred, check if its
+ // availability has changed.
+ if (
+ aAccount.statusType > this._statusType ||
+ (aAccount.statusType == this._statusType &&
+ aAccount.availabilityDetails >= this._availabilityDetails)
+ ) {
+ // keep the currently preferred account, only update the status.
+ this._updateStatus();
+ return;
+ }
+ // We aren't sure that the currently preferred account should
+ // still be preferred. Let's go through the list!
+ } else {
+ // The suggested account is not currently preferred. If it is
+ // more available, prefer it!
+ if (
+ aAccount.statusType > this._statusType ||
+ (aAccount.statusType == this._statusType &&
+ aAccount.availabilityDetails > this._availabilityDetails)
+ ) {
+ this.preferredAccount = aAccount;
+ }
+ return;
+ }
+ }
+
+ let preferred;
+ // TODO take into account the order of the account-manager list.
+ for (let account of this._accounts) {
+ if (
+ !preferred ||
+ preferred.statusType < account.statusType ||
+ (preferred.statusType == account.statusType &&
+ preferred.availabilityDetails < account.availabilityDetails)
+ ) {
+ preferred = account;
+ }
+ }
+ if (!this._preferredAccount) {
+ if (preferred) {
+ this.preferredAccount = preferred;
+ }
+ return;
+ }
+ if (
+ preferred.account.numericId != this._preferredAccount.account.numericId
+ ) {
+ this.preferredAccount = preferred;
+ } else {
+ this._updateStatus();
+ }
+ },
+ _updateStatus() {
+ let account = this._preferredAccount; // for convenience
+
+ // Decide which notifications should be fired.
+ let notifications = [];
+ if (
+ this._statusType != account.statusType ||
+ this._availabilityDetails != account.availabilityDetails
+ ) {
+ notifications.push("availability-changed");
+ }
+ if (
+ this._statusType != account.statusType ||
+ this._statusText != account.statusText
+ ) {
+ notifications.push("status-changed");
+ if (
+ this.online &&
+ account.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ notifications.push("signed-off");
+ }
+ if (
+ !this.online &&
+ account.statusType > Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ notifications.push("signed-on");
+ }
+ }
+
+ // Actually change the stored status.
+ [this._statusType, this._statusText, this._availabilityDetails] = [
+ account.statusType,
+ account.statusText,
+ account.availabilityDetails,
+ ];
+
+ // Fire the notifications.
+ notifications.forEach(function (aTopic) {
+ this._notifyObservers(aTopic);
+ }, this);
+ },
+ get displayName() {
+ return (
+ (this._preferredAccount && this._preferredAccount.displayName) ||
+ this._srvAlias ||
+ this._name
+ );
+ },
+ get buddyIconFilename() {
+ return this._preferredAccount.buddyIconFilename;
+ },
+ _statusType: 0,
+ get statusType() {
+ return this._statusType;
+ },
+ get online() {
+ return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
+ },
+ get available() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
+ },
+ get idle() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
+ },
+ get mobile() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
+ },
+ _statusText: "",
+ get statusText() {
+ return this._statusText;
+ },
+ _availabilityDetails: 0,
+ get availabilityDetails() {
+ return this._availabilityDetails;
+ },
+ get canSendMessage() {
+ return this._preferredAccount.canSendMessage;
+ },
+ // XXX should we list the accounts in the tooltip?
+ getTooltipInfo() {
+ return this._preferredAccount.getTooltipInfo();
+ },
+ createConversation() {
+ return this._preferredAccount.createConversation();
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ if (!this._observers) {
+ return;
+ }
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ // internal calls + calls from add-ons
+ notifyObservers(aSubject, aTopic, aData) {
+ try {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ this._contact._observe(aSubject, aTopic, aData);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ _notifyObservers(aTopic, aData) {
+ this.notifyObservers(this, "buddy-" + aTopic, aData);
+ },
+
+ // This is called by the prplIAccountBuddy implementations.
+ observe(aSubject, aTopic, aData) {
+ // Forward the notification.
+ this.notifyObservers(aSubject, aTopic, aData);
+
+ switch (aTopic) {
+ case "account-buddy-availability-changed":
+ this._updatePreferredAccount(aSubject);
+ break;
+ case "account-buddy-status-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._updateStatus();
+ }
+ break;
+ case "account-buddy-display-name-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._srvAlias =
+ this.displayName != this.userName ? this.displayName : "";
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET srv_alias = :srvAlias WHERE id = :buddyId"
+ );
+ statement.params.buddyId = this.id;
+ statement.params.srvAlias = this._srvAlias;
+ executeAsyncThenFinalize(statement);
+ this._notifyObservers("display-name-changed", aData);
+ }
+ break;
+ case "account-buddy-icon-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._notifyObservers("icon-changed");
+ }
+ break;
+ case "account-buddy-added":
+ if (this._accounts.length == 0) {
+ // Add the new account in the empty buddy instance.
+ // The TagsById hack is to bypass the xpconnect wrapper.
+ this._addAccount(aSubject, TagsById[aSubject.tag.id]);
+ this._updateStatus();
+ this._notifyObservers("added");
+ } else {
+ this._accounts.push(aSubject);
+ this.contact._moved(null, aSubject.tag);
+ this._updatePreferredAccount(aSubject);
+ }
+ break;
+ case "account-buddy-removed":
+ if (this._accounts.length == 1) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM buddies WHERE id = :id"
+ );
+ try {
+ statement.params.id = this.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ this._notifyObservers("removed");
+
+ delete BuddiesById[this._id];
+ this.destroy();
+ } else {
+ this._accounts = this._accounts.filter(function (ab) {
+ return (
+ ab.account.numericId != aSubject.account.numericId ||
+ ab.tag.id != aSubject.tag.id
+ );
+ });
+ if (
+ this._preferredAccount.account.numericId ==
+ aSubject.account.numericId &&
+ this._preferredAccount.tag.id == aSubject.tag.id
+ ) {
+ this._preferredAccount = null;
+ this._updatePreferredAccount();
+ }
+ this.contact._moved(aSubject.tag);
+ }
+ break;
+ }
+ },
+};
+
+export function ContactsService() {}
+ContactsService.prototype = {
+ initContacts() {
+ let statement = lazy.DBConn.createStatement("SELECT id, name FROM tags");
+ try {
+ while (statement.executeStep()) {
+ Tags.push(new Tag(statement.getInt32(0), statement.getUTF8String(1)));
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement("SELECT id, alias FROM contacts");
+ try {
+ while (statement.executeStep()) {
+ new Contact(statement.getInt32(0), statement.getUTF8String(1));
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT contact_id, tag_id FROM contact_tag"
+ );
+ try {
+ while (statement.executeStep()) {
+ let contact = ContactsById[statement.getInt32(0)];
+ let tag = TagsById[statement.getInt32(1)];
+ contact._tags.push(tag);
+ tag._addContact(contact);
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT id, key, name, srv_alias, contact_id FROM buddies ORDER BY position"
+ );
+ try {
+ while (statement.executeStep()) {
+ new Buddy(
+ statement.getInt32(0),
+ statement.getUTF8String(1),
+ statement.getUTF8String(2),
+ statement.getUTF8String(3),
+ statement.getInt32(4)
+ );
+ // FIXME is there a way to enforce that all AccountBuddies of a Buddy have the same protocol?
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT account_id, buddy_id, tag_id FROM account_buddy"
+ );
+ try {
+ while (statement.executeStep()) {
+ let accountId = statement.getInt32(0);
+ let buddyId = statement.getInt32(1);
+ let tagId = statement.getInt32(2);
+
+ let account = IMServices.accounts.getAccountByNumericId(accountId);
+ // If the account was deleted without properly cleaning up the
+ // account_buddy, skip loading this account buddy.
+ if (!account) {
+ continue;
+ }
+
+ if (!BuddiesById.hasOwnProperty(buddyId)) {
+ console.error(
+ "Corrupted database: account_buddy entry for account " +
+ accountId +
+ " and tag " +
+ tagId +
+ " references unknown buddy with id " +
+ buddyId
+ );
+ continue;
+ }
+
+ let buddy = BuddiesById[buddyId];
+ if (buddy._hasAccountBuddy(accountId, tagId)) {
+ console.error(
+ "Corrupted database: duplicated account_buddy entry: " +
+ "account_id = " +
+ accountId +
+ ", buddy_id = " +
+ buddyId +
+ ", tag_id = " +
+ tagId
+ );
+ continue;
+ }
+
+ let tag = TagsById[tagId];
+ try {
+ buddy._addAccount(account.loadBuddy(buddy, tag), tag);
+ } catch (e) {
+ console.error(e);
+ dump(e + "\n");
+ }
+ }
+ } finally {
+ statement.finalize();
+ }
+ otherContactsTag._initHiddenTags();
+ },
+ unInitContacts() {
+ Tags = [];
+ TagsById = {};
+ // Avoid shutdown leaks caused by references to native components
+ // implementing prplIAccountBuddy.
+ for (let buddyId in BuddiesById) {
+ let buddy = BuddiesById[buddyId];
+ buddy.destroy();
+ }
+ BuddiesById = {};
+ ContactsById = {};
+ },
+
+ getContactById: aId => ContactsById[aId],
+ // Get an array of all existing contacts.
+ getContacts() {
+ return Object.keys(ContactsById)
+ .filter(id => !ContactsById[id]._empty)
+ .map(id => ContactsById[id]);
+ },
+ getBuddyById: aId => BuddiesById[aId],
+ getBuddyByNameAndProtocol(aNormalizedName, aPrpl) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT b.id FROM buddies b " +
+ "JOIN account_buddy ab ON buddy_id = b.id " +
+ "JOIN accounts a ON account_id = a.id " +
+ "WHERE b.key = :buddyName and a.prpl = :prplId"
+ );
+ statement.params.buddyName = aNormalizedName;
+ statement.params.prplId = aPrpl.id;
+ try {
+ if (!statement.executeStep()) {
+ return null;
+ }
+ return BuddiesById[statement.row.id];
+ } finally {
+ statement.finalize();
+ }
+ },
+ getAccountBuddyByNameAndAccount(aNormalizedName, aAccount) {
+ let buddy = this.getBuddyByNameAndProtocol(
+ aNormalizedName,
+ aAccount.protocol
+ );
+ if (buddy) {
+ let id = aAccount.id;
+ for (let accountBuddy of buddy.getAccountBuddies()) {
+ if (accountBuddy.account.id == id) {
+ return accountBuddy;
+ }
+ }
+ }
+ return null;
+ },
+
+ accountBuddyAdded(aAccountBuddy) {
+ let account = aAccountBuddy.account;
+ let normalizedName = aAccountBuddy.normalizedName;
+ let buddy = this.getBuddyByNameAndProtocol(
+ normalizedName,
+ account.protocol
+ );
+ if (!buddy) {
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO buddies " +
+ "(key, name, srv_alias, position) " +
+ "VALUES(:key, :name, :srvAlias, 0)"
+ );
+ try {
+ let name = aAccountBuddy.userName;
+ let srvAlias = aAccountBuddy.serverAlias;
+ statement.params.key = normalizedName;
+ statement.params.name = name;
+ statement.params.srvAlias = srvAlias;
+ statement.execute();
+ buddy = new Buddy(
+ lazy.DBConn.lastInsertRowID,
+ normalizedName,
+ name,
+ srvAlias,
+ 0
+ );
+ } finally {
+ statement.finalize();
+ }
+ }
+
+ // Initialize the 'buddy' field of the prplIAccountBuddy instance.
+ aAccountBuddy.buddy = buddy;
+
+ // Ensure we aren't storing a duplicate entry.
+ let accountId = account.numericId;
+ let tagId = aAccountBuddy.tag.id;
+ if (buddy._hasAccountBuddy(accountId, tagId)) {
+ console.error(
+ "Attempting to store a duplicate account buddy " +
+ normalizedName +
+ ", account id = " +
+ accountId +
+ ", tag id = " +
+ tagId
+ );
+ return;
+ }
+
+ // Store the new account buddy.
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO account_buddy " +
+ "(account_id, buddy_id, tag_id) " +
+ "VALUES(:accountId, :buddyId, :tagId)"
+ );
+ try {
+ statement.params.accountId = accountId;
+ statement.params.buddyId = buddy.id;
+ statement.params.tagId = tagId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // Fire the notifications.
+ buddy.observe(aAccountBuddy, "account-buddy-added");
+ },
+ accountBuddyRemoved(aAccountBuddy) {
+ let buddy = aAccountBuddy.buddy;
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM account_buddy " +
+ "WHERE account_id = :accountId AND " +
+ "buddy_id = :buddyId AND " +
+ "tag_id = :tagId"
+ );
+ try {
+ statement.params.accountId = aAccountBuddy.account.numericId;
+ statement.params.buddyId = buddy.id;
+ statement.params.tagId = aAccountBuddy.tag.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ buddy.observe(aAccountBuddy, "account-buddy-removed");
+ },
+
+ accountBuddyMoved(aAccountBuddy, aOldTag, aNewTag) {
+ let buddy = aAccountBuddy.buddy;
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE account_buddy " +
+ "SET tag_id = :newTagId " +
+ "WHERE account_id = :accountId AND " +
+ "buddy_id = :buddyId AND " +
+ "tag_id = :oldTagId"
+ );
+ try {
+ statement.params.accountId = aAccountBuddy.account.numericId;
+ statement.params.buddyId = buddy.id;
+ statement.params.oldTagId = aOldTag.id;
+ statement.params.newTagId = aNewTag.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ let contact = ContactsById[buddy.contact.id];
+
+ // aNewTag is now inherited by the contact from an account buddy, so avoid
+ // keeping direct tag <-> contact links in the contact_tag table.
+ contact._removeContactTagRow(aNewTag);
+
+ buddy.observe(aAccountBuddy, "account-buddy-moved");
+ contact._moved(aOldTag, aNewTag);
+ },
+
+ storeAccount(aId, aUserName, aPrplId) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT name, prpl FROM accounts WHERE id = :id"
+ );
+ statement.params.id = aId;
+ try {
+ if (statement.executeStep()) {
+ if (
+ statement.getUTF8String(0) == aUserName &&
+ statement.getUTF8String(1) == aPrplId
+ ) {
+ // The account is already stored correctly.
+ return;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); // Corrupted database?!?
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ // Actually store the account.
+ statement = lazy.DBConn.createStatement(
+ "INSERT INTO accounts (id, name, prpl) " +
+ "VALUES(:id, :userName, :prplId)"
+ );
+ try {
+ statement.params.id = aId;
+ statement.params.userName = aUserName;
+ statement.params.prplId = aPrplId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ },
+ accountIdExists(aId) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT id FROM accounts WHERE id = :id"
+ );
+ try {
+ statement.params.id = aId;
+ return statement.executeStep();
+ } finally {
+ statement.finalize();
+ }
+ },
+ forgetAccount(aId) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM accounts WHERE id = :accountId"
+ );
+ try {
+ statement.params.accountId = aId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // removing the account from the accounts table is not enough,
+ // we need to remove all the associated account_buddy entries too
+ statement = lazy.DBConn.createStatement(
+ "DELETE FROM account_buddy WHERE account_id = :accountId"
+ );
+ try {
+ statement.params.accountId = aId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIContactsService"]),
+ classDescription: "Contacts",
+};
diff --git a/comm/chat/components/src/imConversations.sys.mjs b/comm/chat/components/src/imConversations.sys.mjs
new file mode 100644
index 0000000000..069ef24fd9
--- /dev/null
+++ b/comm/chat/components/src/imConversations.sys.mjs
@@ -0,0 +1,951 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Status } from "resource:///modules/imStatusUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { ClassInfo } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { Message } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+var gLastUIConvId = 0;
+var gLastPrplConvId = 0;
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "bundle", () =>
+ Services.strings.createBundle("chrome://chat/locale/conversations.properties")
+);
+
+export function imMessage(aPrplMessage) {
+ this.prplMessage = aPrplMessage;
+}
+
+imMessage.prototype = {
+ __proto__: ClassInfo(["imIMessage", "prplIMessage"], "IM Message"),
+ cancelled: false,
+ color: "",
+ _displayMessage: null,
+ otrEncrypted: false,
+
+ get displayMessage() {
+ // Explicitly test for null so that blank messages don't fall back to
+ // the original. Especially problematic in encryption extensions like OTR.
+ return this._displayMessage !== null
+ ? this._displayMessage
+ : this.prplMessage.originalMessage;
+ },
+ set displayMessage(aMsg) {
+ this._displayMessage = aMsg;
+ },
+
+ get message() {
+ return this.prplMessage.message;
+ },
+ set message(aMsg) {
+ this.prplMessage.message = aMsg;
+ },
+
+ // from prplIMessage
+ get who() {
+ return this.prplMessage.who;
+ },
+ get time() {
+ return this.prplMessage.time;
+ },
+ get id() {
+ return this.prplMessage.id;
+ },
+ get remoteId() {
+ return this.prplMessage.remoteId;
+ },
+ get alias() {
+ return this.prplMessage.alias;
+ },
+ get iconURL() {
+ return this.prplMessage.iconURL;
+ },
+ get conversation() {
+ return this.prplMessage.conversation;
+ },
+ set conversation(aConv) {
+ this.prplMessage.conversation = aConv;
+ },
+ get outgoing() {
+ return this.prplMessage.outgoing;
+ },
+ get incoming() {
+ return this.prplMessage.incoming;
+ },
+ get system() {
+ return this.prplMessage.system;
+ },
+ get autoResponse() {
+ return this.prplMessage.autoResponse;
+ },
+ get containsNick() {
+ return this.prplMessage.containsNick;
+ },
+ get noLog() {
+ return this.prplMessage.noLog;
+ },
+ get error() {
+ return this.prplMessage.error;
+ },
+ get delayed() {
+ return this.prplMessage.delayed;
+ },
+ get noFormat() {
+ return this.prplMessage.noFormat;
+ },
+ get containsImages() {
+ return this.prplMessage.containsImages;
+ },
+ get notification() {
+ return this.prplMessage.notification;
+ },
+ get noLinkification() {
+ return this.prplMessage.noLinkification;
+ },
+ get noCollapse() {
+ return this.prplMessage.noCollapse;
+ },
+ get isEncrypted() {
+ return this.prplMessage.isEncrypted || this.otrEncrypted;
+ },
+ get action() {
+ return this.prplMessage.action;
+ },
+ get deleted() {
+ return this.prplMessage.deleted;
+ },
+ get originalMessage() {
+ return this.prplMessage.originalMessage;
+ },
+ getActions() {
+ return this.prplMessage.getActions();
+ },
+ whenDisplayed() {
+ return this.prplMessage.whenDisplayed();
+ },
+ whenRead() {
+ return this.prplMessage.whenRead();
+ },
+};
+
+/**
+ * @param {prplIConversation} aPrplConversation
+ * @param {number} [idToReuse] - ID to use for this UI conversation if it replaces another UI conversation.
+ */
+export function UIConversation(aPrplConversation, idToReuse) {
+ this._prplConv = {};
+ if (idToReuse) {
+ this.id = idToReuse;
+ } else {
+ this.id = ++gLastUIConvId;
+ }
+ // Observers listening to this instance's notifications.
+ this._observers = [];
+ // Observers this instance has attached to prplIConversations.
+ this._convObservers = new WeakMap();
+ this._messages = [];
+ this.changeTargetTo(aPrplConversation);
+ let iface = Ci["prplIConv" + (aPrplConversation.isChat ? "Chat" : "IM")];
+ this._interfaces = this._interfaces.concat(iface);
+ // XPConnect will create a wrapper around 'this' after here,
+ // so the list of exposed interfaces shouldn't change anymore.
+ this.updateContactObserver();
+ if (!idToReuse) {
+ Services.obs.notifyObservers(this, "new-ui-conversation");
+ }
+}
+
+UIConversation.prototype = {
+ __proto__: ClassInfo(
+ ["imIConversation", "prplIConversation", "nsIObserver"],
+ "UI conversation"
+ ),
+ _observedContact: null,
+ get contact() {
+ let target = this.target;
+ if (!target.isChat && target.buddy) {
+ return target.buddy.buddy.contact;
+ }
+ return null;
+ },
+ updateContactObserver() {
+ let contact = this.contact;
+ if (contact && !this._observedContact) {
+ contact.addObserver(this);
+ this._observedContact = contact;
+ } else if (!contact && this.observedContact) {
+ this._observedContact.removeObserver(this);
+ delete this._observedContact;
+ }
+ },
+ /**
+ * @type {prplIConversation}
+ */
+ get target() {
+ return this._prplConv[this._currentTargetId];
+ },
+ set target(aPrplConversation) {
+ this.changeTargetTo(aPrplConversation);
+ },
+ get hasMultipleTargets() {
+ return Object.keys(this._prplConv).length > 1;
+ },
+ getTargetByAccount(aAccount) {
+ let accountId = aAccount.id;
+ for (let id in this._prplConv) {
+ let prplConv = this._prplConv[id];
+ if (prplConv.account.id == accountId) {
+ return prplConv;
+ }
+ }
+ return null;
+ },
+ _currentTargetId: 0,
+ changeTargetTo(aPrplConversation) {
+ let id = aPrplConversation.id;
+ if (this._currentTargetId == id) {
+ return;
+ }
+
+ if (!(id in this._prplConv)) {
+ this._prplConv[id] = aPrplConversation;
+ let observeConv = this.observeConv.bind(this, id);
+ this._convObservers.set(aPrplConversation, observeConv);
+ aPrplConversation.addObserver(observeConv);
+ }
+
+ let shouldNotify = this._currentTargetId;
+ this._currentTargetId = id;
+ if (!this.isChat) {
+ let buddy = this.buddy;
+ if (buddy) {
+ ({ statusType: this.statusType, statusText: this.statusText } = buddy);
+ }
+ }
+ if (shouldNotify) {
+ this.notifyObservers(this, "target-prpl-conversation-changed");
+ let target = this.target;
+ let params = [target.title, target.account.protocol.name];
+ this.systemMessage(
+ lazy.bundle.formatStringFromName("targetChanged", params)
+ );
+ }
+ },
+ // Returns a boolean indicating if the ui-conversation was closed.
+ // If the conversation was closed, aContactId.value is set to the contact id
+ // or 0 if no contact was associated with the conversation.
+ removeTarget(aPrplConversation, aContactId) {
+ let id = aPrplConversation.id;
+ if (!(id in this._prplConv)) {
+ throw new Error("unknown prpl conversation");
+ }
+
+ delete this._prplConv[id];
+ if (this._currentTargetId != id) {
+ return false;
+ }
+
+ for (let newId in this._prplConv) {
+ this.changeTargetTo(this._prplConv[newId]);
+ return false;
+ }
+
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ aContactId.value = this._observedContact.id;
+ delete this._observedContact;
+ } else {
+ aContactId.value = 0;
+ }
+
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-closed");
+ return true;
+ },
+
+ _unreadMessageCount: 0,
+ get unreadMessageCount() {
+ return this._unreadMessageCount;
+ },
+ _unreadTargetedMessageCount: 0,
+ get unreadTargetedMessageCount() {
+ return this._unreadTargetedMessageCount;
+ },
+ _unreadIncomingMessageCount: 0,
+ get unreadIncomingMessageCount() {
+ return this._unreadIncomingMessageCount;
+ },
+ _unreadOTRNotificationCount: 0,
+ get unreadOTRNotificationCount() {
+ return this._unreadOTRNotificationCount;
+ },
+ markAsRead() {
+ delete this._unreadMessageCount;
+ delete this._unreadTargetedMessageCount;
+ delete this._unreadIncomingMessageCount;
+ delete this._unreadOTRNotificationCount;
+ if (this._messages.length) {
+ this._messages[this._messages.length - 1].whenDisplayed();
+ }
+ this._notifyUnreadCountChanged();
+ },
+ _lastNotifiedUnreadCount: 0,
+ _notifyUnreadCountChanged() {
+ if (this._unreadIncomingMessageCount == this._lastNotifiedUnreadCount) {
+ return;
+ }
+
+ this._lastNotifiedUnreadCount = this._unreadIncomingMessageCount;
+ for (let observer of this._observers) {
+ observer.observe(
+ this,
+ "unread-message-count-changed",
+ this._unreadIncomingMessageCount.toString()
+ );
+ }
+ },
+ getMessages() {
+ return this._messages;
+ },
+ checkClose() {
+ if (!this._currentTargetId) {
+ // Already closed.
+ return true;
+ }
+
+ if (
+ !Services.prefs.getBoolPref("messenger.conversations.alwaysClose") &&
+ ((this.isChat && !this.left) ||
+ (!this.isChat &&
+ (this.unreadIncomingMessageCount != 0 ||
+ Services.prefs.getBoolPref(
+ "messenger.conversations.holdByDefault"
+ ))))
+ ) {
+ return false;
+ }
+
+ this.close();
+ return true;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "contact-no-longer-dummy") {
+ let oldId = parseInt(aData);
+ // gConversationsService is ugly... :(
+ delete gConversationsService._uiConvByContactId[oldId];
+ gConversationsService._uiConvByContactId[aSubject.id] = this;
+ } else if (aTopic == "account-buddy-status-changed") {
+ if (
+ !this._statusUpdatePending &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this._statusUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this.updateBuddyStatus.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+ } else if (aTopic == "account-buddy-icon-changed") {
+ if (
+ !this._statusUpdatePending &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this._iconUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this.updateIcon.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+ } else if (
+ aTopic == "account-buddy-display-name-changed" &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this.notifyObservers(this, "update-buddy-display-name");
+ }
+ },
+
+ _iconUpdatePending: false,
+ updateIcon() {
+ delete this._iconUpdatePending;
+ this.notifyObservers(this, "update-buddy-icon");
+ },
+
+ _statusUpdatePending: false,
+ updateBuddyStatus() {
+ delete this._statusUpdatePending;
+ let { statusType: statusType, statusText: statusText } = this.buddy;
+
+ if (
+ "statusType" in this &&
+ this.statusType == statusType &&
+ this.statusText == statusText
+ ) {
+ return;
+ }
+
+ let wasUnknown = this.statusType == Ci.imIStatusInfo.STATUS_UNKNOWN;
+ this.statusType = statusType;
+ this.statusText = statusText;
+
+ this.notifyObservers(this, "update-buddy-status");
+
+ let msg;
+ if (statusType == Ci.imIStatusInfo.STATUS_UNKNOWN) {
+ msg = lazy.bundle.formatStringFromName("statusUnknown", [this.title]);
+ } else {
+ let status = Status.toLabel(statusType);
+ let stringId = wasUnknown ? "statusChangedFromUnknown" : "statusChanged";
+ if (this._justReconnected) {
+ stringId = "statusKnown";
+ delete this._justReconnected;
+ }
+ if (statusText) {
+ msg = lazy.bundle.formatStringFromName(stringId + "WithStatusText", [
+ this.title,
+ status,
+ statusText,
+ ]);
+ } else {
+ msg = lazy.bundle.formatStringFromName(stringId, [this.title, status]);
+ }
+ }
+ this.systemMessage(msg);
+ },
+
+ _disconnected: false,
+ disconnecting() {
+ if (this._disconnected) {
+ return;
+ }
+
+ this._disconnected = true;
+ if (this.contact) {
+ // Handled by the contact observer.
+ return;
+ }
+
+ if (this.isChat && this.left) {
+ this._wasLeft = true;
+ } else {
+ this.systemMessage(lazy.bundle.GetStringFromName("accountDisconnected"));
+ }
+ this.notifyObservers(this, "update-buddy-status");
+ },
+ connected() {
+ if (this._disconnected) {
+ delete this._disconnected;
+ let msg = lazy.bundle.GetStringFromName("accountReconnected");
+ if (this.isChat) {
+ if (!this._wasLeft) {
+ this.systemMessage(msg);
+ // Reconnect chat if possible.
+ let chatRoomFields = this.target.chatRoomFields;
+ if (chatRoomFields) {
+ this.account.joinChat(chatRoomFields);
+ }
+ }
+ delete this._wasLeft;
+ } else {
+ this._justReconnected = true;
+ // Exclude convs with contacts, these receive presence info updates
+ // (and therefore a reconnected message).
+ if (!this.contact) {
+ this.systemMessage(msg);
+ }
+ }
+ }
+ this.notifyObservers(this, "update-buddy-status");
+ },
+
+ observeConv(aTargetId, aSubject, aTopic, aData) {
+ if (
+ aTargetId != this._currentTargetId &&
+ (aTopic == "new-text" ||
+ aTopic == "update-text" ||
+ aTopic == "remove-text" ||
+ (aTopic == "update-typing" &&
+ this._prplConv[aTargetId].typingState == Ci.prplIConvIM.TYPING))
+ ) {
+ this.target = this._prplConv[aTargetId];
+ }
+
+ this.notifyObservers(aSubject, aTopic, aData);
+ },
+
+ systemMessage(aText, aIsError, aNoCollapse) {
+ let flags = {
+ system: true,
+ noLog: true,
+ error: !!aIsError,
+ noCollapse: !!aNoCollapse,
+ };
+ const message = new Message("system", aText, flags, this);
+ this.notifyObservers(message, "new-text");
+ },
+
+ /**
+ * Emit a notification sound for a new chat message and trigger the
+ * global notificationbox to prompt the user with the verifiation request.
+ *
+ * @param String aText - The system message.
+ */
+ notifyVerifyOTR(aText) {
+ this._unreadOTRNotificationCount++;
+ this.systemMessage(aText, false, true);
+ for (let observer of this._observers) {
+ observer.observe(
+ this,
+ "unread-message-count-changed",
+ this._unreadOTRNotificationCount.toString()
+ );
+ }
+ },
+
+ // prplIConversation
+ get isChat() {
+ return this.target.isChat;
+ },
+ get account() {
+ return this.target.account;
+ },
+ get name() {
+ return this.target.name;
+ },
+ get normalizedName() {
+ return this.target.normalizedName;
+ },
+ get title() {
+ return this.target.title;
+ },
+ get startDate() {
+ return this.target.startDate;
+ },
+ get convIconFilename() {
+ return this.target.convIconFilename;
+ },
+ get encryptionState() {
+ return this.target.encryptionState;
+ },
+ initializeEncryption() {
+ this.target.initializeEncryption();
+ },
+ sendMsg(aMsg, aAction = false, aNotice = false) {
+ this.target.sendMsg(aMsg, aAction, aNotice);
+ },
+ unInit() {
+ for (let id in this._prplConv) {
+ let conv = this._prplConv[id];
+ gConversationsService.forgetConversation(conv);
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ delete this._observedContact;
+ }
+ this._prplConv = {}; // Prevent .close from failing.
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-destroyed");
+ },
+ close() {
+ for (let id in this._prplConv) {
+ let conv = this._prplConv[id];
+ conv.close();
+ }
+ if (!this.hasOwnProperty("_currentTargetId")) {
+ return;
+ }
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-closed");
+ Services.obs.notifyObservers(this, "ui-conversation-closed");
+ },
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ if (aTopic == "new-text" || aTopic == "update-text") {
+ aSubject = new imMessage(aSubject);
+ this.notifyObservers(aSubject, "received-message");
+ if (aSubject.cancelled) {
+ return;
+ }
+ if (!aSubject.system) {
+ aSubject.conversation.prepareForDisplaying(aSubject);
+ }
+ }
+ if (aTopic == "new-text") {
+ this._messages.push(aSubject);
+ ++this._unreadMessageCount;
+ if (aSubject.incoming && !aSubject.system) {
+ ++this._unreadIncomingMessageCount;
+ if (!this.isChat || aSubject.containsNick) {
+ ++this._unreadTargetedMessageCount;
+ }
+ }
+ } else if (aTopic == "update-text") {
+ const index = this._messages.findIndex(
+ msg => msg.remoteId == aSubject.remoteId
+ );
+ if (index != -1) {
+ this._messages.splice(index, 1, aSubject);
+ }
+ } else if (aTopic == "remove-text") {
+ const index = this._messages.findIndex(msg => msg.remoteId == aData);
+ if (index != -1) {
+ this._messages.splice(index, 1);
+ }
+ }
+
+ if (aTopic == "chat-update-type") {
+ // bail if there is no change of the conversation type
+ if (
+ (this.target.isChat && this._interfaces.includes(Ci.prplIConvChat)) ||
+ (!this.target.isChat && this._interfaces.includes(Ci.prplIConvIM))
+ ) {
+ return;
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ }
+ this.target.removeObserver(this._convObservers.get(this.target));
+ gConversationsService.updateConversation(this.target);
+ return;
+ }
+
+ for (let observer of this._observers) {
+ if (!observer.observe && !this._observers.includes(observer)) {
+ // Observer removed by a previous call to another observer.
+ continue;
+ }
+ observer.observe(aSubject, aTopic, aData);
+ }
+ this._notifyUnreadCountChanged();
+
+ if (aTopic == "new-text" || aTopic == "update-text") {
+ // Even updated messages should be treated as new message for logs.
+ // TODO proper handling in logs is bug 1735353
+ Services.obs.notifyObservers(aSubject, "new-text", aData);
+ if (
+ aTopic == "new-text" &&
+ aSubject.incoming &&
+ !aSubject.system &&
+ (!this.isChat || aSubject.containsNick)
+ ) {
+ this.notifyObservers(aSubject, "new-directed-incoming-message", aData);
+ Services.obs.notifyObservers(
+ aSubject,
+ "new-directed-incoming-message",
+ aData
+ );
+ }
+ }
+ },
+
+ // Used above when notifying of new-texts originating in the
+ // UIConversation. This happens when this.systemMessage() is called. The
+ // conversation for the message is set as the UIConversation.
+ prepareForDisplaying(aMsg) {},
+
+ // prplIConvIM
+ get buddy() {
+ return this.target.buddy;
+ },
+ get typingState() {
+ return this.target.typingState;
+ },
+ sendTyping(aString) {
+ return this.target.sendTyping(aString);
+ },
+
+ // Chat only
+ getParticipants() {
+ return this.target.getParticipants();
+ },
+ get topic() {
+ return this.target.topic;
+ },
+ set topic(aTopic) {
+ this.target.topic = aTopic;
+ },
+ get topicSetter() {
+ return this.target.topicSetter;
+ },
+ get topicSettable() {
+ return this.target.topicSettable;
+ },
+ get noTopicString() {
+ return lazy.bundle.GetStringFromName("noTopic");
+ },
+ get nick() {
+ return this.target.nick;
+ },
+ get left() {
+ return this.target.left;
+ },
+ get joining() {
+ return this.target.joining;
+ },
+};
+
+var gConversationsService;
+
+export function ConversationsService() {
+ gConversationsService = this;
+}
+
+ConversationsService.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+
+ initConversations() {
+ this._uiConv = {};
+ this._uiConvByContactId = {};
+ this._prplConversations = [];
+ Services.obs.addObserver(this, "account-disconnecting");
+ Services.obs.addObserver(this, "account-connected");
+ Services.obs.addObserver(this, "account-buddy-added");
+ Services.obs.addObserver(this, "account-buddy-removed");
+ },
+
+ unInitConversations() {
+ let UIConvs = this.getUIConversations();
+ for (let UIConv of UIConvs) {
+ UIConv.unInit();
+ }
+ delete this._uiConv;
+ delete this._uiConvByContactId;
+ // This should already be empty, but just to be sure...
+ for (let prplConv of this._prplConversations) {
+ prplConv.unInit();
+ }
+ delete this._prplConversations;
+ Services.obs.removeObserver(this, "account-disconnecting");
+ Services.obs.removeObserver(this, "account-connected");
+ Services.obs.removeObserver(this, "account-buddy-added");
+ Services.obs.removeObserver(this, "account-buddy-removed");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "account-connected") {
+ for (let id in this._uiConv) {
+ let conv = this._uiConv[id];
+ if (conv.account.id == aSubject.id) {
+ conv.connected();
+ }
+ }
+ } else if (aTopic == "account-disconnecting") {
+ for (let id in this._uiConv) {
+ let conv = this._uiConv[id];
+ if (conv.account.id == aSubject.id) {
+ conv.disconnecting();
+ }
+ }
+ } else if (aTopic == "account-buddy-added") {
+ let accountBuddy = aSubject;
+ let prplConversation = this.getConversationByNameAndAccount(
+ accountBuddy.normalizedName,
+ accountBuddy.account,
+ false
+ );
+ if (!prplConversation) {
+ return;
+ }
+
+ let uiConv = this.getUIConversation(prplConversation);
+ let contactId = accountBuddy.buddy.contact.id;
+ if (contactId in this._uiConvByContactId) {
+ // Trouble! There is an existing uiConv for this contact.
+ // We should avoid having two uiConvs with the same contact.
+ // This is ugly UX, but at least can only happen if there is
+ // already an accountBuddy with the same name for the same
+ // protocol on a different account, which should be rare.
+ this.removeConversation(prplConversation);
+ return;
+ }
+ // Link the existing uiConv to the contact.
+ this._uiConvByContactId[contactId] = uiConv;
+ uiConv.updateContactObserver();
+ uiConv.notifyObservers(uiConv, "update-conv-buddy");
+ } else if (aTopic == "account-buddy-removed") {
+ let accountBuddy = aSubject;
+ let contactId = accountBuddy.buddy.contact.id;
+ if (!(contactId in this._uiConvByContactId)) {
+ return;
+ }
+ let uiConv = this._uiConvByContactId[contactId];
+
+ // If there is more than one target on the uiConv, close the
+ // prplConv as we can't dissociate the uiConv from the contact.
+ // The conversation with the contact will continue with a different
+ // target.
+ if (uiConv.hasMultipleTargets) {
+ let prplConversation = uiConv.getTargetByAccount(accountBuddy.account);
+ if (prplConversation) {
+ this.removeConversation(prplConversation);
+ }
+ return;
+ }
+
+ delete this._uiConvByContactId[contactId];
+ uiConv.updateContactObserver();
+ uiConv.notifyObservers(uiConv, "update-conv-buddy");
+ }
+ },
+
+ addConversation(aPrplConversation) {
+ // Give an id to the new conversation.
+ aPrplConversation.id = ++gLastPrplConvId;
+ this._prplConversations.push(aPrplConversation);
+
+ // Notify observers.
+ Services.obs.notifyObservers(aPrplConversation, "new-conversation");
+
+ // Update or create the corresponding UI conversation.
+ let contactId;
+ if (!aPrplConversation.isChat) {
+ let accountBuddy = aPrplConversation.buddy;
+ if (accountBuddy) {
+ contactId = accountBuddy.buddy.contact.id;
+ }
+ }
+
+ if (contactId) {
+ if (contactId in this._uiConvByContactId) {
+ let uiConv = this._uiConvByContactId[contactId];
+ uiConv.target = aPrplConversation;
+ this._uiConv[aPrplConversation.id] = uiConv;
+ return;
+ }
+ }
+
+ let newUIConv = new UIConversation(aPrplConversation);
+ this._uiConv[aPrplConversation.id] = newUIConv;
+ if (contactId) {
+ this._uiConvByContactId[contactId] = newUIConv;
+ }
+ },
+ /**
+ * Informs the conversation service that the type of the conversation changed, which then lets the
+ * UI components know to use a new UI conversation instance.
+ *
+ * @param {prplIConversation} aPrplConversation - The prpl conversation to update the UI conv for.
+ */
+ updateConversation(aPrplConversation) {
+ let contactId;
+ let uiConv = this.getUIConversation(aPrplConversation);
+
+ if (!aPrplConversation.isChat) {
+ let accountBuddy = aPrplConversation.buddy;
+ if (accountBuddy) {
+ contactId = accountBuddy.buddy.contact.id;
+ }
+ }
+ // Ensure conv is not in the by contact ID map
+ for (const [contactId, uiConversation] of Object.entries(
+ this._uiConvByContactId
+ )) {
+ if (uiConversation === uiConv) {
+ delete this._uiConvByContactId[contactId];
+ break;
+ }
+ }
+ Services.obs.notifyObservers(uiConv, "ui-conversation-replaced");
+ let uiConvId = uiConv.id;
+ // create new UI conv with correct interfaces.
+ uiConv = new UIConversation(aPrplConversation, uiConvId);
+ this._uiConv[aPrplConversation.id] = uiConv;
+
+ // Ensure conv is in the by contact ID map if it has a contact
+ if (contactId) {
+ this._uiConvByContactId[contactId] = uiConv;
+ }
+ Services.obs.notifyObservers(uiConv, "conversation-update-type");
+ },
+ removeConversation(aPrplConversation) {
+ Services.obs.notifyObservers(aPrplConversation, "conversation-closed");
+
+ let uiConv = this.getUIConversation(aPrplConversation);
+ delete this._uiConv[aPrplConversation.id];
+ let contactId = {};
+ if (uiConv.removeTarget(aPrplConversation, contactId)) {
+ if (contactId.value) {
+ delete this._uiConvByContactId[contactId.value];
+ }
+ Services.obs.notifyObservers(uiConv, "ui-conversation-closed");
+ }
+ this.forgetConversation(aPrplConversation);
+ },
+ forgetConversation(aPrplConversation) {
+ aPrplConversation.unInit();
+
+ this._prplConversations = this._prplConversations.filter(
+ c => c !== aPrplConversation
+ );
+ },
+
+ getUIConversations() {
+ let rv = [];
+ if (this._uiConv) {
+ for (let prplConvId in this._uiConv) {
+ // Since an UIConversation may be linked to multiple prplConversations,
+ // we must ensure we don't return the same UIConversation twice,
+ // by checking the id matches that of the active prplConversation.
+ let uiConv = this._uiConv[prplConvId];
+ if (prplConvId == uiConv.target.id) {
+ rv.push(uiConv);
+ }
+ }
+ }
+ return rv;
+ },
+ getUIConversation(aPrplConversation) {
+ let id = aPrplConversation.id;
+ if (this._uiConv && id in this._uiConv) {
+ return this._uiConv[id];
+ }
+ throw new Error("Unknown conversation");
+ },
+ getUIConversationByContactId(aId) {
+ return aId in this._uiConvByContactId ? this._uiConvByContactId[aId] : null;
+ },
+
+ getConversations() {
+ return this._prplConversations;
+ },
+ getConversationById(aId) {
+ for (let conv of this._prplConversations) {
+ if (conv.id == aId) {
+ return conv;
+ }
+ }
+ return null;
+ },
+ getConversationByNameAndAccount(aName, aAccount, aIsChat) {
+ let normalizedName = aAccount.normalize(aName);
+ for (let conv of this._prplConversations) {
+ if (
+ aAccount.normalize(conv.name) == normalizedName &&
+ aAccount.numericId == conv.account.numericId &&
+ conv.isChat == aIsChat
+ ) {
+ return conv;
+ }
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIConversationsService"]),
+ classDescription: "Conversations",
+};
diff --git a/comm/chat/components/src/imCore.sys.mjs b/comm/chat/components/src/imCore.sys.mjs
new file mode 100644
index 0000000000..ba05bd4b63
--- /dev/null
+++ b/comm/chat/components/src/imCore.sys.mjs
@@ -0,0 +1,407 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ ClassInfo,
+ initLogModule,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+var kQuitApplicationGranted = "quit-application-granted";
+var kProtocolPluginCategory = "im-protocol-plugin";
+
+var kPrefReportIdle = "messenger.status.reportIdle";
+var kPrefUserIconFilename = "messenger.status.userIconFileName";
+var kPrefUserDisplayname = "messenger.status.userDisplayName";
+var kPrefTimeBeforeIdle = "messenger.status.timeBeforeIdle";
+var kPrefAwayWhenIdle = "messenger.status.awayWhenIdle";
+var kPrefDefaultMessage = "messenger.status.defaultIdleAwayMessage";
+
+var NS_IOSERVICE_GOING_OFFLINE_TOPIC = "network:offline-about-to-go-offline";
+var NS_IOSERVICE_OFFLINE_STATUS_TOPIC = "network:offline-status-changed";
+
+function UserStatus() {
+ this._observers = [];
+
+ if (Services.prefs.getBoolPref(kPrefReportIdle)) {
+ this._addIdleObserver();
+ }
+ Services.prefs.addObserver(kPrefReportIdle, this);
+
+ if (Services.io.offline) {
+ this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ Services.obs.addObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC);
+ Services.obs.addObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC);
+}
+UserStatus.prototype = {
+ __proto__: ClassInfo("imIUserStatusInfo", "User status info"),
+
+ unInit() {
+ this._observers = [];
+ Services.prefs.removeObserver(kPrefReportIdle, this);
+ if (this._observingIdleness) {
+ this._removeIdleObserver();
+ }
+ Services.obs.removeObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC);
+ Services.obs.removeObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC);
+ },
+ _observingIdleness: false,
+ _addIdleObserver() {
+ this._observingIdleness = true;
+ this._idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ Services.obs.addObserver(this, "im-sent");
+
+ this._timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle);
+ if (this._timeBeforeIdle < 0) {
+ this._timeBeforeIdle = 0;
+ }
+ Services.prefs.addObserver(kPrefTimeBeforeIdle, this);
+ if (this._timeBeforeIdle) {
+ this._idleService.addIdleObserver(this, this._timeBeforeIdle);
+ }
+ },
+ _removeIdleObserver() {
+ if (this._timeBeforeIdle) {
+ this._idleService.removeIdleObserver(this, this._timeBeforeIdle);
+ }
+
+ Services.prefs.removeObserver(kPrefTimeBeforeIdle, this);
+ delete this._timeBeforeIdle;
+
+ Services.obs.removeObserver(this, "im-sent");
+ delete this._idleService;
+ delete this._observingIdleness;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ if (aData == kPrefReportIdle) {
+ let reportIdle = Services.prefs.getBoolPref(kPrefReportIdle);
+ if (reportIdle && !this._observingIdleness) {
+ this._addIdleObserver();
+ } else if (!reportIdle && this._observingIdleness) {
+ this._removeIdleObserver();
+ }
+ } else if (aData == kPrefTimeBeforeIdle) {
+ let timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle);
+ if (timeBeforeIdle != this._timeBeforeIdle) {
+ if (this._timeBeforeIdle) {
+ this._idleService.removeIdleObserver(this, this._timeBeforeIdle);
+ }
+ this._timeBeforeIdle = timeBeforeIdle;
+ if (this._timeBeforeIdle) {
+ this._idleService.addIdleObserver(this, this._timeBeforeIdle);
+ }
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ } else if (aTopic == NS_IOSERVICE_GOING_OFFLINE_TOPIC) {
+ this.offline = true;
+ } else if (
+ aTopic == NS_IOSERVICE_OFFLINE_STATUS_TOPIC &&
+ aData == "online"
+ ) {
+ this.offline = false;
+ } else {
+ this._checkIdle();
+ }
+ },
+
+ _offlineStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ set offline(aOffline) {
+ let statusType = this.statusType;
+ let statusText = this.statusText;
+ if (aOffline) {
+ this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ } else {
+ delete this._offlineStatusType;
+ }
+ if (this.statusType != statusType || this.statusText != statusText) {
+ this._notifyObservers("status-changed", this.statusText);
+ }
+ },
+
+ _idleTime: 0,
+ get idleTime() {
+ return this._idleTime;
+ },
+ set idleTime(aIdleTime) {
+ this._idleTime = aIdleTime;
+ this._notifyObservers("idle-time-changed", aIdleTime);
+ },
+ _idle: false,
+ _idleStatusText: "",
+ _idleStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ _checkIdle() {
+ let idleTime = Math.floor(this._idleService.idleTime / 1000);
+ let idle = this._timeBeforeIdle && idleTime >= this._timeBeforeIdle;
+ if (idle == this._idle) {
+ return;
+ }
+
+ let statusType = this.statusType;
+ let statusText = this.statusText;
+ this._idle = idle;
+ if (idle) {
+ this.idleTime = idleTime;
+ if (Services.prefs.getBoolPref(kPrefAwayWhenIdle)) {
+ this._idleStatusType = Ci.imIStatusInfo.STATUS_AWAY;
+ this._idleStatusText = Services.prefs.getComplexValue(
+ kPrefDefaultMessage,
+ Ci.nsIPrefLocalizedString
+ ).data;
+ }
+ } else {
+ this.idleTime = 0;
+ delete this._idleStatusType;
+ delete this._idleStatusText;
+ }
+ if (this.statusType != statusType || this.statusText != statusText) {
+ this._notifyObservers("status-changed", this.statusText);
+ }
+ },
+
+ _statusText: "",
+ get statusText() {
+ return this._statusText || this._idleStatusText;
+ },
+ _statusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ get statusType() {
+ return Math.min(
+ this._statusType,
+ this._idleStatusType,
+ this._offlineStatusType
+ );
+ },
+ setStatus(aStatus, aMessage) {
+ if (aStatus != Ci.imIStatusInfo.STATUS_UNKNOWN) {
+ this._statusType = aStatus;
+ }
+ if (aStatus != Ci.imIStatusInfo.STATUS_OFFLINE) {
+ this._statusText = aMessage;
+ }
+ this._notifyObservers("status-changed", aMessage);
+ },
+
+ _getProfileDir: () => Services.dirsvc.get("ProfD", Ci.nsIFile),
+ setUserIcon(aIconFile) {
+ let folder = this._getProfileDir();
+
+ let newName = "";
+ if (aIconFile) {
+ // Get the extension (remove trailing dots - invalid Windows extension).
+ let ext = aIconFile.leafName.replace(/.*(\.[a-z0-9]+)\.*/i, "$1");
+ // newName = userIcon-<timestamp(now)>.<aIconFile.extension>
+ newName = "userIcon-" + Math.floor(Date.now() / 1000) + ext;
+
+ // Copy the new icon file to newName in the profile folder.
+ aIconFile.copyTo(folder, newName);
+ }
+
+ // Get the previous file name before saving the new file name.
+ let oldFileName = Services.prefs.getCharPref(kPrefUserIconFilename);
+ Services.prefs.setCharPref(kPrefUserIconFilename, newName);
+
+ // Now that the new icon has been copied to the profile directory
+ // and the pref value changed, we can remove the old icon. Ignore
+ // failures so that we always fire the user-icon-changed notification.
+ try {
+ if (oldFileName) {
+ folder.append(oldFileName);
+ if (folder.exists()) {
+ folder.remove(false);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this._notifyObservers("user-icon-changed", newName);
+ },
+ getUserIcon() {
+ let filename = Services.prefs.getCharPref(kPrefUserIconFilename);
+ if (!filename) {
+ // No icon has been set.
+ return null;
+ }
+
+ let file = this._getProfileDir();
+ file.append(filename);
+
+ if (!file.exists()) {
+ Services.console.logStringMessage("Invalid userIconFileName preference");
+ return null;
+ }
+
+ return Services.io.newFileURI(file);
+ },
+
+ get displayName() {
+ return Services.prefs.getStringPref(kPrefUserDisplayname);
+ },
+ set displayName(aDisplayName) {
+ Services.prefs.setStringPref(kPrefUserDisplayname, aDisplayName);
+ this._notifyObservers("user-display-name-changed", aDisplayName);
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ _notifyObservers(aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(this, aTopic, aData);
+ }
+ },
+};
+
+export function CoreService() {}
+CoreService.prototype = {
+ globalUserStatus: null,
+
+ _initialized: false,
+ get initialized() {
+ return this._initialized;
+ },
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ initLogModule("core", this);
+
+ Services.obs.addObserver(this, kQuitApplicationGranted);
+ this._initialized = true;
+
+ IMServices.cmd.initCommands();
+ this._protos = {};
+
+ this.globalUserStatus = new UserStatus();
+ this.globalUserStatus.addObserver({
+ observe(aSubject, aTopic, aData) {
+ Services.obs.notifyObservers(aSubject, aTopic, aData);
+ },
+ });
+
+ IMServices.accounts.initAccounts();
+ IMServices.contacts.initContacts();
+ IMServices.conversations.initConversations();
+ Services.obs.notifyObservers(this, "prpl-init");
+
+ // Wait with automatic connections until the password service
+ // is available.
+ if (
+ IMServices.accounts.autoLoginStatus ==
+ Ci.imIAccountsService.AUTOLOGIN_ENABLED
+ ) {
+ Services.logins.initializationPromise.then(() => {
+ IMServices.accounts.processAutoLogin();
+ });
+ }
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == kQuitApplicationGranted) {
+ this.quit();
+ }
+ },
+ quit() {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ Services.obs.removeObserver(this, kQuitApplicationGranted);
+ Services.obs.notifyObservers(this, "prpl-quit");
+
+ IMServices.conversations.unInitConversations();
+ IMServices.accounts.unInitAccounts();
+ IMServices.contacts.unInitContacts();
+ IMServices.cmd.unInitCommands();
+
+ this.globalUserStatus.unInit();
+ delete this.globalUserStatus;
+ delete this._protos;
+ delete this._initialized;
+ },
+
+ getProtocols() {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ let protocols = [];
+ for (let entry of Services.catMan.enumerateCategory(
+ kProtocolPluginCategory
+ )) {
+ let id = entry.data;
+
+ // If the preference is set to disable this prpl, don't show it in the
+ // full list of protocols.
+ let pref = "chat.prpls." + id + ".disable";
+ if (
+ Services.prefs.getPrefType(pref) == Services.prefs.PREF_BOOL &&
+ Services.prefs.getBoolPref(pref)
+ ) {
+ this.LOG("Disabling prpl: " + id);
+ continue;
+ }
+
+ let proto = this.getProtocolById(id);
+ if (proto) {
+ protocols.push(proto);
+ }
+ }
+ return protocols;
+ },
+
+ getProtocolById(aPrplId) {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ if (this._protos.hasOwnProperty(aPrplId)) {
+ return this._protos[aPrplId];
+ }
+
+ let cid;
+ try {
+ cid = Services.catMan.getCategoryEntry(kProtocolPluginCategory, aPrplId);
+ } catch (e) {
+ return null; // no protocol registered for this id.
+ }
+
+ let proto = null;
+ try {
+ proto = Cc[cid].createInstance(Ci.prplIProtocol);
+ } catch (e) {
+ // This is a real error, the protocol is registered and failed to init.
+ let error = "failed to create an instance of " + cid + ": " + e;
+ dump(error + "\n");
+ console.error(error);
+ }
+ if (!proto) {
+ return null;
+ }
+
+ try {
+ proto.init(aPrplId);
+ } catch (e) {
+ console.error("Could not initialize protocol " + aPrplId + ": " + e);
+ return null;
+ }
+
+ this._protos[aPrplId] = proto;
+ return proto;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imICoreService"]),
+ classDescription: "Core",
+};
diff --git a/comm/chat/components/src/logger.sys.mjs b/comm/chat/components/src/logger.sys.mjs
new file mode 100644
index 0000000000..bde2e2945e
--- /dev/null
+++ b/comm/chat/components/src/logger.sys.mjs
@@ -0,0 +1,971 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { GenericMessagePrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+import {
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/logger.properties")
+);
+
+/*
+ * Maps file paths to promises returned by ongoing IOUtils operations on them.
+ * This is so that a file can be read after a pending write operation completes
+ * and vice versa (opening a file multiple times concurrently may fail on Windows).
+ */
+export var gFilePromises = new Map();
+/**
+ * Set containing log file paths that are scheduled to have deleted messages
+ * removed.
+ *
+ * @type {Set<string>}
+ */
+export var gPendingCleanup = new Set();
+
+const kPendingLogCleanupPref = "chat.logging.cleanup.pending";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SHOULD_CLEANUP_LOGS",
+ "chat.logging.cleanup",
+ true
+);
+
+// Uses above map to queue operations on a file.
+export function queueFileOperation(aPath, aOperation) {
+ // Ensure the operation is queued regardless of whether the last one succeeded.
+ // This is safe since the promise is returned and consumers are expected to
+ // handle any errors. If there's no promise existing for the given path already,
+ // queue the operation on a dummy pre-resolved promise.
+ let promise = (gFilePromises.get(aPath) || Promise.resolve()).then(
+ aOperation,
+ aOperation
+ );
+ gFilePromises.set(aPath, promise);
+
+ let cleanup = () => {
+ // If no further operations have been queued, remove the reference from the map.
+ if (gFilePromises.get(aPath) === promise) {
+ gFilePromises.delete(aPath);
+ }
+ };
+ // Ensure we clear unused promises whether they resolved or rejected.
+ promise.then(cleanup, cleanup);
+
+ return promise;
+}
+
+/**
+ * Convenience method to append to a file using the above queue system. If any of
+ * the I/O operations reject, the returned promise will reject with the same reason.
+ * We open the file, append, and close it immediately. The alternative is to keep
+ * it open and append as required, but we want to make sure we don't open a file
+ * for reading while it's already open for writing, so we close it every time
+ * (opening a file multiple times concurrently may fail on Windows).
+ * Note: This function creates parent directories if required.
+ */
+export function appendToFile(aPath, aString, aCreate) {
+ return queueFileOperation(aPath, async function () {
+ await IOUtils.makeDirectory(PathUtils.parent(aPath));
+ const mode = aCreate ? "create" : "append";
+ try {
+ await IOUtils.writeUTF8(aPath, aString, {
+ mode,
+ });
+ } catch (error) {
+ // Ignore existing file when adding the header.
+ if (
+ aCreate &&
+ error.name == "NoModificationAllowedError" &&
+ error.message.startsWith("Refusing to overwrite the file")
+ ) {
+ return;
+ }
+ throw error;
+ }
+ });
+}
+
+// This function checks names against OS naming conventions and alters them
+// accordingly so that they can be used as file/folder names.
+export function encodeName(aName) {
+ // Reserved device names by Windows (prefixing "%").
+ let reservedNames = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i;
+ if (reservedNames.test(aName)) {
+ return "%" + aName;
+ }
+
+ // "." and " " must not be at the end of a file or folder name (appending "_").
+ if (/[\. _]/.test(aName.slice(-1))) {
+ aName += "_";
+ }
+
+ // Reserved characters are replaced by %[hex value]. encodeURIComponent() is
+ // not sufficient, nevertheless decodeURIComponent() can be used to decode.
+ function encodeReservedChars(match) {
+ return "%" + match.charCodeAt(0).toString(16);
+ }
+ return aName.replace(/[<>:"\/\\|?*&%]/g, encodeReservedChars);
+}
+
+export function getLogFolderPathForAccount(aAccount) {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs",
+ aAccount.protocol.normalizedName,
+ encodeName(aAccount.normalizedName)
+ );
+}
+
+export function getLogFilePathForConversation(aConv, aStartTime) {
+ if (!aStartTime) {
+ aStartTime = aConv.startDate / 1000;
+ }
+ let path = getLogFolderPathForAccount(aConv.account);
+ let name = aConv.normalizedName;
+ if (aConv.isChat) {
+ name += ".chat";
+ }
+ return PathUtils.join(path, encodeName(name), getNewLogFileName(aStartTime));
+}
+
+export function getNewLogFileName(aStartTime) {
+ let date = aStartTime ? new Date(aStartTime) : new Date();
+ let dateTime = lazy.ToLocaleFormat("%Y-%m-%d.%H%M%S", date);
+ let offset = date.getTimezoneOffset();
+ if (offset < 0) {
+ dateTime += "+";
+ offset *= -1;
+ } else {
+ dateTime += "-";
+ }
+ let minutes = offset % 60;
+ offset = (offset - minutes) / 60;
+ function twoDigits(number) {
+ if (number == 0) {
+ return "00";
+ }
+ return number < 10 ? "0" + number : number;
+ }
+ return dateTime + twoDigits(offset) + twoDigits(minutes) + ".json";
+}
+
+/**
+ * Schedules a cleanup of the logfiles contents, removing the message texts
+ * from messages that were marked as deleted. This can be disabled by a pref.
+ *
+ * @param {string} path - Path to the logfile to clean.
+ */
+function queueLogFileCleanup(path) {
+ if (gPendingCleanup.has(path) || !lazy.SHOULD_CLEANUP_LOGS) {
+ return;
+ }
+ let idleCallback = () => {
+ if (gFilePromises.has(path)) {
+ gFilePromises.get(path).finally(() => {
+ ChromeUtils.idleDispatch(idleCallback);
+ });
+ return;
+ }
+ // Queue a new file operation to ensure nothing gets appended between
+ // reading the log and writing it back. This means we might run this when
+ // the application isn't idle, but due to the async operations that is
+ // very hard to guarantee either way.
+ queueFileOperation(path, async () => {
+ try {
+ let logContents = await IOUtils.readUTF8(path);
+ let logLines = logContents.split("\n").map(line => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ return line;
+ }
+ });
+ let lastDeletionIndex = 0;
+ let deletedMessages = new Set(
+ logLines
+ .filter((message, index) => {
+ if (message.flags?.includes("deleted") && message.remoteId) {
+ lastDeletionIndex = index;
+ return true;
+ }
+ return false;
+ })
+ .map(message => message.remoteId)
+ );
+ for (let [index, message] of logLines.entries()) {
+ // If we are past the last deletion in the logs, there is no more
+ // work to be done.
+ if (index >= lastDeletionIndex) {
+ break;
+ }
+ if (
+ deletedMessages.has(message.remoteId) &&
+ !message.flags?.includes("deleted")
+ ) {
+ // Void the text of deleted messages but keep the message
+ // metadata for journaling.
+ message.text = "";
+ }
+ }
+ let cleanedLog = logLines
+ .map(line => {
+ if (typeof line === "string") {
+ return line;
+ }
+ return JSON.stringify(line);
+ })
+ .join("\n");
+ await IOUtils.writeUTF8(path, cleanedLog);
+ } catch (error) {
+ console.error(
+ "Error cleaning up log file contents for " + path + ": " + error
+ );
+ } finally {
+ gPendingCleanup.delete(path);
+ Services.prefs.setStringPref(
+ kPendingLogCleanupPref,
+ JSON.stringify(Array.from(gPendingCleanup.values()))
+ );
+ }
+ });
+ };
+ ChromeUtils.idleDispatch(idleCallback);
+ gPendingCleanup.add(path);
+ Services.prefs.setStringPref(
+ kPendingLogCleanupPref,
+ JSON.stringify(Array.from(gPendingCleanup.values()))
+ );
+}
+
+/**
+ * Schedule pending log cleanups that weren't completed last time the
+ * application was running.
+ */
+function initLogCleanup() {
+ if (!lazy.SHOULD_CLEANUP_LOGS) {
+ return;
+ }
+ // Capture the value of the pending cleanups before it gets overridden by
+ // newly scheduled cleanups.
+ let pendingCleanupPathValue = Services.prefs.getStringPref(
+ kPendingLogCleanupPref,
+ "[]"
+ );
+ // We are in no hurry to queue these cleanups, worst case we try to schedule
+ // a cleanup for a file that is already scheduled.
+ ChromeUtils.idleDispatch(() => {
+ let pendingCleanupPaths = JSON.parse(pendingCleanupPathValue) ?? [];
+ if (!Array.isArray(pendingCleanupPaths)) {
+ console.error(
+ "Pending chat log cleanup pref is not a valid array. " +
+ "Assuming all chat logs are clean."
+ );
+ return;
+ }
+ for (const path of pendingCleanupPaths) {
+ if (typeof path === "string") {
+ queueLogFileCleanup(path);
+ }
+ }
+ });
+}
+
+// One of these is maintained for every conversation being logged. It initializes
+// a log file and appends to it as required.
+function LogWriter(aConversation) {
+ this._conv = aConversation;
+ this.paths = [];
+ this.startNewFile(this._conv.startDate / 1000);
+}
+LogWriter.prototype = {
+ // All log file paths used by this LogWriter.
+ paths: [],
+ // Path of the log file that is currently being written to.
+ get currentPath() {
+ return this.paths[this.paths.length - 1];
+ },
+ // Constructor sets this to a promise that will resolve when the log header
+ // has been written.
+ _initialized: null,
+ _startTime: null,
+ _lastMessageTime: null,
+ _messageCount: 0,
+ startNewFile(aStartTime, aContinuedSession) {
+ // We start a new log file every 1000 messages. The start time of this new
+ // log file is the time of the next message. Since message times are in seconds,
+ // if we receive 1000 messages within a second after starting the new file,
+ // we will create another file, using the same start time - and so the same
+ // file name. To avoid this, ensure the new start time is at least one second
+ // greater than the current one. This is ugly, but should rarely be needed.
+ aStartTime = Math.max(aStartTime, this._startTime + 1000);
+ this._startTime = this._lastMessageTime = aStartTime;
+ this._messageCount = 0;
+ this.paths.push(getLogFilePathForConversation(this._conv, aStartTime));
+ let account = this._conv.account;
+ let header = {
+ date: new Date(this._startTime),
+ name: this._conv.name,
+ title: this._conv.title,
+ account: account.normalizedName,
+ protocol: account.protocol.normalizedName,
+ isChat: this._conv.isChat,
+ normalizedName: this._conv.normalizedName,
+ };
+ if (aContinuedSession) {
+ header.continuedSession = true;
+ }
+ header = JSON.stringify(header) + "\n";
+
+ this._initialized = appendToFile(this.currentPath, header, true);
+ // Catch the error separately so that _initialized will stay rejected if
+ // writing the header failed.
+ this._initialized.catch(aError =>
+ console.error("Failed to initialize log file:\n" + aError)
+ );
+ },
+ // We start a new log file in the following cases:
+ // - If it has been 30 minutes since the last message.
+ kInactivityLimit: 30 * 60 * 1000,
+ // - If at midnight, it's been longer than 3 hours since we started the file.
+ kDayOverlapLimit: 3 * 60 * 60 * 1000,
+ // - After every 1000 messages.
+ kMessageCountLimit: 1000,
+ async logMessage(aMessage) {
+ // aMessage.time is in seconds, we need it in milliseconds.
+ let messageTime = aMessage.time * 1000;
+ let messageMidnight = new Date(messageTime).setHours(0, 0, 0, 0);
+
+ let inactivityLimitExceeded =
+ !aMessage.delayed &&
+ messageTime - this._lastMessageTime > this.kInactivityLimit;
+ let dayOverlapLimitExceeded =
+ !aMessage.delayed &&
+ messageMidnight - this._startTime > this.kDayOverlapLimit;
+
+ if (
+ inactivityLimitExceeded ||
+ dayOverlapLimitExceeded ||
+ this._messageCount == this.kMessageCountLimit
+ ) {
+ // We start a new session if the inactivity limit was exceeded.
+ this.startNewFile(messageTime, !inactivityLimitExceeded);
+ }
+ ++this._messageCount;
+
+ if (!aMessage.delayed) {
+ this._lastMessageTime = messageTime;
+ }
+
+ let msg = {
+ date: new Date(messageTime),
+ who: aMessage.who,
+ text: aMessage.displayMessage,
+ flags: [
+ "outgoing",
+ "incoming",
+ "system",
+ "autoResponse",
+ "containsNick",
+ "error",
+ "delayed",
+ "noFormat",
+ "containsImages",
+ "notification",
+ "noLinkification",
+ "isEncrypted",
+ "action",
+ "deleted",
+ ].filter(f => aMessage[f]),
+ remoteId: aMessage.remoteId,
+ };
+ let alias = aMessage.alias;
+ if (alias && alias != msg.who) {
+ msg.alias = alias;
+ }
+ let lineToWrite = JSON.stringify(msg) + "\n";
+
+ await this._initialized;
+ try {
+ await appendToFile(this.currentPath, lineToWrite);
+ } catch (error) {
+ console.error("Failed to log message:\n" + error);
+ }
+ if (aMessage.deleted) {
+ queueLogFileCleanup(this.currentPath);
+ }
+ },
+};
+
+var dummyLogWriter = {
+ paths: null,
+ currentPath: null,
+ logMessage() {},
+};
+
+var gLogWritersById = new Map();
+export function getLogWriter(aConversation) {
+ let id = aConversation.id;
+ if (!gLogWritersById.has(id)) {
+ let prefName =
+ "purple.logging.log_" + (aConversation.isChat ? "chats" : "ims");
+ if (Services.prefs.getBoolPref(prefName)) {
+ gLogWritersById.set(id, new LogWriter(aConversation));
+ } else {
+ gLogWritersById.set(id, dummyLogWriter);
+ }
+ }
+ return gLogWritersById.get(id);
+}
+
+export function closeLogWriter(aConversation) {
+ gLogWritersById.delete(aConversation.id);
+}
+
+/**
+ * Takes a properly formatted log file name and extracts the date information
+ * and filetype, returning the results as an Array.
+ *
+ * Filenames are expected to be formatted as:
+ *
+ * YYYY-MM-DD.HHmmSS+ZZzz.format
+ *
+ * @param aFilename the name of the file
+ * @returns an Array, where the first element is a Date object for the date
+ * that the log file represents, and the file type as a string.
+ */
+function getDateFromFilename(aFilename) {
+ const kRegExp =
+ /([\d]{4})-([\d]{2})-([\d]{2}).([\d]{2})([\d]{2})([\d]{2})([+-])([\d]{2})([\d]{2}).*\.([A-Za-z]+)$/;
+
+ let r = aFilename.match(kRegExp);
+ if (!r) {
+ console.error(
+ "Found log file with name not matching YYYY-MM-DD.HHmmSS+ZZzz.format: " +
+ aFilename
+ );
+ return [];
+ }
+
+ // We ignore the timezone offset for now (FIXME)
+ return [new Date(r[1], r[2] - 1, r[3], r[4], r[5], r[6]), r[10]];
+}
+
+function LogMessage(aData, aConversation) {
+ this._init(aData.who, aData.text, {}, aConversation);
+ // Not overriding time using the init options, since init also sets the
+ // property.
+ this.time = Math.round(new Date(aData.date) / 1000);
+ if ("alias" in aData) {
+ this._alias = aData.alias;
+ }
+ this.remoteId = aData.remoteId;
+ if (aData.flags) {
+ for (let flag of aData.flags) {
+ this[flag] = true;
+ }
+ }
+}
+
+LogMessage.prototype = {
+ __proto__: GenericMessagePrototype,
+ _interfaces: [Ci.imIMessage, Ci.prplIMessage],
+ get displayMessage() {
+ return this.originalMessage;
+ },
+};
+
+function LogConversation(aMessages, aProperties) {
+ this._messages = aMessages;
+ for (let property in aProperties) {
+ this[property] = aProperties[property];
+ }
+}
+LogConversation.prototype = {
+ __proto__: ClassInfo("imILogConversation", "Log conversation object"),
+ get isChat() {
+ return this._isChat;
+ },
+ get buddy() {
+ return null;
+ },
+ get account() {
+ return {
+ alias: "",
+ name: this._accountName,
+ normalizedName: this._accountName,
+ protocol: { name: this._protocolName },
+ statusInfo: IMServices.core.globalUserStatus,
+ };
+ },
+ getMessages() {
+ // Start with the newest message to filter out older versions of the same
+ // message. Also filter out deleted messages.
+ return this._messages.map(m => new LogMessage(m, this));
+ },
+};
+
+/**
+ * A Log object represents one or more log files. The constructor expects one
+ * argument, which is either a single path to a json log file or an array of
+ * objects each having two properties:
+ * path: The full path of the (json only) log file it represents.
+ * time: The Date object extracted from the filename of the logfile.
+ *
+ * The returned Log object's time property will be:
+ * For a single file - exact time extracted from the name of the log file.
+ * For a set of files - the time extracted, reduced to the day.
+ */
+function Log(aEntries) {
+ if (typeof aEntries == "string") {
+ // Assume that aEntries is a single path.
+ let path = aEntries;
+ this.path = path;
+ let [date, format] = getDateFromFilename(PathUtils.filename(path));
+ if (!date || !format) {
+ this.time = 0;
+ return;
+ }
+ this.time = date.valueOf() / 1000;
+ // Wrap the path in an array
+ this._entryPaths = [path];
+ return;
+ }
+
+ if (!aEntries.length) {
+ throw new Error(
+ "Log was passed an invalid argument, " +
+ "expected a non-empty array or a string."
+ );
+ }
+
+ // Assume aEntries is an array of objects.
+ // Sort our list of entries for this day in increasing order.
+ aEntries.sort((aLeft, aRight) => aLeft.time - aRight.time);
+
+ this._entryPaths = aEntries.map(entry => entry.path);
+ // Calculate the timestamp for the first entry down to the day.
+ let timestamp = new Date(aEntries[0].time);
+ timestamp.setHours(0);
+ timestamp.setMinutes(0);
+ timestamp.setSeconds(0);
+ this.time = timestamp.valueOf() / 1000;
+ // Path is used to uniquely identify a Log, and sometimes used to
+ // quickly determine which directory a log file is from. We'll use
+ // the first file's path.
+ this.path = aEntries[0].path;
+}
+Log.prototype = {
+ __proto__: ClassInfo("imILog", "Log object"),
+ _entryPaths: null,
+ async getConversation() {
+ /*
+ * Read the set of log files asynchronously and return a promise that
+ * resolves to a LogConversation instance. Even if a file contains some
+ * junk (invalid JSON), messages that are valid will be read. If the first
+ * line of metadata is corrupt however, the data isn't useful and the
+ * promise will resolve to null.
+ */
+ let messages = [];
+ let properties = {};
+ let firstFile = true;
+ let decoder = new TextDecoder();
+ let lastRemoteIdIndex = {};
+ for (let path of this._entryPaths) {
+ let lines;
+ try {
+ let contents = await queueFileOperation(path, () => IOUtils.read(path));
+ lines = decoder.decode(contents).split("\n");
+ } catch (aError) {
+ console.error('Error reading log file "' + path + '":\n' + aError);
+ continue;
+ }
+ let nextLine = lines.shift();
+ let filename = PathUtils.filename(path);
+
+ let data;
+ try {
+ // This will fail if either nextLine is undefined, or not valid JSON.
+ data = JSON.parse(nextLine);
+ } catch (aError) {
+ messages.push({
+ who: "sessionstart",
+ date: getDateFromFilename(filename)[0],
+ text: lazy._("badLogfile", filename),
+ flags: ["noLog", "notification", "error", "system"],
+ });
+ continue;
+ }
+
+ if (firstFile || !data.continuedSession) {
+ messages.push({
+ who: "sessionstart",
+ date: getDateFromFilename(filename)[0],
+ text: "",
+ flags: ["noLog", "notification"],
+ });
+ }
+
+ if (firstFile) {
+ properties.startDate = new Date(data.date) * 1000;
+ properties.name = data.name;
+ properties.title = data.title;
+ properties._accountName = data.account;
+ properties._protocolName = data.protocol;
+ properties._isChat = data.isChat;
+ properties.normalizedName = data.normalizedName;
+ firstFile = false;
+ }
+
+ while (lines.length) {
+ nextLine = lines.shift();
+ if (!nextLine) {
+ break;
+ }
+ try {
+ let message = JSON.parse(nextLine);
+
+ // Backwards compatibility for old action messages.
+ if (
+ !message.flags.includes("action") &&
+ message.text?.startsWith("/me ")
+ ) {
+ message.flags.push("action");
+ message.text = message.text.slice(4);
+ }
+
+ if (message.remoteId) {
+ lastRemoteIdIndex[message.remoteId] = messages.length;
+ }
+ messages.push(message);
+ } catch (e) {
+ // If a message line contains junk, just ignore the error and
+ // continue reading the conversation.
+ }
+ }
+ }
+
+ if (firstFile) {
+ // All selected log files are invalid.
+ return null;
+ }
+
+ // Ignore older versions of edited messages and deleted messages.
+ messages = messages.filter((message, index) => {
+ if (
+ message.remoteId &&
+ lastRemoteIdIndex.hasOwnProperty(message.remoteId) &&
+ index < lastRemoteIdIndex[message.remoteId]
+ ) {
+ return false;
+ }
+ return !message.flags.includes("deleted");
+ });
+
+ return new LogConversation(messages, properties);
+ },
+};
+
+/**
+ * logsGroupedByDay() organizes log entries by date.
+ *
+ * @param {string[]} aEntries - paths of log files to be parsed.
+ * @returns {imILog[]} Logs, ordered by day.
+ */
+function logsGroupedByDay(aEntries) {
+ if (!Array.isArray(aEntries)) {
+ return [];
+ }
+
+ let entries = {};
+ for (let path of aEntries) {
+ let [logDate, logFormat] = getDateFromFilename(PathUtils.filename(path));
+ if (!logDate) {
+ // We'll skip this one, since it's got a busted filename.
+ continue;
+ }
+
+ let dateForID = new Date(logDate);
+ let dayID;
+ // If the file isn't a JSON file, ignore it.
+ if (logFormat != "json") {
+ continue;
+ }
+ // We want to cluster all of the logs that occur on the same day
+ // into the same Arrays. We clone the date for the log, reset it to
+ // the 0th hour/minute/second, and use that to construct an ID for the
+ // Array we'll put the log in.
+ dateForID.setHours(0);
+ dateForID.setMinutes(0);
+ dateForID.setSeconds(0);
+ dayID = dateForID.toISOString();
+
+ if (!(dayID in entries)) {
+ entries[dayID] = [];
+ }
+
+ entries[dayID].push({
+ path,
+ time: logDate,
+ });
+ }
+
+ let days = Object.keys(entries);
+ days.sort();
+ return days.map(dayID => new Log(entries[dayID]));
+}
+
+export function Logger() {
+ IOUtils.profileBeforeChange.addBlocker(
+ "Chat logger: writing all pending messages",
+ async function () {
+ for (let promise of gFilePromises.values()) {
+ try {
+ await promise;
+ } catch (aError) {
+ // Ignore the error, whatever queued the operation will take care of it.
+ }
+ }
+ }
+ );
+
+ Services.obs.addObserver(this, "new-text");
+ Services.obs.addObserver(this, "conversation-closed");
+ Services.obs.addObserver(this, "conversation-left-chat");
+ initLogCleanup();
+}
+
+Logger.prototype = {
+ // Returned Promise resolves to an array of entries for the
+ // log folder if it exists, otherwise null.
+ async _getLogEntries(aAccount, aNormalizedName) {
+ let path;
+ try {
+ path = PathUtils.join(
+ getLogFolderPathForAccount(aAccount),
+ encodeName(aNormalizedName)
+ );
+ if (await queueFileOperation(path, () => IOUtils.exists(path))) {
+ return await IOUtils.getChildren(path);
+ }
+ } catch (aError) {
+ console.error(
+ 'Error getting directory entries for "' + path + '":\n' + aError
+ );
+ }
+ return [];
+ },
+ async getLogFromFile(aFilePath, aGroupByDay) {
+ if (!aGroupByDay) {
+ return new Log(aFilePath);
+ }
+ let [targetDate] = getDateFromFilename(PathUtils.filename(aFilePath));
+ if (!targetDate) {
+ return null;
+ }
+
+ targetDate = targetDate.toDateString();
+
+ // We'll assume that the files relevant to our interests are
+ // in the same folder as the one provided.
+ let relevantEntries = [];
+ for (const path of await IOUtils.getChildren(PathUtils.parent(aFilePath))) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory") {
+ continue;
+ }
+ let [logTime] = getDateFromFilename(PathUtils.filename(path));
+ // If someone placed a 'foreign' file into the logs directory,
+ // pattern matching fails and getDateFromFilename() returns [].
+ if (logTime && targetDate == logTime.toDateString()) {
+ relevantEntries.push({
+ path,
+ time: logTime,
+ });
+ }
+ }
+ return new Log(relevantEntries);
+ },
+
+ async getLogPathsForConversation(aConversation) {
+ let writer = gLogWritersById.get(aConversation.id);
+ // Resolve to null if we haven't created a LogWriter yet for this conv, or
+ // if logging is disabled (paths will be null).
+ if (!writer || !writer.paths) {
+ return null;
+ }
+ let paths = writer.paths;
+ // Wait for any pending file operations to finish, then resolve to the paths
+ // regardless of whether these operations succeeded.
+ for (let path of paths) {
+ await gFilePromises.get(path);
+ }
+ return paths;
+ },
+ async getLogsForContact(aContact) {
+ let entries = [];
+ for (let buddy of aContact.getBuddies()) {
+ for (let accountBuddy of buddy.getAccountBuddies()) {
+ entries = entries.concat(
+ await this._getLogEntries(
+ accountBuddy.account,
+ accountBuddy.normalizedName
+ )
+ );
+ }
+ }
+ return logsGroupedByDay(entries);
+ },
+ getLogsForConversation(aConversation) {
+ let name = aConversation.normalizedName;
+ if (aConversation.isChat) {
+ name += ".chat";
+ }
+
+ return this._getLogEntries(aConversation.account, name).then(entries =>
+ logsGroupedByDay(entries)
+ );
+ },
+ async getSimilarLogs(log) {
+ let entries;
+ try {
+ entries = await IOUtils.getChildren(PathUtils.parent(log.path));
+ } catch (aError) {
+ console.error(
+ 'Error getting similar logs for "' + log.path + '":\n' + aError
+ );
+ }
+ // If there was an error, this will return an empty array.
+ return logsGroupedByDay(entries);
+ },
+
+ getLogFolderPathForAccount(aAccount) {
+ return getLogFolderPathForAccount(aAccount);
+ },
+
+ deleteLogFolderForAccount(aAccount) {
+ if (!aAccount.disconnecting && !aAccount.disconnected) {
+ throw new Error(
+ "Account must be disconnected first before deleting logs."
+ );
+ }
+
+ if (aAccount.disconnecting) {
+ console.error(
+ "Account is still disconnecting while we attempt to remove logs."
+ );
+ }
+
+ let logPath = this.getLogFolderPathForAccount(aAccount);
+ // Find all operations on files inside the log folder.
+ let pendingPromises = [];
+ function checkLogFiles(promiseOperation, filePath) {
+ if (filePath.startsWith(logPath)) {
+ pendingPromises.push(promiseOperation);
+ }
+ }
+ gFilePromises.forEach(checkLogFiles);
+ // After all operations finish, remove the whole log folder.
+ return Promise.all(pendingPromises)
+ .then(values => {
+ IOUtils.remove(logPath, { recursive: true });
+ })
+ .catch(aError =>
+ console.error("Failed to remove log folders:\n" + aError)
+ );
+ },
+
+ async forEach(aCallback) {
+ let getAllSubdirs = async function (aPaths, aErrorMsg) {
+ let entries = [];
+ for (let path of aPaths) {
+ try {
+ entries = entries.concat(await IOUtils.getChildren(path));
+ } catch (aError) {
+ if (aErrorMsg) {
+ console.error(aErrorMsg + "\n" + aError);
+ }
+ }
+ }
+ let filteredPaths = [];
+ for (let path of entries) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory") {
+ filteredPaths.push(path);
+ }
+ }
+ return filteredPaths;
+ };
+
+ let logsPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs"
+ );
+ let prpls = await getAllSubdirs([logsPath]);
+ let accounts = await getAllSubdirs(
+ prpls,
+ "Error while sweeping prpl folder:"
+ );
+ let logFolders = await getAllSubdirs(
+ accounts,
+ "Error while sweeping account folder:"
+ );
+ for (let folder of logFolders) {
+ try {
+ for (const path of await IOUtils.getChildren(folder)) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory" || !path.endsWith(".json")) {
+ continue;
+ }
+ await aCallback.processLog(path);
+ }
+ } catch (aError) {
+ // If the callback threw, reject the promise and let the caller handle it.
+ if (!DOMException.isInstance(aError)) {
+ throw aError;
+ }
+ console.error("Error sweeping log folder:\n" + aError);
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "new-text":
+ let excludeBecauseEncrypted = false;
+ if (aSubject.isEncrypted) {
+ excludeBecauseEncrypted = !Services.prefs.getBoolPref(
+ "messenger.account." +
+ aSubject.conversation.account.id +
+ ".options.otrAllowMsgLog",
+ Services.prefs.getBoolPref("chat.otr.default.allowMsgLog")
+ );
+ }
+ if (!aSubject.noLog && !excludeBecauseEncrypted) {
+ let log = getLogWriter(aSubject.conversation);
+ log.logMessage(aSubject);
+ }
+ break;
+ case "conversation-closed":
+ case "conversation-left-chat":
+ closeLogWriter(aSubject);
+ break;
+ default:
+ throw new Error("Unexpected notification " + aTopic);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver", "imILogger"]),
+ classDescription: "Logger",
+};
diff --git a/comm/chat/components/src/moz.build b/comm/chat/components/src/moz.build
new file mode 100644
index 0000000000..cbab7e998b
--- /dev/null
+++ b/comm/chat/components/src/moz.build
@@ -0,0 +1,19 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "imAccounts.sys.mjs",
+ "imCommands.sys.mjs",
+ "imContacts.sys.mjs",
+ "imConversations.sys.mjs",
+ "imCore.sys.mjs",
+ "logger.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/components/src/test/test_accounts.js b/comm/chat/components/src/test/test_accounts.js
new file mode 100644
index 0000000000..267095455f
--- /dev/null
+++ b/comm/chat/components/src/test/test_accounts.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+function run_test() {
+ do_get_profile();
+
+ // Test the handling of accounts for unknown protocols.
+ const kAccountName = "Unknown";
+ const kPrplId = "prpl-unknown";
+
+ let prefs = Services.prefs;
+ prefs.setCharPref("messenger.account.account1.name", kAccountName);
+ prefs.setCharPref("messenger.account.account1.prpl", kPrplId);
+ prefs.setCharPref("mail.accountmanager.accounts", "account1");
+ prefs.setCharPref("mail.account.account1.server", "server1");
+ prefs.setCharPref("mail.server.server1.imAccount", "account1");
+ prefs.setCharPref("mail.server.server1.type", "im");
+ prefs.setCharPref("mail.server.server1.userName", kAccountName);
+ prefs.setCharPref("mail.server.server1.hostname", kPrplId);
+ try {
+ // Having an implementation of nsIXULAppInfo is required for
+ // IMServices.core.init to work.
+ updateAppInfo();
+ IMServices.core.init();
+
+ let account = IMServices.accounts.getAccountByNumericId(1);
+ Assert.ok(account instanceof Ci.imIAccount);
+ Assert.equal(account.name, kAccountName);
+ Assert.equal(account.normalizedName, kAccountName);
+ Assert.equal(account.protocol.id, kPrplId);
+ Assert.equal(
+ account.connectionErrorReason,
+ Ci.imIAccount.ERROR_UNKNOWN_PRPL
+ );
+ } finally {
+ IMServices.core.quit();
+
+ prefs.deleteBranch("messenger");
+ }
+}
diff --git a/comm/chat/components/src/test/test_commands.js b/comm/chat/components/src/test/test_commands.js
new file mode 100644
index 0000000000..de0fd0e665
--- /dev/null
+++ b/comm/chat/components/src/test/test_commands.js
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+// We don't load the command service via Services as we want to access
+// _findCommands in order to avoid having to intercept command execution.
+var { CommandsService } = ChromeUtils.importESModule(
+ "resource:///modules/imCommands.sys.mjs"
+);
+
+var kPrplId = "green";
+var kPrplId2 = "red";
+
+var fakeAccount = {
+ connected: true,
+ protocol: { id: kPrplId },
+};
+var fakeDisconnectedAccount = {
+ connected: false,
+ protocol: { id: kPrplId },
+};
+var fakeAccount2 = {
+ connected: true,
+ protocol: { id: kPrplId2 },
+};
+
+var fakeConversation = {
+ account: fakeAccount,
+ isChat: true,
+};
+
+function fakeCommand(aName, aUsageContext) {
+ this.name = aName;
+ if (aUsageContext) {
+ this.usageContext = aUsageContext;
+ }
+}
+fakeCommand.prototype = {
+ get helpString() {
+ return "";
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_PRPL,
+ run: (aMsg, aConv) => true,
+};
+
+function run_test() {
+ let cmdserv = new CommandsService();
+ cmdserv.initCommands();
+
+ // Some commands providing multiple possible completions.
+ cmdserv.registerCommand(new fakeCommand("banana"), kPrplId2);
+ cmdserv.registerCommand(new fakeCommand("baloney"), kPrplId2);
+
+ // MUC-only command.
+ cmdserv.registerCommand(
+ new fakeCommand("balderdash", Ci.imICommand.CMD_CONTEXT_CHAT),
+ kPrplId
+ );
+
+ // Name clashes with global command.
+ cmdserv.registerCommand(new fakeCommand("offline"), kPrplId);
+
+ // Name starts with another command name.
+ cmdserv.registerCommand(new fakeCommand("helpme"), kPrplId);
+
+ // Command name contains numbers.
+ cmdserv.registerCommand(new fakeCommand("r9kbeta"), kPrplId);
+
+ // Array of (possibly partial) command names as entered by the user.
+ let testCmds = [
+ "x",
+ "b",
+ "ba",
+ "bal",
+ "back",
+ "hel",
+ "help",
+ "off",
+ "offline",
+ ];
+
+ // We test an array of different possible conversations.
+ // cmdlist lists all the available commands for the given conversation.
+ // results is an array which for each testCmd provides an array containing
+ // data with which the return value of _findCommands can be checked. In
+ // particular, the name of the command and whether the first (i.e. preferred)
+ // entry in the returned array of commands is a prpl command. (If the latter
+ // boolean is not given, false is assumed, if the name is not given, that
+ // corresponds to no commands being returned.)
+ let testData = [
+ {
+ desc: "No conversation argument.",
+ cmdlist: "away, back, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Disconnected conversation with fakeAccount.",
+ conv: {
+ account: fakeDisconnectedAccount,
+ },
+ cmdlist:
+ "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Conversation with fakeAccount.",
+ conv: {
+ account: fakeAccount,
+ },
+ cmdlist:
+ "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ [],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "MUC with fakeAccount.",
+ conv: {
+ account: fakeAccount,
+ isChat: true,
+ },
+ cmdlist:
+ "away, back, balderdash, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["balderdash", true],
+ ["back"],
+ [],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Conversation with fakeAccount2.",
+ conv: {
+ account: fakeAccount2,
+ },
+ cmdlist:
+ "away, back, baloney, banana, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["baloney", true],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "MUC with fakeAccount2.",
+ conv: {
+ account: fakeAccount2,
+ isChat: true,
+ },
+ cmdlist:
+ "away, back, baloney, banana, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["baloney", true],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ ];
+
+ for (let test of testData) {
+ info("The following tests are with: " + test.desc);
+
+ // Check which commands are available in which context.
+ let cmdlist = cmdserv
+ .listCommandsForConversation(test.conv)
+ .map(aCmd => aCmd.name)
+ .sort()
+ .join(", ");
+ Assert.equal(cmdlist, test.cmdlist);
+
+ for (let testCmd of testCmds) {
+ info("Testing command found for '" + testCmd + "'");
+ let expectedResult = test.results.shift();
+ let cmdArray = cmdserv._findCommands(test.conv, testCmd);
+ // Check whether commands are only returned when appropriate.
+ Assert.equal(cmdArray.length > 0, expectedResult.length > 0);
+ if (cmdArray.length) {
+ // Check if the right command was returned.
+ Assert.equal(cmdArray[0].name, expectedResult[0]);
+ Assert.equal(
+ cmdArray[0].priority == Ci.imICommand.CMD_PRIORITY_PRPL,
+ !!expectedResult[1]
+ );
+ }
+ }
+ }
+
+ // Array of messages to test command execution of.
+ let testMessages = [
+ {
+ message: "/r9kbeta",
+ result: true,
+ },
+ {
+ message: "/helpme 2 arguments",
+ result: true,
+ },
+ {
+ message: "nocommand",
+ result: false,
+ },
+ {
+ message: "/-a",
+ result: false,
+ },
+ {
+ message: "/notregistered",
+ result: false,
+ },
+ ];
+
+ // Test command execution.
+ for (let executionTest of testMessages) {
+ info("Testing command execution for '" + executionTest.message + "'");
+ Assert.equal(
+ cmdserv.executeCommand(executionTest.message, fakeConversation),
+ executionTest.result
+ );
+ }
+
+ cmdserv.unInitCommands();
+}
diff --git a/comm/chat/components/src/test/test_conversations.js b/comm/chat/components/src/test/test_conversations.js
new file mode 100644
index 0000000000..c1ede89734
--- /dev/null
+++ b/comm/chat/components/src/test/test_conversations.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { GenericConvIMPrototype, Message } = ChromeUtils.importESModule(
+ "resource:///modules/jsProtoHelper.sys.mjs"
+);
+var { imMessage, UIConversation } = ChromeUtils.importESModule(
+ "resource:///modules/imConversations.sys.mjs"
+);
+
+// Fake prplConversation
+var _id = 0;
+function Conversation(aName) {
+ this._name = aName;
+ this._observers = [];
+ this._date = Date.now() * 1000;
+ this.id = ++_id;
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ _account: {
+ imAccount: {
+ protocol: { name: "Fake Protocol" },
+ alias: "",
+ name: "Fake Account",
+ },
+ ERROR(e) {
+ throw e;
+ },
+ DEBUG() {},
+ },
+ addObserver(aObserver) {
+ if (!(aObserver instanceof Ci.nsIObserver)) {
+ aObserver = { observe: aObserver };
+ }
+ GenericConvIMPrototype.addObserver.call(this, aObserver);
+ },
+};
+
+// Ensure that when iMsg.message is set to a message (including the empty
+// string), it returns that message. If not, it should return the original
+// message. This prevents regressions due to JS coercions.
+var test_null_message = function () {
+ let originalMessage = "Hi!";
+ let pMsg = new Message(
+ "buddy",
+ originalMessage,
+ {
+ outgoing: true,
+ _alias: "buddy",
+ time: Date.now(),
+ },
+ null
+ );
+ let iMsg = new imMessage(pMsg);
+ equal(iMsg.message, originalMessage, "Expected the original message.");
+ // Setting the message should prevent a fallback to the original.
+ iMsg.message = "";
+ equal(
+ iMsg.message,
+ "",
+ "Expected an empty string; not the original message."
+ );
+ equal(
+ iMsg.originalMessage,
+ originalMessage,
+ "Expected the original message."
+ );
+};
+
+// ROT13, used as an example transformation.
+function rot13(aString) {
+ return aString.replace(/[a-zA-Z]/g, function (c) {
+ return String.fromCharCode(
+ c.charCodeAt(0) + (c.toLowerCase() < "n" ? 1 : -1) * 13
+ );
+ });
+}
+
+// A test that exercises the message transformation pipeline.
+//
+// From the sending users perspective, this looks like:
+// -> protocol sendMsg
+// -> protocol notifyObservers `preparing-message`
+// -> protocol prepareForSending
+// -> protocol notifyObservers `sending-message`
+// -> protocol dispatchMessage (jsProtoHelper specific)
+// -> protocol writeMessage
+// -> protocol notifyObservers `new-text`
+// -> UIConv notifyObservers `received-message`
+// -> protocol prepareForDisplaying
+// -> UIConv notifyObservers `new-text`
+//
+// From the receiving users perspective, they get:
+// -> protocol writeMessage
+// -> protocol notifyObservers `new-text`
+// -> UIConv notifyObservers `received-message`
+// -> protocol prepareForDisplaying
+// -> UIConv notifyObservers `new-text`
+//
+// The test walks the sending path, which covers both.
+add_task(function test_message_transformation() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ this.writeMessage("user", aMsg, { outgoing: true });
+ };
+
+ let message = "Hello!";
+ let receivedMsg = false,
+ newTxt = false;
+
+ let uiConv = new UIConversation(conv);
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "sending-message":
+ ok(!newTxt, "sending-message should fire before new-text.");
+ ok(
+ !receivedMsg,
+ "sending-message should fire before received-message."
+ );
+ ok(
+ aObject.QueryInterface(Ci.imIOutgoingMessage),
+ "Wrong message type."
+ );
+ aObject.message = rot13(aObject.message);
+ break;
+ case "received-message":
+ ok(!newTxt, "received-message should fire before new-text.");
+ ok(
+ !receivedMsg,
+ "Sanity check that receive-message hasn't fired yet."
+ );
+ ok(aObject.outgoing, "Expected an outgoing message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ rot13(message),
+ "Expected to have been rotated while sending-message."
+ );
+ aObject.displayMessage = rot13(aObject.displayMessage);
+ receivedMsg = true;
+ break;
+ case "new-text":
+ ok(!newTxt, "Sanity check that new-text hasn't fired yet.");
+ ok(receivedMsg, "Expected received-message to have fired.");
+ ok(aObject.outgoing, "Expected an outgoing message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ message,
+ "Expected to have been rotated back to msg in received-message."
+ );
+ newTxt = true;
+ break;
+ }
+ },
+ });
+
+ uiConv.sendMsg(message);
+ ok(newTxt, "Expected new-text to have fired.");
+});
+
+// A test that cancels a message before it gets displayed.
+add_task(function test_cancel_display_message() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ this.writeMessage("user", aMsg, { outgoing: true });
+ };
+
+ let received = false;
+ let uiConv = new UIConversation(conv);
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "received-message":
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ aObject.cancelled = true;
+ received = true;
+ break;
+ case "new-text":
+ ok(false, "Should not fire for a cancelled message.");
+ break;
+ }
+ },
+ });
+
+ uiConv.sendMsg("Hi!");
+ ok(received, "The received-message notification was never fired.");
+});
+
+var test_update_message = function () {
+ let conv = new Conversation();
+
+ let uiConv = new UIConversation(conv);
+ let message = "Hello!";
+ let receivedMsg = false;
+ let updateText = false;
+
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "received-message":
+ ok(!updateText, "received-message should fire before update-text.");
+ ok(
+ !receivedMsg,
+ "Sanity check that receive-message hasn't fired yet."
+ );
+ ok(aObject.incoming, "Expected an incoming message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(aObject.displayMessage, message, "Wrong message contents");
+ aObject.displayMessage = rot13(aObject.displayMessage);
+ receivedMsg = true;
+ break;
+ case "update-text":
+ ok(!updateText, "Sanity check that update-text hasn't fired yet.");
+ ok(receivedMsg, "Expected received-message to have fired.");
+ ok(aObject.incoming, "Expected an incoming message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ rot13(message),
+ "Expected to have been rotated in received-message."
+ );
+ updateText = true;
+ break;
+ }
+ },
+ });
+
+ conv.updateMessage("user", message, { incoming: true, remoteId: "foo" });
+ ok(updateText, "Expected update-text to have fired.");
+};
+
+add_task(test_null_message);
+add_task(test_update_message);
diff --git a/comm/chat/components/src/test/test_init.js b/comm/chat/components/src/test/test_init.js
new file mode 100644
index 0000000000..48f064027f
--- /dev/null
+++ b/comm/chat/components/src/test/test_init.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+// Modules that should only be loaded once a chat account exists.
+var ACCOUNT_MODULES = new Set([
+ "resource:///modules/matrixAccount.sys.mjs",
+ "resource:///modules/matrix-sdk.sys.mjs",
+ "resource:///modules/ircAccount.sys.mjs",
+ "resource:///modules/ircHandlers.sys.mjs",
+ "resource:///modules/xmpp-base.sys.mjs",
+ "resource:///modules/xmpp-session.sys.mjs",
+]);
+
+add_task(function test_coreInitLoadedModules() {
+ do_get_profile();
+ // Make sure protocols are all loaded.
+ IMServices.core.init();
+ IMServices.core.getProtocols();
+
+ for (const module of ACCOUNT_MODULES) {
+ ok(!Cu.isESModuleLoaded(module), `${module} should be loaded later`);
+ }
+});
diff --git a/comm/chat/components/src/test/test_logger.js b/comm/chat/components/src/test/test_logger.js
new file mode 100644
index 0000000000..be93d8b300
--- /dev/null
+++ b/comm/chat/components/src/test/test_logger.js
@@ -0,0 +1,860 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+do_get_profile();
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+const {
+ Logger,
+ gFilePromises,
+ gPendingCleanup,
+ queueFileOperation,
+ getLogFolderPathForAccount,
+ encodeName,
+ getLogFilePathForConversation,
+ getNewLogFileName,
+ appendToFile,
+ getLogWriter,
+ closeLogWriter,
+} = ChromeUtils.importESModule("resource:///modules/logger.sys.mjs");
+
+var logDirPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs"
+);
+
+var dummyAccount = {
+ name: "dummy-account",
+ normalizedName: "dummyaccount",
+ protocol: {
+ normalizedName: "dummy",
+ id: "prpl-dummy",
+ },
+};
+
+var dummyConv = {
+ account: dummyAccount,
+ id: 0,
+ title: "dummy conv",
+ normalizedName: "dummyconv",
+ get name() {
+ return this.normalizedName;
+ },
+ get startDate() {
+ return new Date(2011, 5, 28).valueOf() * 1000;
+ },
+ isChat: false,
+};
+
+// A day after the first one.
+var dummyConv2 = {
+ account: dummyAccount,
+ id: 0,
+ title: "dummy conv",
+ normalizedName: "dummyconv",
+ get name() {
+ return this.normalizedName;
+ },
+ get startDate() {
+ return new Date(2011, 5, 29).valueOf() * 1000;
+ },
+ isChat: false,
+};
+
+var dummyMUC = {
+ account: dummyAccount,
+ id: 1,
+ title: "Dummy MUC",
+ normalizedName: "dummymuc",
+ get name() {
+ return this.normalizedName;
+ },
+ startDate: new Date(2011, 5, 28).valueOf() * 1000,
+ isChat: true,
+};
+
+var encodeName_input = [
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ "COM3",
+ "LPT5",
+ "file",
+ "file.",
+ "file ",
+ "file_",
+ "file<",
+ "file>",
+ "file:",
+ 'file"',
+ "file/",
+ "file\\",
+ "file|",
+ "file?",
+ "file*",
+ "file&",
+ "file%",
+ "fi<le",
+ "fi>le",
+ "fi:le",
+ 'fi"le',
+ "fi/le",
+ "fi\\le",
+ "fi|le",
+ "fi?le",
+ "fi*le",
+ "fi&le",
+ "fi%le",
+ "<file",
+ ">file",
+ ":file",
+ '"file',
+ "/file",
+ "\\file",
+ "|file",
+ "?file",
+ "*file",
+ "&file",
+ "%file",
+ "\\fi?*&%le<>",
+];
+
+var encodeName_output = [
+ "%CON",
+ "%PRN",
+ "%AUX",
+ "%NUL",
+ "%COM3",
+ "%LPT5",
+ "file",
+ "file._",
+ "file _",
+ "file__",
+ "file%3c",
+ "file%3e",
+ "file%3a",
+ "file%22",
+ "file%2f",
+ "file%5c",
+ "file%7c",
+ "file%3f",
+ "file%2a",
+ "file%26",
+ "file%25",
+ "fi%3cle",
+ "fi%3ele",
+ "fi%3ale",
+ "fi%22le",
+ "fi%2fle",
+ "fi%5cle",
+ "fi%7cle",
+ "fi%3fle",
+ "fi%2ale",
+ "fi%26le",
+ "fi%25le",
+ "%3cfile",
+ "%3efile",
+ "%3afile",
+ "%22file",
+ "%2ffile",
+ "%5cfile",
+ "%7cfile",
+ "%3ffile",
+ "%2afile",
+ "%26file",
+ "%25file",
+ "%5c" + "fi" + "%3f%2a%26%25" + "le" + "%3c%3e", // eslint-disable-line no-useless-concat
+];
+
+var test_queueFileOperation = async function () {
+ let dummyRejectedOperation = () => Promise.reject("Rejected!");
+ let dummyResolvedOperation = () => Promise.resolve("Resolved!");
+
+ // Immediately after calling qFO, "path1" should be mapped to p1.
+ // After yielding, the reference should be cleared from the map.
+ let p1 = queueFileOperation("path1", dummyResolvedOperation);
+ equal(gFilePromises.get("path1"), p1);
+ await p1;
+ ok(!gFilePromises.has("path1"));
+
+ // Repeat above test for a rejected promise.
+ let p2 = queueFileOperation("path2", dummyRejectedOperation);
+ equal(gFilePromises.get("path2"), p2);
+ // This should throw since p2 rejected. Drop the error.
+ await p2.then(
+ () => do_throw(),
+ () => {}
+ );
+ ok(!gFilePromises.has("path2"));
+
+ let onPromiseComplete = (aPromise, aHandler) => {
+ return aPromise.then(aHandler, aHandler);
+ };
+ let test_queueOrder = aOperation => {
+ let promise = queueFileOperation("queueOrderPath", aOperation);
+ let firstOperationComplete = false;
+ onPromiseComplete(promise, () => (firstOperationComplete = true));
+ return queueFileOperation("queueOrderPath", () => {
+ ok(firstOperationComplete);
+ });
+ };
+ // Test the queue order for rejected and resolved promises.
+ await test_queueOrder(dummyResolvedOperation);
+ await test_queueOrder(dummyRejectedOperation);
+};
+
+var test_getLogFolderPathForAccount = async function () {
+ let path = getLogFolderPathForAccount(dummyAccount);
+ equal(
+ PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ ),
+ path
+ );
+};
+
+// Tests the global function getLogFilePathForConversation in logger.js.
+var test_getLogFilePathForConversation = async function () {
+ let path = getLogFilePathForConversation(dummyConv);
+ let expectedPath = PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ encodeName(dummyConv.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ getNewLogFileName(dummyConv.startDate / 1000)
+ );
+ equal(path, expectedPath);
+};
+
+var test_getLogFilePathForMUC = async function () {
+ let path = getLogFilePathForConversation(dummyMUC);
+ let expectedPath = PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ encodeName(dummyMUC.normalizedName + ".chat")
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ getNewLogFileName(dummyMUC.startDate / 1000)
+ );
+ equal(path, expectedPath);
+};
+
+var test_appendToFile = async function () {
+ const kStringToWrite = "Hello, world!";
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "testFile.txt"
+ );
+ await IOUtils.write(path, new Uint8Array());
+ appendToFile(path, kStringToWrite);
+ appendToFile(path, kStringToWrite);
+ ok(await queueFileOperation(path, () => IOUtils.exists(path)));
+ let text = await queueFileOperation(path, () => IOUtils.readUTF8(path));
+ // The read text should be equal to kStringToWrite repeated twice.
+ equal(text, kStringToWrite + kStringToWrite);
+ await IOUtils.remove(path);
+};
+
+add_task(async function test_appendToFileHeader() {
+ const kStringToWrite = "Lorem ipsum";
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "headerTestFile.txt"
+ );
+ await appendToFile(path, kStringToWrite, true);
+ await appendToFile(path, kStringToWrite, true);
+ let text = await queueFileOperation(path, () => IOUtils.readUTF8(path));
+ // The read text should be equal to kStringToWrite once, since the second
+ // create should just noop.
+ equal(text, kStringToWrite);
+ await IOUtils.remove(path);
+});
+
+// Tests the getLogPathsForConversation API defined in the imILogger interface.
+var test_getLogPathsForConversation = async function () {
+ let logger = new Logger();
+ let paths = await logger.getLogPathsForConversation(dummyConv);
+ // The path should be null since a LogWriter hasn't been created yet.
+ equal(paths, null);
+ let logWriter = getLogWriter(dummyConv);
+ paths = await logger.getLogPathsForConversation(dummyConv);
+ equal(paths.length, 1);
+ equal(paths[0], logWriter.currentPath);
+ ok(await IOUtils.exists(paths[0]));
+ // Ensure this doesn't interfere with future tests.
+ await IOUtils.remove(paths[0]);
+ closeLogWriter(dummyConv);
+};
+
+var test_logging = async function () {
+ let logger = new Logger();
+ let oneSec = 1000000; // Microseconds.
+
+ // Creates a set of dummy messages for a conv (sets appropriate times).
+ let getMsgsForConv = function (aConv) {
+ // Convert to seconds because that's what logMessage expects.
+ let startTime = Math.round(aConv.startDate / oneSec);
+ return [
+ {
+ time: startTime + 1,
+ who: "personA",
+ displayMessage: "Hi!",
+ outgoing: true,
+ },
+ {
+ time: startTime + 2,
+ who: "personB",
+ displayMessage: "Hello!",
+ incoming: true,
+ },
+ {
+ time: startTime + 3,
+ who: "personA",
+ displayMessage: "What's up?",
+ outgoing: true,
+ },
+ {
+ time: startTime + 4,
+ who: "personB",
+ displayMessage: "Nothing much!",
+ incoming: true,
+ },
+ {
+ time: startTime + 5,
+ who: "personB",
+ displayMessage: "Encrypted msg",
+ remoteId: "identifier",
+ incoming: true,
+ isEncrypted: true,
+ },
+ {
+ time: startTime + 6,
+ who: "personA",
+ displayMessage: "Deleted",
+ remoteId: "otherID",
+ outgoing: true,
+ isEncrypted: true,
+ deleted: true,
+ },
+ ];
+ };
+ let firstDayMsgs = getMsgsForConv(dummyConv);
+ let secondDayMsgs = getMsgsForConv(dummyConv2);
+
+ let logMessagesForConv = async function (aConv, aMessages) {
+ let logWriter = getLogWriter(aConv);
+ for (let message of aMessages) {
+ logWriter.logMessage(message);
+ }
+ // If we don't wait for the messages to get written, we have no guarantee
+ // later in the test that the log files were created, and getConversation
+ // will return an EmptyEnumerator. Logging the messages is queued on the
+ // _initialized promise, so we need to await on that first.
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ // Ensure two different files for the different dates.
+ closeLogWriter(aConv);
+ };
+ await logMessagesForConv(dummyConv, firstDayMsgs);
+ await logMessagesForConv(dummyConv2, secondDayMsgs);
+
+ // Write a zero-length file and a file with incorrect JSON for each day
+ // to ensure they are handled correctly.
+ let logDir = PathUtils.parent(getLogFilePathForConversation(dummyConv));
+ let createBadFiles = async function (aConv) {
+ let blankFile = PathUtils.join(
+ logDir,
+ getNewLogFileName((aConv.startDate + oneSec) / 1000)
+ );
+ let invalidJSONFile = PathUtils.join(
+ logDir,
+ getNewLogFileName((aConv.startDate + 2 * oneSec) / 1000)
+ );
+ await IOUtils.write(blankFile, new Uint8Array());
+ await IOUtils.writeUTF8(invalidJSONFile, "This isn't JSON!");
+ };
+ await createBadFiles(dummyConv);
+ await createBadFiles(dummyConv2);
+
+ let testMsgs = function (aMsgs, aExpectedMsgs, aExpectedSessions) {
+ // Ensure the number of session messages is correct.
+ let sessions = aMsgs.filter(aMsg => aMsg.who == "sessionstart").length;
+ equal(sessions, aExpectedSessions);
+
+ // Discard session messages, etc.
+ aMsgs = aMsgs.filter(aMsg => !aMsg.noLog);
+
+ equal(aMsgs.length, aExpectedMsgs.length);
+
+ for (let i = 0; i < aMsgs.length; ++i) {
+ let message = aMsgs[i],
+ expectedMessage = aExpectedMsgs[i];
+ for (let prop in expectedMessage) {
+ ok(prop in message);
+ equal(expectedMessage[prop], message[prop]);
+ }
+ }
+ };
+
+ // Accepts time in seconds, reduces it to a date, and returns the value in millis.
+ let reduceTimeToDate = function (aTime) {
+ let date = new Date(aTime * 1000);
+ date.setHours(0);
+ date.setMinutes(0);
+ date.setSeconds(0);
+ return date.valueOf();
+ };
+
+ // Group expected messages by day.
+ let messagesByDay = new Map();
+ messagesByDay.set(
+ reduceTimeToDate(firstDayMsgs[0].time),
+ firstDayMsgs.filter(msg => !msg.deleted)
+ );
+ messagesByDay.set(
+ reduceTimeToDate(secondDayMsgs[0].time),
+ secondDayMsgs.filter(msg => !msg.deleted)
+ );
+
+ let logs = await logger.getLogsForConversation(dummyConv);
+ for (let log of logs) {
+ let conv = await log.getConversation();
+ let date = reduceTimeToDate(log.time);
+ // 3 session messages - for daily logs, bad files are included.
+ testMsgs(conv.getMessages(), messagesByDay.get(date), 3);
+ }
+
+ // Remove the created log files, testing forEach in the process.
+ await logger.forEach({
+ async processLog(aLog) {
+ let info = await IOUtils.stat(aLog);
+ notEqual(info.type, "directory");
+ ok(aLog.endsWith(".json"));
+ await IOUtils.remove(aLog);
+ },
+ });
+ let logFolder = PathUtils.parent(getLogFilePathForConversation(dummyConv));
+ // The folder should now be empty - this will throw if it isn't.
+ await IOUtils.remove(logFolder, { ignoreAbsent: false });
+};
+
+var test_logFileSplitting = async function () {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logWriter = getLogWriter(dummyConv);
+ let startTime = logWriter._startTime / 1000; // Message times are in seconds.
+ let oldPath = logWriter.currentPath;
+ let message = {
+ time: startTime,
+ who: "John Doe",
+ originalMessage: "Hello, world!",
+ outgoing: true,
+ };
+
+ let logMessage = async function (aMessage) {
+ logWriter.logMessage(aMessage);
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ };
+
+ await logMessage(message);
+ message.time += logWriter.kInactivityLimit / 1000 + 1;
+ // This should go in a new log file.
+ await logMessage(message);
+ notEqual(logWriter.currentPath, oldPath);
+ // The log writer's new start time should be the time of the message.
+ equal(message.time * 1000, logWriter._startTime);
+
+ let getCurrentHeader = async function () {
+ return JSON.parse(
+ (await IOUtils.readUTF8(logWriter.currentPath)).split("\n")[0]
+ );
+ };
+
+ // The header of the new log file should not have the continuedSession flag set.
+ ok(!(await getCurrentHeader()).continuedSession);
+
+ // Set the start time sufficiently before midnight, and the last message time
+ // to just before midnight. A new log file should be created at midnight.
+ logWriter._startTime = new Date(logWriter._startTime).setHours(
+ 24,
+ 0,
+ 0,
+ -(logWriter.kDayOverlapLimit + 1)
+ );
+ let nearlyMidnight = new Date(logWriter._startTime).setHours(24, 0, 0, -1);
+ oldPath = logWriter.currentPath;
+ logWriter._lastMessageTime = nearlyMidnight;
+ message.time = new Date(nearlyMidnight).setHours(24, 0, 0, 1) / 1000;
+ await logMessage(message);
+ // The message should have gone in a new file.
+ notEqual(oldPath, logWriter.currentPath);
+ // The header should have the continuedSession flag set this time.
+ ok((await getCurrentHeader()).continuedSession);
+
+ // Ensure a new file is created every kMessageCountLimit messages.
+ oldPath = logWriter.currentPath;
+ let messageCountLimit = logWriter.kMessageCountLimit;
+ for (let i = 0; i < messageCountLimit; ++i) {
+ logMessage(message);
+ }
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ // The header should have the continuedSession flag set this time too.
+ ok((await getCurrentHeader()).continuedSession);
+ // Again, to make sure it still works correctly after splitting it once already.
+ oldPath = logWriter.currentPath;
+ // We already logged one message to ensure it went into a new file, so i = 1.
+ for (let i = 1; i < messageCountLimit; ++i) {
+ logMessage(message);
+ }
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok((await getCurrentHeader()).continuedSession);
+
+ // The new start time is the time of the message. If we log sufficiently more
+ // messages with the same time property, ensure that the start time of the next
+ // log file is greater than the previous one, and that a new path is being used.
+ let oldStartTime = logWriter._startTime;
+ oldPath = logWriter.currentPath;
+ logWriter._messageCount = messageCountLimit;
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok(logWriter._startTime > oldStartTime);
+
+ // Do it again with the same message.
+ oldStartTime = logWriter._startTime;
+ oldPath = logWriter.currentPath;
+ logWriter._messageCount = messageCountLimit;
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok(logWriter._startTime > oldStartTime);
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ closeLogWriter(dummyConv);
+};
+
+add_task(async function test_logWithEdits() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:48.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:51.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:53.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Lorem ipsum dolor sit amet",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:53.000Z",
+ who: "@other:example.com",
+ text: "consectetur adipiscing elit",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:51.000Z",
+ who: "@other:example.com",
+ text: "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:48.000Z",
+ who: "@other:example.com",
+ text: "Ut enim ad minim veniam",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk",
+ alias: "other",
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ const conv = await logs[0].getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 5);
+ for (const msg of messages) {
+ if (msg.who !== "sessionstart") {
+ notEqual(msg.displayMessage, "Decrypting...");
+ }
+ }
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+// Ensure that any message with a remoteId that has a deleted flag in the
+// latest version is not visible in logs.
+add_task(async function test_logWithDeletedMessages() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ const remoteId = "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM";
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "isEncrypted"],
+ remoteId,
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Message was redacted.",
+ flags: ["incoming", "isEncrypted", "deleted"],
+ remoteId,
+ alias: "other",
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ const conv = await logs[0].getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 1);
+ equal(messages[0].who, "sessionstart");
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+add_task(async function test_logDeletedMessageCleanup() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logWriter = getLogWriter(dummyConv);
+ let remoteId = "testId";
+
+ let logMessage = async function (aMessage) {
+ logWriter.logMessage(aMessage);
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ };
+
+ await logMessage({
+ time: Math.floor(dummyConv.startDate / 1000000) + 10,
+ who: "test",
+ displayMessage: "delete me",
+ remoteId,
+ incoming: true,
+ });
+
+ await logMessage({
+ time: Math.floor(dummyConv.startDate / 1000000) + 20,
+ who: "test",
+ displayMessage: "Message is deleted",
+ remoteId,
+ deleted: true,
+ incoming: true,
+ });
+ ok(gPendingCleanup.has(logWriter.currentPath));
+ equal(
+ Services.prefs.getStringPref("chat.logging.cleanup.pending"),
+ JSON.stringify([logWriter.currentPath])
+ );
+
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ await (gFilePromises.get(logWriter.currentPath) || Promise.resolve());
+
+ ok(!gPendingCleanup.has(logWriter.currentPath));
+ equal(Services.prefs.getStringPref("chat.logging.cleanup.pending"), "[]");
+
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1, "Only a single log file for this conversation");
+ let conv = await logs[0].getConversation();
+ let messages = conv.getMessages();
+ equal(messages.length, 1, "Only the log header is left");
+ equal(messages[0].who, "sessionstart");
+
+ // Check that the message contents were removed from the file on disk. The
+ // log parser above removes it either way.
+ let logOnDisk = await IOUtils.readUTF8(logWriter.currentPath);
+ let rawMessages = logOnDisk
+ .split("\n")
+ .filter(Boolean)
+ .map(line => JSON.parse(line));
+ equal(rawMessages.length, 3);
+ equal(rawMessages[1].text, "", "Deleted message content was removed");
+ equal(
+ rawMessages[2].text,
+ "Message is deleted",
+ "Deletion content is unaffected"
+ );
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+
+ closeLogWriter(dummyConv);
+});
+
+add_task(async function test_displayOldActionLog() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "/me an old action",
+ flags: ["incoming"],
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "a new action",
+ flags: ["incoming", "action"],
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ for (let log of logs) {
+ const conv = await log.getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 3);
+ for (let message of messages) {
+ if (message.who !== "sessionstart") {
+ ok(message.action, "Message is marked as action");
+ ok(
+ !message.displayMessage.startsWith("/me"),
+ "Message has no leading /me"
+ );
+ }
+ }
+ }
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+add_task(function test_encodeName() {
+ // Test encodeName().
+ for (let i = 0; i < encodeName_input.length; ++i) {
+ equal(encodeName(encodeName_input[i]), encodeName_output[i]);
+ }
+});
+
+add_task(test_getLogFolderPathForAccount);
+
+add_task(test_getLogFilePathForConversation);
+
+add_task(test_getLogFilePathForMUC);
+
+add_task(test_queueFileOperation);
+
+add_task(test_appendToFile);
+
+add_task(test_getLogPathsForConversation);
+
+add_task(test_logging);
+
+add_task(test_logFileSplitting);
diff --git a/comm/chat/components/src/test/xpcshell.ini b/comm/chat/components/src/test/xpcshell.ini
new file mode 100644
index 0000000000..63cce6e7e1
--- /dev/null
+++ b/comm/chat/components/src/test/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head =
+tail =
+
+[test_accounts.js]
+[test_commands.js]
+[test_conversations.js]
+[test_init.js]
+[test_logger.js]
diff --git a/comm/chat/content/chat-account-richlistitem.js b/comm/chat/content/chat-account-richlistitem.js
new file mode 100644
index 0000000000..23efcdc596
--- /dev/null
+++ b/comm/chat/content/chat-account-richlistitem.js
@@ -0,0 +1,354 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozElements, MozXULElement, gAccountManager */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+ );
+ const { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+
+ /**
+ * The MozChatAccountRichlistitem widget displays the information about the
+ * configured account: i.e. icon, state, name, error, checkbox for
+ * auto sign in and buttons for disconnect and properties.
+ *
+ * @augments {MozElements.MozRichlistitem}
+ */
+ class MozChatAccountRichlistitem extends MozElements.MozRichlistitem {
+ static get inheritedAttributes() {
+ return {
+ stack: "tooltiptext=protocol",
+ ".accountName": "value=name",
+ ".autoSignOn": "checked=autologin",
+ ".account-buttons": "autologin,name",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "chat-account-richlistitem");
+ this.addEventListener("dblclick", event => {
+ if (event.button == 0) {
+ // If we double clicked on a widget that has already done
+ // something with the first click, we should ignore the event
+ let localName = event.target.localName;
+ if (localName != "button" && localName != "checkbox") {
+ this.proceedDefaultAction();
+ }
+ }
+ // Prevent from loading an account wizard
+ event.stopPropagation();
+ });
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <vbox flex="1">
+ <hbox flex="1" align="start">
+ <vbox>
+ <stack>
+ <html:img class="accountIcon" alt="" />
+ <html:img class="statusTypeIcon" alt="" />
+ </stack>
+ <spacer flex="1"></spacer>
+ </vbox>
+ <vbox flex="1" align="start">
+ <label crop="end" class="accountName"></label>
+ <label class="connecting" crop="end" value="&account.connecting;"></label>
+ <label class="connected" crop="end"></label>
+ <label class="disconnecting" crop="end" value="&account.disconnecting;"></label>
+ <label class="disconnected" crop="end" value="&account.disconnected;"></label>
+ <description class="error error-description"></description>
+ <description class="error error-reconnect"></description>
+ <spacer flex="1"></spacer>
+ </vbox>
+ <checkbox label="&account.autoSignOn.label;"
+ class="autoSignOn"
+ accesskey="&account.autoSignOn.accesskey;"
+ oncommand="gAccountManager.autologin()"></checkbox>
+ </hbox>
+ <hbox flex="1" class="account-buttons">
+ <button class="disconnectButton" command="cmd_disconnect"></button>
+ <button class="connectButton" command="cmd_connect"></button>
+ <spacer flex="1"></spacer>
+ <button command="cmd_edit"></button>
+ </hbox>
+ </vbox>
+ `,
+ ["chrome://chat/locale/accounts.dtd"]
+ )
+ );
+ this._buttons = this.querySelector(".account-buttons");
+ this._connectedLabel = this.querySelector(".connected");
+ this._stateIcon = this.querySelector(".statusTypeIcon");
+ this.initializeAttributeInheritance();
+ }
+
+ set autoLogin(val) {
+ if (val) {
+ this.setAttribute("autologin", "true");
+ } else {
+ this.removeAttribute("autologin");
+ }
+ if (this._account.autoLogin != val) {
+ this._account.autoLogin = val;
+ }
+ }
+
+ get autoLogin() {
+ return this.hasAttribute("autologin");
+ }
+
+ /**
+ * override the default accessible name
+ */
+ get label() {
+ return this.getAttribute("name");
+ }
+
+ get account() {
+ return this._account;
+ }
+
+ get buttons() {
+ return this._buttons;
+ }
+
+ build(aAccount) {
+ this._account = aAccount;
+ this.setAttribute("name", aAccount.name);
+ this.setAttribute("id", aAccount.id);
+ let proto = aAccount.protocol;
+ this.setAttribute("protocol", proto.name);
+ this.querySelector(".accountIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(proto, 32)
+ );
+ this.refreshState();
+ this.autoLogin = aAccount.autoLogin;
+ }
+
+ /**
+ * Refresh the shown connection state.
+ *
+ * @param {"connected"|"connecting"|"disconnected"|"disconnecting"}
+ * [forceState] - The connection state to show. Otherwise, determined
+ * through the account status.
+ */
+ refreshState(forceState) {
+ let account = this._account;
+ let state = "unknown";
+ if (forceState) {
+ state = forceState;
+ } else if (account.connected) {
+ state = "connected";
+ } else if (account.disconnected) {
+ state = "disconnected";
+ } else if (this._account.connecting) {
+ state = "connecting";
+ } else if (this._account.disconnecting) {
+ state = "disconnecting";
+ }
+
+ switch (state) {
+ case "connected":
+ this.refreshConnectedLabel();
+ break;
+ case "connecting":
+ this.updateConnectingProgress();
+ break;
+ }
+
+ /* "state" and "error" attributes are needed for CSS styling of the
+ * accountIcon and the connection buttons. */
+ this.setAttribute("state", state);
+
+ if (account.connectionErrorReason !== Ci.prplIAccount.NO_ERROR) {
+ /* Icon and error attribute set in other method. */
+ this.updateConnectionError();
+ return;
+ }
+
+ this.removeAttribute("error");
+
+ this._stateIcon.setAttribute("src", ChatIcons.getStatusIconURI(state));
+ }
+
+ updateConnectingProgress() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ const key = "account.connection.progress";
+ let text = this._account.connectionStateMsg;
+ text = text
+ ? bundle.formatStringFromName(key, [text])
+ : bundle.GetStringFromName("account.connecting");
+
+ let progress = this.querySelector(".connecting");
+ progress.setAttribute("value", text);
+ if (this.reconnectUpdateInterval) {
+ this._cancelReconnectTimer();
+ }
+ }
+
+ updateConnectionError() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ const key = "account.connection.error";
+ let account = this._account;
+ let text;
+ let errorReason = account.connectionErrorReason;
+ if (errorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL) {
+ text = bundle.formatStringFromName(key + "UnknownPrpl", [
+ account.protocol.id,
+ ]);
+ } else if (errorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD) {
+ text = bundle.GetStringFromName(key + "EnteringPasswordRequired");
+ } else if (errorReason == Ci.imIAccount.ERROR_CRASHED) {
+ text = bundle.GetStringFromName(key + "CrashedAccount");
+ } else {
+ text = account.connectionErrorMessage;
+ }
+
+ if (errorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD) {
+ text = bundle.formatStringFromName(key, [text]);
+ }
+
+ /* "error" attribute is needed for CSS styling of the accountIcon and the
+ * connection buttons. */
+ this.setAttribute("error", "true");
+ this._stateIcon.setAttribute(
+ "src",
+ "chrome://global/skin/icons/warning.svg"
+ );
+ let error = this.querySelector(".error-description");
+ error.textContent = text;
+
+ let updateReconnect = () => {
+ let date = Math.round(
+ (account.timeOfNextReconnect - Date.now()) / 1000
+ );
+ let reconnect = "";
+ if (date > 0) {
+ let [val1, unit1, val2, unit2] = DownloadUtils.convertTimeUnits(date);
+ if (!val2) {
+ reconnect = bundle.formatStringFromName(
+ "account.reconnectInSingle",
+ [val1, unit1]
+ );
+ } else {
+ reconnect = bundle.formatStringFromName(
+ "account.reconnectInDouble",
+ [val1, unit1, val2, unit2]
+ );
+ }
+ }
+ this.querySelector(".error-reconnect").textContent = reconnect;
+ return reconnect;
+ };
+ if (updateReconnect() && !this.reconnectUpdateInterval) {
+ this.setAttribute("reconnectPending", "true");
+ this.reconnectUpdateInterval = setInterval(updateReconnect, 1000);
+ gAccountManager.disableCommandItems();
+ }
+ }
+
+ refreshConnectedLabel() {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/imAccounts.properties"
+ );
+ let date =
+ 60 * Math.floor((Date.now() - this._account.timeOfLastConnect) / 60000);
+ let value;
+ if (date > 0) {
+ let [val1, unit1, val2, unit2] = DownloadUtils.convertTimeUnits(date);
+ if (!val2) {
+ value = bundle.formatStringFromName("account.connectedForSingle", [
+ val1,
+ unit1,
+ ]);
+ } else {
+ value = bundle.formatStringFromName("account.connectedForDouble", [
+ val1,
+ unit1,
+ val2,
+ unit2,
+ ]);
+ }
+ } else {
+ value = bundle.GetStringFromName("account.connectedForSeconds");
+ }
+ this._connectedLabel.value = value;
+ }
+
+ _cancelReconnectTimer() {
+ this.removeAttribute("reconnectPending");
+ clearInterval(this.reconnectUpdateInterval);
+ delete this.reconnectUpdateInterval;
+ gAccountManager.disableCommandItems();
+ }
+
+ cancelReconnection() {
+ if (this.reconnectUpdateInterval) {
+ this._cancelReconnectTimer();
+ this._account.cancelReconnection();
+ }
+ }
+
+ destroy() {
+ // If we have a reconnect timer, stop it:
+ // it will throw errors otherwise (see bug 480).
+ if (!this.reconnectUpdateInterval) {
+ return;
+ }
+ clearInterval(this.reconnectUpdateInterval);
+ delete this.reconnectUpdateInterval;
+ }
+
+ get activeButton() {
+ let action = this.account.disconnected
+ ? ".connectButton"
+ : ".disconnectButton";
+ return this.querySelector(action);
+ }
+
+ setFocus() {
+ let focusTarget = this.activeButton;
+ let accountName = this.getAttribute("name");
+ focusTarget.setAttribute(
+ "aria-label",
+ focusTarget.label + " " + accountName
+ );
+ if (focusTarget.disabled) {
+ focusTarget = document.getElementById("accountlist");
+ }
+ focusTarget.focus();
+ }
+
+ proceedDefaultAction() {
+ this.activeButton.click();
+ }
+ }
+
+ MozXULElement.implementCustomInterface(MozChatAccountRichlistitem, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ ]);
+
+ customElements.define(
+ "chat-account-richlistitem",
+ MozChatAccountRichlistitem,
+ { extends: "richlistitem" }
+ );
+}
diff --git a/comm/chat/content/chat-tooltip.js b/comm/chat/content/chat-tooltip.js
new file mode 100644
index 0000000000..1bb3fd36bf
--- /dev/null
+++ b/comm/chat/content/chat-tooltip.js
@@ -0,0 +1,604 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozElements */
+/* global MozXULElement */
+/* global getBrowser */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+ );
+ let { ChatIcons } = ChromeUtils.importESModule(
+ "resource:///modules/chatIcons.sys.mjs"
+ );
+ const LazyModules = {};
+
+ ChromeUtils.defineESModuleGetters(LazyModules, {
+ Status: "resource:///modules/imStatusUtils.sys.mjs",
+ });
+
+ /**
+ * The MozChatTooltip widget implements a custom tooltip for chat. This tooltip
+ * is used to display a rich tooltip when you mouse over contacts, channels
+ * etc. in the chat view.
+ *
+ * @augments {XULPopupElement}
+ */
+ class MozChatTooltip extends MozElements.MozElementMixin(XULPopupElement) {
+ static get inheritedAttributes() {
+ return { ".displayName": "value=displayname" };
+ }
+
+ constructor() {
+ super();
+ this._buddy = null;
+
+ this.observer = {
+ // @see {nsIObserver}
+ observe: (subject, topic, data) => {
+ if (
+ subject == this.buddy &&
+ (topic == "account-buddy-status-changed" ||
+ topic == "account-buddy-status-detail-changed" ||
+ topic == "account-buddy-display-name-changed" ||
+ topic == "account-buddy-icon-changed")
+ ) {
+ this.updateTooltipFromBuddy(this.buddy);
+ } else if (
+ topic == "user-info-received" &&
+ data == this.observedUserInfo
+ ) {
+ this.updateTooltipInfo(
+ subject.QueryInterface(Ci.nsISimpleEnumerator)
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ this.addEventListener("popupshowing", event => {
+ if (!this._onPopupShowing()) {
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("popuphiding", event => {
+ this.buddy = null;
+ if ("observedUserInfo" in this && this.observedUserInfo) {
+ Services.obs.removeObserver(this.observer, "user-info-received");
+ delete this.observedUserInfo;
+ }
+ });
+ }
+
+ _onPopupShowing() {
+ // No tooltip for elements that have already been removed.
+ if (!this.triggerNode.parentNode) {
+ return false;
+ }
+
+ let showHTMLTooltip = false;
+
+ // Reset tooltip.
+ let largeTooltip = this.querySelector(".largeTooltip");
+ largeTooltip.hidden = false;
+ this.removeAttribute("label");
+ let htmlTooltip = this.querySelector(".htmlTooltip");
+ htmlTooltip.hidden = true;
+
+ this.hasBestAvatar = false;
+
+ // We have a few cases that have special behavior. These are richlistitems
+ // and have tooltip="<myid>".
+ let item = this.triggerNode.closest(
+ `[tooltip="${this.id}"] richlistitem`
+ );
+
+ // No tooltip on search results
+ if (item?.hasAttribute("is-search-result")) {
+ return false;
+ }
+
+ // No tooltip on the group headers
+ if (item && item.matches(`:scope[is="chat-group-richlistitem"]`)) {
+ return false;
+ }
+
+ if (item && item.matches(`:scope[is="chat-imconv-richlistitem"]`)) {
+ return this.updateTooltipFromConversation(item.conv);
+ }
+
+ if (item && item.matches(`:scope[is="chat-contact-richlistitem"]`)) {
+ return this.updateTooltipFromBuddy(
+ item.contact.preferredBuddy.preferredAccountBuddy
+ );
+ }
+
+ if (item) {
+ let contactlistbox = document.getElementById("contactlistbox");
+ let conv = contactlistbox.selectedItem.conv;
+ return this.updateTooltipFromParticipant(
+ item.chatBuddy.name,
+ conv,
+ item.chatBuddy
+ );
+ }
+
+ // Tooltips are also used for the chat content, where we need to do
+ // some more general checks.
+ let elt = this.triggerNode;
+ let classList = elt.classList;
+ // ib-sender nicks are handled with _originalMsg if possible
+ if (classList.contains("ib-nick") || classList.contains("ib-person")) {
+ let conv = getBrowser()._conv;
+ if (conv.isChat) {
+ return this.updateTooltipFromParticipant(elt.textContent, conv);
+ }
+ if (!conv.isChat && elt.textContent == conv.name) {
+ return this.updateTooltipFromConversation(conv);
+ }
+ }
+
+ let sender = elt.textContent;
+ let overrideAvatar = undefined;
+
+ // Are we over a message?
+ for (let node = elt; node; node = node.parentNode) {
+ if (!node._originalMsg) {
+ continue;
+ }
+ // Nick, build tooltip with original who information from message
+ if (classList.contains("ib-sender")) {
+ sender = node._originalMsg.who;
+ overrideAvatar = node._originalMsg.iconURL;
+ break;
+ }
+ // It's a message, so add a date/time tooltip.
+ let date = new Date(node._originalMsg.time * 1000);
+ let text;
+ if (new Date().toDateString() == date.toDateString()) {
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ {
+ timeStyle: "medium",
+ }
+ );
+ text = dateTimeFormatter.format(date);
+ } else {
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ {
+ dateStyle: "short",
+ timeStyle: "medium",
+ }
+ );
+ text = dateTimeFormatter.format(date);
+ }
+ // Setting the attribute on this node means that if the element
+ // we are pointing at carries a title set by the prpl,
+ // that title won't be overridden.
+ node.setAttribute("title", text);
+ showHTMLTooltip = true;
+ break;
+ }
+
+ if (classList.contains("ib-sender")) {
+ let conv = getBrowser()._conv;
+ if (conv.isChat) {
+ return this.updateTooltipFromParticipant(
+ sender,
+ conv,
+ undefined,
+ overrideAvatar
+ );
+ }
+ if (!conv.isChat && elt.textContent == conv.name) {
+ return this.updateTooltipFromConversation(conv, overrideAvatar);
+ }
+ }
+
+ largeTooltip.hidden = true;
+ // Show the title in the tooltip
+ if (showHTMLTooltip) {
+ let content = this.triggerNode.getAttribute("title");
+ if (!content) {
+ let closestTitle = this.triggerNode.closest("[title]");
+ if (closestTitle) {
+ content = closestTitle.getAttribute("title");
+ }
+ }
+ if (!content) {
+ return false;
+ }
+ htmlTooltip.textContent = content;
+ htmlTooltip.hidden = false;
+ return true;
+ }
+ return false;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.textContent = "";
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <vbox class="largeTooltip">
+ <html:div class="displayUserAccount tooltipDisplayUserAccount">
+ <stack>
+ <html:img class="userIcon" alt=""/>
+ <html:img class="statusTypeIcon status" alt=""/>
+ </stack>
+ <html:div class="nameAndStatusGrid">
+ <description class="displayName" crop="end"></description>
+ <html:img class="protoIcon status" alt=""/>
+ <html:hr />
+ <description class="statusMessage" crop="end"></description>
+ </html:div>
+ </html:div>
+ <html:table class="tooltipTable">
+ </html:table>
+ </vbox>
+ <html:div class="htmlTooltip" hidden="hidden"></html:div>
+ `)
+ );
+ this.initializeAttributeInheritance();
+ }
+
+ get bundle() {
+ if (!this._bundle) {
+ this._bundle = Services.strings.createBundle(
+ "chrome://chat/locale/imtooltip.properties"
+ );
+ }
+ return this._bundle;
+ }
+
+ set buddy(val) {
+ if (val == this._buddy) {
+ return;
+ }
+
+ if (!val) {
+ this._buddy.buddy.removeObserver(this.observer);
+ } else {
+ val.buddy.addObserver(this.observer);
+ }
+
+ this._buddy = val;
+ }
+
+ get buddy() {
+ return this._buddy;
+ }
+
+ get table() {
+ if (!("_table" in this)) {
+ this._table = this.querySelector(".tooltipTable");
+ }
+ return this._table;
+ }
+
+ setMessage(aMessage, noTopic = false) {
+ let msg = this.querySelector(".statusMessage");
+ msg.value = aMessage;
+ msg.toggleAttribute("noTopic", noTopic);
+ }
+
+ reset() {
+ while (this.table.hasChildNodes()) {
+ this.table.lastChild.remove();
+ }
+ }
+
+ /**
+ * Add a row to the tooltip's table
+ *
+ * @param {string} aLabel - Label for the table row.
+ * @param {string} aValue - Value for the table row.
+ * @param {{label: boolean, value: boolean}} [l10nIds] - Treat the label
+ * and value as l10n IDs
+ */
+ addRow(aLabel, aValue, l10nIds = { label: false, value: false }) {
+ let description;
+ let row = [...this.table.querySelectorAll("tr")].find(row => {
+ let th = row.querySelector("th");
+ if (l10nIds?.label) {
+ return th.dataset.l10nId == aLabel;
+ }
+ return th.textContent == aLabel;
+ });
+ if (!row) {
+ // Create a new row for this label.
+ row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+ let th = document.createElementNS("http://www.w3.org/1999/xhtml", "th");
+ if (l10nIds?.label) {
+ document.l10n.setAttributes(th, aLabel);
+ } else {
+ th.textContent = aLabel;
+ }
+ th.setAttribute("valign", "top");
+ row.appendChild(th);
+ description = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "td"
+ );
+ row.appendChild(description);
+ this.table.appendChild(row);
+ } else {
+ // Row with this label already exists - just update.
+ description = row.querySelector("td");
+ }
+ if (l10nIds?.value) {
+ document.l10n.setAttributes(description, aValue);
+ } else {
+ description.textContent = aValue;
+ }
+ }
+
+ addSeparator() {
+ if (this.table.hasChildNodes()) {
+ let lastElement = this.table.lastElementChild;
+ lastElement.querySelector("th").classList.add("chatTooltipSeparator");
+ lastElement.querySelector("td").classList.add("chatTooltipSeparator");
+ }
+ }
+
+ requestBuddyInfo(aAccount, aObservedName) {
+ // Libpurple prpls don't necessarily return data in response to
+ // requestBuddyInfo that is suitable for displaying inside a
+ // tooltip (e.g. too many objects, or <img> and <a> tags),
+ // so we only use it for JavaScript prpls.
+ // This is a terrible, terrible hack to work around the fact that
+ // ClassInfo.implementationLanguage has gone.
+ if (!aAccount.prplAccount || !aAccount.prplAccount.wrappedJSObject) {
+ return;
+ }
+ this.observedUserInfo = aObservedName;
+ Services.obs.addObserver(this.observer, "user-info-received");
+ aAccount.requestBuddyInfo(aObservedName);
+ }
+
+ /**
+ * Sets the shown user icon.
+ *
+ * @param {string|null} iconURI - The image uri to show, or "" to use the
+ * fallback, or null to hide the icon.
+ * @param {boolean} useFallback - True if the "fallback" icon should be shown
+ * if iconUri isn't provided.
+ */
+ setUserIcon(iconUri, useFalback) {
+ ChatIcons.setUserIconSrc(
+ this.querySelector(".userIcon"),
+ iconUri,
+ useFalback
+ );
+ }
+
+ setProtocolIcon(protocol) {
+ this.querySelector(".protoIcon").setAttribute(
+ "src",
+ ChatIcons.getProtocolIconURI(protocol)
+ );
+ }
+
+ setStatusIcon(statusName) {
+ this.querySelector(".statusTypeIcon").setAttribute(
+ "src",
+ ChatIcons.getStatusIconURI(statusName)
+ );
+ ChatIcons.setProtocolIconOpacity(
+ this.querySelector(".protoIcon"),
+ statusName
+ );
+ }
+
+ /**
+ * Regenerate the tooltip based on a buddy.
+ *
+ * @param {prplIAccountBuddy} aBuddy - The buddy to generate the conversation.
+ * @param {imIConversation} [aConv] - A conversation associated with this buddy.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead.
+ */
+ updateTooltipFromBuddy(aBuddy, aConv, overrideAvatar) {
+ this.buddy = aBuddy;
+
+ this.reset();
+ let name = aBuddy.userName;
+ let displayName = aBuddy.displayName;
+ this.setAttribute("displayname", displayName);
+ let account = aBuddy.account;
+ this.setProtocolIcon(account.protocol);
+ // If a conversation is provided, use the icon from it. Otherwise, use the
+ // buddy icon filename.
+ if (overrideAvatar) {
+ this.setUserIcon(overrideAvatar, true);
+ this.hasBestAvatar = true;
+ } else if (aConv && !aConv.isChat) {
+ this.setUserIcon(aConv.convIconFilename, true);
+ this.hasBestAvatar = true;
+ } else {
+ this.setUserIcon(aBuddy.buddyIconFilename, true);
+ }
+
+ let statusType = aBuddy.statusType;
+ this.setStatusIcon(LazyModules.Status.toAttribute(statusType));
+ this.setMessage(
+ LazyModules.Status.toLabel(statusType, aBuddy.statusText)
+ );
+
+ if (displayName != name) {
+ this.addRow(this.bundle.GetStringFromName("buddy.username"), name);
+ }
+
+ this.addRow(this.bundle.GetStringFromName("buddy.account"), account.name);
+
+ if (aBuddy.canVerifyIdentity) {
+ const identityStatus = aBuddy.identityVerified
+ ? "chat-buddy-identity-status-verified"
+ : "chat-buddy-identity-status-unverified";
+ this.addRow("chat-buddy-identity-status", identityStatus, {
+ label: true,
+ value: true,
+ });
+ }
+
+ // Add encryption status.
+ if (this.triggerNode.classList.contains("message-encrypted")) {
+ this.addRow(
+ this.bundle.GetStringFromName("encryption.tag"),
+ this.bundle.GetStringFromName("message.status")
+ );
+ }
+
+ this.requestBuddyInfo(account, aBuddy.normalizedName);
+
+ let tooltipInfo = aBuddy.getTooltipInfo();
+ if (tooltipInfo) {
+ this.updateTooltipInfo(tooltipInfo);
+ }
+ return true;
+ }
+
+ updateTooltipInfo(aTooltipInfo) {
+ for (let elt of aTooltipInfo) {
+ switch (elt.type) {
+ case Ci.prplITooltipInfo.pair:
+ case Ci.prplITooltipInfo.sectionHeader:
+ this.addRow(elt.label, elt.value);
+ break;
+ case Ci.prplITooltipInfo.sectionBreak:
+ this.addSeparator();
+ break;
+ case Ci.prplITooltipInfo.status:
+ let statusType = parseInt(elt.label);
+ this.setStatusIcon(LazyModules.Status.toAttribute(statusType));
+ this.setMessage(LazyModules.Status.toLabel(statusType, elt.value));
+ break;
+ case Ci.prplITooltipInfo.icon:
+ if (!this.hasBestAvatar) {
+ this.setUserIcon(elt.value);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Regenerate the tooltip based on a conversation.
+ *
+ * @param {imIConversation} aConv - The conversation to generate the tooltip from.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead if the conversation is a direct conversation.
+ */
+ updateTooltipFromConversation(aConv, overrideAvatar) {
+ if (!aConv.isChat && aConv.buddy) {
+ return this.updateTooltipFromBuddy(aConv.buddy, aConv, overrideAvatar);
+ }
+
+ this.reset();
+ this.setAttribute("displayname", aConv.name);
+ let account = aConv.account;
+ this.setProtocolIcon(account.protocol);
+ if (overrideAvatar && !aConv.isChat) {
+ this.setUserIcon(overrideAvatar, true);
+ this.hasBestAvatar = true;
+ } else {
+ // Set the icon, potentially showing a fallback icon if this is an IM.
+ this.setUserIcon(aConv.convIconFilename, !aConv.isChat);
+ }
+ if (aConv.isChat) {
+ if (!account.connected || aConv.left) {
+ this.setStatusIcon("chat-left");
+ } else {
+ this.setStatusIcon("chat");
+ }
+ let topic = aConv.topic;
+ let noTopic = !topic;
+ this.setMessage(topic || aConv.noTopicString, noTopic);
+ } else {
+ this.setStatusIcon("unknown");
+ this.setMessage(LazyModules.Status.toLabel("unknown"));
+ // Last ditch attempt to get some tooltip info. This call relies on
+ // the account's requestBuddyInfo implementation working correctly
+ // with aConv.normalizedName.
+ this.requestBuddyInfo(account, aConv.normalizedName);
+ }
+ this.addRow(this.bundle.GetStringFromName("buddy.account"), account.name);
+ return true;
+ }
+
+ /**
+ * Set the tooltip details based on a conversation participant.
+ *
+ * @param {string} aNick - Nick of the user this tooltip is for.
+ * @param {prplIConversation} aConv - Conversation this tooltip is shown
+ * in.
+ * @param {prplIConvChatBuddy} [aParticipant] - Participant to use instead
+ * of looking it up in the conversation by the passed nick.
+ * @param {string} [overrideAvatar] - URL for the user avatar to use
+ * instead.
+ */
+ updateTooltipFromParticipant(aNick, aConv, aParticipant, overrideAvatar) {
+ if (!aConv.target) {
+ return false; // We're viewing a log.
+ }
+ if (!aParticipant) {
+ aParticipant = aConv.target.getParticipant(aNick);
+ }
+
+ let account = aConv.account;
+ let normalizedNick = aConv.target.getNormalizedChatBuddyName(aNick);
+ // To try to ensure that we aren't misidentifying a nick with a
+ // contact, we require at least that the normalizedChatBuddyName of
+ // the nick is normalized like a normalizedName for contacts.
+ if (normalizedNick == account.normalize(normalizedNick)) {
+ let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount(
+ normalizedNick,
+ account
+ );
+ if (accountBuddy) {
+ return this.updateTooltipFromBuddy(
+ accountBuddy,
+ aConv,
+ overrideAvatar
+ );
+ }
+ }
+
+ this.reset();
+ this.setAttribute("displayname", aNick);
+ this.setProtocolIcon(account.protocol);
+ this.setStatusIcon("unknown");
+ this.setMessage(LazyModules.Status.toLabel("unknown"));
+ this.setUserIcon(overrideAvatar ?? aParticipant?.buddyIconFilename, true);
+ if (overrideAvatar) {
+ this.hasBestAvatar = true;
+ }
+
+ if (aParticipant.canVerifyIdentity) {
+ const identityStatus = aParticipant.identityVerified
+ ? "chat-buddy-identity-status-verified"
+ : "chat-buddy-identity-status-unverified";
+ this.addRow("chat-buddy-identity-status", identityStatus, {
+ label: true,
+ value: true,
+ });
+ }
+
+ this.requestBuddyInfo(account, normalizedNick);
+ return true;
+ }
+ }
+ customElements.define("chat-tooltip", MozChatTooltip, { extends: "tooltip" });
+}
diff --git a/comm/chat/content/conv.html b/comm/chat/content/conv.html
new file mode 100644
index 0000000000..ebcb33cb93
--- /dev/null
+++ b/comm/chat/content/conv.html
@@ -0,0 +1,4 @@
+<!DOCTYPE html>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
diff --git a/comm/chat/content/conversation-browser.js b/comm/chat/content/conversation-browser.js
new file mode 100644
index 0000000000..baa7f57447
--- /dev/null
+++ b/comm/chat/content/conversation-browser.js
@@ -0,0 +1,906 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const LazyModules = {};
+ ChromeUtils.defineESModuleGetters(LazyModules, {
+ cleanupImMarkup: "resource:///modules/imContentSink.sys.mjs",
+ getCurrentTheme: "resource:///modules/imThemes.sys.mjs",
+ getDocumentFragmentFromHTML: "resource:///modules/imThemes.sys.mjs",
+ getHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ initHTMLDocument: "resource:///modules/imThemes.sys.mjs",
+ insertHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ isNextMessage: "resource:///modules/imThemes.sys.mjs",
+ wasNextMessage: "resource:///modules/imThemes.sys.mjs",
+ replaceHTMLForMessage: "resource:///modules/imThemes.sys.mjs",
+ removeMessage: "resource:///modules/imThemes.sys.mjs",
+ serializeSelection: "resource:///modules/imThemes.sys.mjs",
+ smileTextNode: "resource:///modules/imSmileys.sys.mjs",
+ });
+
+ (function () {
+ // <browser> is lazily set up through setElementCreationCallback,
+ // i.e. put into customElements the first time it's really seen.
+ // Create a fake to ensure browser exists in customElements, since otherwise
+ // we can't extend it. Then make sure this fake doesn't stay around.
+ if (!customElements.get("browser")) {
+ delete document.createXULElement("browser");
+ }
+ })();
+
+ /**
+ * The chat conversation browser, i.e. the main content on the chat tab.
+ *
+ * @augments {MozBrowser}
+ */
+ class MozConversationBrowser extends customElements.get("browser") {
+ constructor() {
+ super();
+
+ this._conv = null;
+
+ // Make sure to load URLs externally.
+ this.addEventListener("click", event => {
+ // Right click should open the context menu.
+ if (event.button == 2) {
+ return;
+ }
+
+ // The 'click' event is fired even when the link is
+ // activated with the keyboard.
+
+ // The event target may be a descendant of the actual link.
+ let url;
+ for (let elem = event.target; elem; elem = elem.parentNode) {
+ if (HTMLAnchorElement.isInstance(elem)) {
+ url = elem.href;
+ if (url) {
+ break;
+ }
+ }
+ }
+ if (!url) {
+ return;
+ }
+
+ let uri = Services.io.newURI(url);
+
+ // http and https are the only schemes that are both
+ // allowed by our IM filters and exposed.
+ if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ // loadURI can throw if the default browser is misconfigured.
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ });
+
+ this.addEventListener("keypress", event => {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_PAGE_UP: {
+ if (event.shiftKey) {
+ this.contentWindow.scrollByPages(-1);
+ } else if (event.altKey) {
+ this.scrollToPreviousSection();
+ }
+ break;
+ }
+ case KeyEvent.DOM_VK_PAGE_DOWN: {
+ if (event.shiftKey) {
+ this.contentWindow.scrollByPages(1);
+ } else if (event.altKey) {
+ this.scrollToNextSection();
+ }
+ break;
+ }
+ case KeyEvent.DOM_VK_HOME: {
+ this.scrollToPreviousSection();
+ event.preventDefault();
+ break;
+ }
+ case KeyEvent.DOM_VK_END: {
+ this.scrollToNextSection();
+ event.preventDefault();
+ break;
+ }
+ }
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ super.connectedCallback();
+
+ this._theme = null;
+
+ this.autoCopyEnabled = false;
+
+ this.magicCopyPref =
+ "messenger.conversations.selections.magicCopyEnabled";
+
+ this.magicCopyInitialized = false;
+
+ this._destroyed = false;
+
+ // Makes the chat browser scroll to the bottom automatically when we append
+ // a new message. This behavior gets disabled when the user scrolls up to
+ // look at the history, and we re-enable it when the user scrolls to
+ // (within 10px) of the bottom.
+ this._convScrollEnabled = true;
+
+ this._textModifiers = [LazyModules.smileTextNode];
+
+ // These variables are reset in onStateChange:
+ this._lastMessage = null;
+ this._lastMessageIsContext = true;
+ this._firstNonContextElt = null;
+ this._messageDisplayPending = false;
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ this._sessions = [];
+
+ this.progressBar = null;
+
+ this.addEventListener("scroll", this.browserScroll);
+ this.addEventListener("resize", this.browserResize);
+
+ // @implements {nsIObserver}
+ this.prefObserver = (subject, topic, data) => {
+ if (this.magicCopyEnabled) {
+ this.enableMagicCopy();
+ } else {
+ this.disableMagicCopy();
+ }
+ };
+
+ // @implements {nsIController}
+ this.copyController = {
+ supportsCommand(command) {
+ return command == "cmd_copy" || command == "cmd_cut";
+ },
+ isCommandEnabled: command => {
+ return (
+ command == "cmd_copy" &&
+ !this.contentWindow.getSelection().isCollapsed
+ );
+ },
+ doCommand: command => {
+ let selection = this.contentWindow.getSelection();
+ if (selection.isCollapsed) {
+ return;
+ }
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(LazyModules.serializeSelection(selection));
+ },
+ onEvent(command) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIController"]),
+ };
+
+ // @implements {nsISelectionListener}
+ this.chatSelectionListener = {
+ notifySelectionChanged(document, selection, reason) {
+ if (
+ !(
+ reason & Ci.nsISelectionListener.MOUSEUP_REASON ||
+ reason & Ci.nsISelectionListener.SELECTALL_REASON ||
+ reason & Ci.nsISelectionListener.KEYPRESS_REASON
+ )
+ ) {
+ // We are still dragging, don't bother with the selection.
+ return;
+ }
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyStringToClipboard(
+ LazyModules.serializeSelection(selection),
+ Ci.nsIClipboard.kSelectionClipboard
+ );
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsISelectionListener"]),
+ };
+ }
+
+ init(conversation) {
+ // Magic Copy may be initialized if the convbrowser is already
+ // displaying a conversation.
+ this.uninitMagicCopy();
+
+ this._conv = conversation;
+
+ // init is called when the message style preview is
+ // reloaded so we need to reset _theme.
+ this._theme = null;
+
+ // Prevent ongoing asynchronous message display from continuing.
+ this._messageDisplayPending = false;
+
+ this.addEventListener(
+ "load",
+ () => {
+ LazyModules.initHTMLDocument(
+ this._conv,
+ this.theme,
+ this.contentDocument
+ );
+
+ this._exposeMethodsToContent();
+ this.initMagicCopy();
+
+ // We need to reset these variables here to avoid a race
+ // condition if we are starting to display a new conversation
+ // but the display of the previous conversation wasn't finished.
+ // This can happen if the user quickly changes the selected
+ // conversation in the log viewer.
+ this._lastMessage = null;
+ this._lastMessageIsContext = true;
+ this._firstNonContextElt = null;
+ this._messageDisplayPending = false;
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ this._sessions = [];
+ if (this.progressBar) {
+ this.progressBar.hidden = true;
+ }
+
+ this.onChatNodeContentLoad = this.onContentElementLoad.bind(this);
+ this.contentChatNode.addEventListener(
+ "load",
+ this.onChatNodeContentLoad,
+ true
+ );
+
+ // Notify observers to get the conversation shown.
+ Services.obs.notifyObservers(this, "conversation-loaded");
+ },
+ {
+ once: true,
+ capture: true,
+ }
+ );
+ this.loadURI(Services.io.newURI("chrome://chat/content/conv.html"), {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+
+ get theme() {
+ return this._theme || (this._theme = LazyModules.getCurrentTheme());
+ }
+
+ get contentDocument() {
+ return this.webNavigation.document;
+ }
+
+ get contentChatNode() {
+ return this.contentDocument.getElementById("Chat");
+ }
+
+ get magicCopyEnabled() {
+ return Services.prefs.getBoolPref(this.magicCopyPref);
+ }
+
+ enableMagicCopy() {
+ this.contentWindow.controllers.insertControllerAt(0, this.copyController);
+ this.autoCopyEnabled =
+ Services.clipboard.isClipboardTypeSupported(
+ Services.clipboard.kSelectionClipboard
+ ) && Services.prefs.getBoolPref("clipboard.autocopy");
+ if (this.autoCopyEnabled) {
+ let selection = this.contentWindow.getSelection();
+ if (selection) {
+ selection.addSelectionListener(this.chatSelectionListener);
+ }
+ }
+ }
+
+ disableMagicCopy() {
+ this.contentWindow.controllers.removeController(this.copyController);
+ if (this.autoCopyEnabled) {
+ let selection = this.contentWindow.getSelection();
+ if (selection) {
+ selection.removeSelectionListener(this.chatSelectionListener);
+ }
+ }
+ }
+
+ initMagicCopy() {
+ if (this.magicCopyInitialized) {
+ return;
+ }
+ Services.prefs.addObserver(this.magicCopyPref, this.prefObserver);
+ this.magicCopyInitialized = true;
+ if (this.magicCopyEnabled) {
+ this.enableMagicCopy();
+ }
+ }
+
+ uninitMagicCopy() {
+ if (!this.magicCopyInitialized) {
+ return;
+ }
+ Services.prefs.removeObserver(this.magicCopyPref, this.prefObserver);
+ if (this.magicCopyEnabled) {
+ this.disableMagicCopy();
+ }
+ this.magicCopyInitialized = false;
+ }
+
+ destroy() {
+ super.destroy();
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+ this._messageDisplayPending = false;
+
+ this.uninitMagicCopy();
+
+ if (this.contentChatNode) {
+ // Remove the listener only if the conversation was initialized.
+ this.contentChatNode.removeEventListener(
+ "load",
+ this.onChatNodeContentLoad,
+ true
+ );
+ }
+ }
+
+ _updateConvScrollEnabled() {
+ // Enable auto-scroll if the scrollbar is at the bottom.
+ let body = this.contentDocument.querySelector("body");
+ this._convScrollEnabled =
+ body.scrollHeight <= body.scrollTop + body.clientHeight + 10;
+ return this._convScrollEnabled;
+ }
+
+ convScrollEnabled() {
+ return this._convScrollEnabled || this._updateConvScrollEnabled();
+ }
+
+ _scrollToElement(aElt) {
+ aElt.scrollIntoView(true);
+ this._scrollingIntoView = true;
+ }
+
+ _exposeMethodsToContent() {
+ // Expose scrollToElement and convScrollEnabled to the message styles.
+ this.contentWindow.scrollToElement = this._scrollToElement.bind(this);
+ this.contentWindow.convScrollEnabled = this.convScrollEnabled.bind(this);
+ }
+
+ addTextModifier(aModifier) {
+ if (!this._textModifiers.includes(aModifier)) {
+ this._textModifiers.push(aModifier);
+ }
+ }
+
+ set isActive(value) {
+ if (!value && !this.browsingContext) {
+ return;
+ }
+ this.browsingContext.isActive = value;
+ if (value && this._pendingMessages.length) {
+ this.startDisplayingPendingMessages(false);
+ }
+ }
+
+ appendMessage(aMsg, aContext, aFirstUnread) {
+ this._pendingMessages.push({
+ msg: aMsg,
+ context: aContext,
+ firstUnread: aFirstUnread,
+ });
+ if (this.browsingContext.isActive) {
+ this.startDisplayingPendingMessages(true);
+ }
+ }
+
+ /**
+ * Replace an existing message in the conversation based on the remote ID.
+ *
+ * @param {imIMessage} msg - Message to use as replacement.
+ */
+ replaceMessage(msg) {
+ if (!msg.remoteId) {
+ // No remote id, nothing existing to replace.
+ return;
+ }
+ if (this._messageDisplayPending || this._pendingMessages.length) {
+ let pendingIndex = this._pendingMessages.findIndex(
+ ({ msg: pendingMsg }) => pendingMsg.remoteId === msg.remoteId
+ );
+ if (
+ pendingIndex > -1 &&
+ pendingIndex >= this._nextPendingMessageIndex
+ ) {
+ this._pendingMessages[pendingIndex].msg = msg;
+ }
+ }
+ if (this.browsingContext.isActive) {
+ msg.message = this.prepareMessageContent(msg);
+ const isNext = LazyModules.wasNextMessage(msg, this.contentDocument);
+ const htmlMessage = LazyModules.getHTMLForMessage(
+ msg,
+ this.theme,
+ isNext,
+ false
+ );
+ let ruler = this.contentDocument.getElementById("unread-ruler");
+ if (ruler?._originalMsg?.remoteId === msg.remoteId) {
+ ruler._originalMsg = msg;
+ ruler.nextMsgHtml = htmlMessage;
+ }
+ LazyModules.replaceHTMLForMessage(
+ msg,
+ htmlMessage,
+ this.contentDocument,
+ isNext
+ );
+ }
+ if (this._lastMessage?.remoteId === msg.remoteId) {
+ this._lastMessage = msg;
+ }
+ }
+
+ /**
+ * Remove an existing message in the conversation based on the remote ID.
+ *
+ * @param {string} remoteId - Remote ID of the message to remove.
+ */
+ removeMessage(remoteId) {
+ if (this.browsingContext.isActive) {
+ LazyModules.removeMessage(remoteId, this.contentDocument);
+ }
+ if (this._lastMessage?.remoteId === remoteId) {
+ // Reset last message info if we removed the last message.
+ this._lastMessage = null;
+ }
+ }
+
+ startDisplayingPendingMessages(delayed) {
+ if (this._messageDisplayPending) {
+ return;
+ }
+ this._messageDisplayPending = true;
+ this.contentWindow.messageInsertPending = true;
+ if (delayed) {
+ requestIdleCallback(this.displayPendingMessages.bind(this));
+ } else {
+ // 200ms here is a generous amount of time. The conversation switch
+ // should take no more than 100ms to feel 'immediate', but the perceived
+ // performance if we flicker is likely even worse than having a barely
+ // perceptible delay.
+ let deadline = Cu.now() + 200;
+ this.displayPendingMessages({
+ timeRemaining() {
+ return deadline - Cu.now();
+ },
+ });
+ }
+ }
+
+ // getNextPendingMessage and getPendingMessagesCount are the
+ // only 2 methods accessing the this._pendingMessages array
+ // directly during the chunked display of messages. It is
+ // possible to override these 2 methods to replace the array
+ // with something else. The log viewer for example uses an
+ // enumerator that creates message objects lazily to avoid
+ // jank when displaying lots of messages.
+ getNextPendingMessage() {
+ let length = this._pendingMessages.length;
+ if (this._nextPendingMessageIndex == length) {
+ return null;
+ }
+
+ let result = this._pendingMessages[this._nextPendingMessageIndex++];
+
+ if (this._nextPendingMessageIndex == length) {
+ this._pendingMessages = [];
+ this._nextPendingMessageIndex = 0;
+ }
+
+ return result;
+ }
+
+ getPendingMessagesCount() {
+ return this._pendingMessages.length;
+ }
+
+ displayPendingMessages(timing) {
+ if (!this._messageDisplayPending) {
+ return;
+ }
+
+ let max = this.getPendingMessagesCount();
+ do {
+ // One message takes less than 2ms on average.
+ let msg = this.getNextPendingMessage();
+ if (!msg) {
+ break;
+ }
+ this.displayMessage(
+ msg.msg,
+ msg.context,
+ ++this._pendingMessagesDisplayed < max,
+ msg.firstUnread
+ );
+ } while (timing.timeRemaining() > 2);
+
+ let event = document.createEvent("UIEvents");
+ event.initUIEvent("MessagesDisplayed", false, false, window, 0);
+ if (this._pendingMessagesDisplayed < max) {
+ if (this.progressBar) {
+ // Show progress bar if after the third call (ca. 120ms)
+ // less than half the messages have been displayed.
+ if (
+ ++this._displayPendingMessagesCalls > 2 &&
+ max > 2 * this._pendingMessagesDisplayed
+ ) {
+ this.progressBar.hidden = false;
+ }
+ this.progressBar.max = max;
+ this.progressBar.value = this._pendingMessagesDisplayed;
+ }
+ requestIdleCallback(this.displayPendingMessages.bind(this));
+ this.dispatchEvent(event);
+ return;
+ }
+ this.contentWindow.messageInsertPending = false;
+ this._messageDisplayPending = false;
+ this._pendingMessagesDisplayed = 0;
+ this._displayPendingMessagesCalls = 0;
+ if (this.progressBar) {
+ this.progressBar.hidden = true;
+ }
+ this.dispatchEvent(event);
+ }
+
+ displayMessage(aMsg, aContext, aNoAutoScroll, aFirstUnread) {
+ let doc = this.contentDocument;
+
+ if (aMsg.noLog && aMsg.notification && aMsg.who == "sessionstart") {
+ // New session log.
+ if (this._lastMessage) {
+ let ruler = doc.createElement("hr");
+ ruler.className = "sessionstart-ruler";
+ this.contentChatNode.appendChild(ruler);
+ this._sessions.push(ruler);
+ // Close any open bubble.
+ this._lastMessage = null;
+ }
+ // Suppress this message unless it was an error message.
+ if (!aMsg.error) {
+ return;
+ }
+ }
+
+ if (aFirstUnread) {
+ this.setUnreadRuler();
+ }
+
+ aMsg.message = this.prepareMessageContent(aMsg);
+
+ let next =
+ (aContext == this._lastMessageIsContext || aMsg.system) &&
+ LazyModules.isNextMessage(this.theme, aMsg, this._lastMessage);
+ let newElt;
+ if (next && aFirstUnread) {
+ // If there wasn't an unread ruler, this would be a Next message.
+ // Therefore, save that version for later.
+ let html = LazyModules.getHTMLForMessage(
+ aMsg,
+ this.theme,
+ next,
+ aContext
+ );
+ let ruler = doc.getElementById("unread-ruler");
+ ruler.nextMsgHtml = html;
+ ruler._originalMsg = aMsg;
+
+ // Remember where the Next message(s) would have gone.
+ let insert = doc.getElementById("insert");
+ if (!insert) {
+ insert = doc.createElement("div");
+ ruler.parentNode.insertBefore(insert, ruler);
+ }
+ insert.id = "insert-before";
+
+ next = false;
+ html = LazyModules.getHTMLForMessage(aMsg, this.theme, next, aContext);
+ newElt = LazyModules.insertHTMLForMessage(aMsg, html, doc, next);
+ let marker = doc.createElement("div");
+ marker.id = "end-of-split-block";
+ newElt.parentNode.appendChild(marker);
+
+ // Bracket the place where additional Next messages will be added,
+ // if that's not after the end-of-split-block element.
+ insert = doc.getElementById("insert");
+ if (insert) {
+ marker = doc.createElement("div");
+ marker.id = "next-messages-start";
+ insert.parentNode.insertBefore(marker, insert);
+ marker = doc.createElement("div");
+ marker.id = "next-messages-end";
+ insert.parentNode.insertBefore(marker, insert.nextElementSibling);
+ }
+ } else {
+ let html = LazyModules.getHTMLForMessage(
+ aMsg,
+ this.theme,
+ next,
+ aContext
+ );
+ newElt = LazyModules.insertHTMLForMessage(aMsg, html, doc, next);
+ }
+
+ if (!aNoAutoScroll) {
+ newElt.getBoundingClientRect(); // avoid ireflow bugs
+ if (this.convScrollEnabled()) {
+ this._scrollToElement(newElt);
+ }
+ }
+ this._lastElement = newElt;
+ this._lastMessage = aMsg;
+ if (!aContext && !this._firstNonContextElt && !aMsg.system) {
+ this._firstNonContextElt = newElt;
+ }
+ this._lastMessageIsContext = aContext;
+ }
+
+ /**
+ * Prepare the message text for display. Transforms plain text formatting
+ * and removes any unwanted formatting.
+ *
+ * @param {imIMessage} message - Raw message.
+ * @returns {string} Message content ready for insertion.
+ */
+ prepareMessageContent(message) {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(
+ Ci.mozITXTToHTMLConv
+ );
+
+ // kStructPhrase creates tags for plaintext-markup like *bold*,
+ // /italics/, etc. We always use this; the content filter will
+ // filter it out if the user does not want styling.
+ let csFlags = cs.kStructPhrase;
+ // Automatically find and link freetext URLs
+ if (!message.noLinkification) {
+ csFlags |= cs.kURLs;
+ }
+
+ // Right trim before displaying. This removes any OTR related
+ // whitespace when the extension isn't enabled.
+ let msg = message.displayMessage?.trimRight() ?? "";
+ msg = cs
+ .scanHTML(msg.replace(/&/g, "FROM-DTD-amp"), csFlags)
+ .replace(/FROM-DTD-amp/g, "&");
+
+ return LazyModules.cleanupImMarkup(
+ msg.replace(/\r?\n/g, "<br/>"),
+ null,
+ this._textModifiers
+ );
+ }
+
+ setUnreadRuler() {
+ // Remove any existing ruler (occurs when the window has lost focus).
+ this.removeUnreadRuler();
+
+ let ruler = this.contentDocument.createElement("hr");
+ ruler.id = "unread-ruler";
+ this.contentChatNode.appendChild(ruler);
+ }
+
+ removeUnreadRuler() {
+ if (this._lastMessage) {
+ this._lastMessage.whenRead();
+ }
+
+ let doc = this.contentDocument;
+ let ruler = doc.getElementById("unread-ruler");
+ if (!ruler) {
+ return;
+ }
+
+ // If a message block was split by the ruler, rejoin it.
+ let moveTo = doc.getElementById("insert-before");
+ if (moveTo) {
+ // Protect an existing insert node.
+ let actualInsert = doc.getElementById("insert");
+ if (actualInsert) {
+ actualInsert.id = "actual-insert";
+ }
+
+ // Add first message following the ruler as a Next type message.
+ // Replicates the relevant parts of insertHTMLForMessage().
+ let range = doc.createRange();
+ let moveToParent = moveTo.parentNode;
+ range.selectNode(moveToParent);
+ // eslint-disable-next-line no-unsanitized/method
+ let documentFragment = LazyModules.getDocumentFragmentFromHTML(
+ doc,
+ ruler.nextMsgHtml
+ );
+ for (
+ let root = documentFragment.firstElementChild;
+ root;
+ root = root.nextElementSibling
+ ) {
+ root._originalMsg = ruler._originalMsg;
+ root.dataset.remoteId = ruler._originalMsg.remoteId;
+ }
+ moveToParent.insertBefore(documentFragment, moveTo);
+
+ // If this added an insert node, insert the next messages there.
+ let insert = doc.getElementById("insert");
+ if (insert) {
+ moveTo.remove();
+ moveTo = insert;
+ moveToParent = moveTo.parentNode;
+ }
+
+ // Move remaining messages from the message block following the ruler.
+ let nextMessagesStart = doc.getElementById("next-messages-start");
+ if (nextMessagesStart) {
+ range = doc.createRange();
+ range.setStartAfter(nextMessagesStart);
+ range.setEndBefore(doc.getElementById("next-messages-end"));
+ moveToParent.insertBefore(range.extractContents(), moveTo);
+ }
+ moveTo.remove();
+
+ // Restore existing insert node.
+ if (actualInsert) {
+ actualInsert.id = "insert";
+ }
+
+ // Delete surplus message block.
+ range = doc.createRange();
+ range.setStartAfter(ruler);
+ range.setEndAfter(doc.getElementById("end-of-split-block"));
+ range.deleteContents();
+ }
+ ruler.remove();
+ }
+
+ _getSections() {
+ // If a section is displayed below this point, we assume not enough of
+ // it is visible, so we must scroll to it.
+ // The 3/4 constant is arbitrary, but it has to be greater than 1/2.
+ this._maximalSectionOffset = Math.round((this.clientHeight * 3) / 4);
+
+ // Get list of current section elements.
+ let sectionElements = [];
+ if (this._firstNonContextElt) {
+ sectionElements.push(this._firstNonContextElt);
+ }
+ let ruler = this.contentDocument.getElementById("unread-ruler");
+ if (ruler) {
+ sectionElements.push(ruler);
+ }
+ sectionElements = sectionElements.concat(this._sessions);
+
+ // Return ordered array of sections with entries
+ // [Y, scrollY such that Y is centered]
+ let sections = [];
+ let maxY = this.contentWindow.scrollMaxY;
+ for (let i = 0; i < sectionElements.length; ++i) {
+ let y = sectionElements[i].offsetTop;
+ // The section is unnecessary if close to top/bottom of conversation.
+ if (y < this._maximalSectionOffset || maxY < y) {
+ continue;
+ }
+ sections.push([y, y - Math.round(this.clientHeight / 2)]);
+ }
+ sections.sort((a, b) => a[0] - b[0]);
+ return sections;
+ }
+
+ scrollToPreviousSection() {
+ let sections = this._getSections();
+ let y = this.contentWindow.scrollY;
+ let newY = 0;
+ for (let i = sections.length - 1; i >= 0; --i) {
+ let section = sections[i];
+ if (y > section[0]) {
+ newY = section[1];
+ break;
+ }
+ }
+ this.contentWindow.scrollTo(0, newY);
+ }
+
+ scrollToNextSection() {
+ let sections = this._getSections();
+ let y = this.contentWindow.scrollY;
+ let newY = this.contentWindow.scrollMaxY;
+ for (let i = 0; i < sections.length; ++i) {
+ let section = sections[i];
+ if (y + this._maximalSectionOffset < section[0]) {
+ newY = section[1];
+ break;
+ }
+ }
+ this.contentWindow.scrollTo(0, newY);
+ }
+
+ browserScroll(event) {
+ if (this._scrollingIntoView) {
+ // We have explicitly requested a scrollIntoView, ignore the event.
+ this._scrollingIntoView = false;
+ this._lastScrollHeight = this.scrollHeight;
+ this._lastScrollWidth = this.scrollWidth;
+ return;
+ }
+
+ if (
+ !("_lastScrollHeight" in this) ||
+ this._lastScrollHeight != this.scrollHeight ||
+ this._lastScrollWidth != this.scrollWidth
+ ) {
+ // Ensure scroll events triggered by a change of the
+ // content area size (eg. resizing the window or moving the
+ // textbox splitter) don't affect the auto-scroll behavior.
+ this._lastScrollHeight = this.scrollHeight;
+ this._lastScrollWidth = this.scrollWidth;
+ }
+
+ // If images higher than one line of text load they will trigger a
+ // scroll event, which shouldn't disable auto-scroll while messages
+ // are being appended without being scrolled.
+ if (this._messageDisplayPending) {
+ return;
+ }
+
+ // Enable or disable auto-scroll based on the scrollbar position.
+ this._updateConvScrollEnabled();
+ }
+
+ browserResize(event) {
+ if (this._convScrollEnabled && this._lastElement) {
+ // The content area was resized and auto-scroll is enabled,
+ // make sure the last inserted element is still visible
+ this._scrollToElement(this._lastElement);
+ }
+ }
+
+ onContentElementLoad(event) {
+ if (
+ event.target.localName == "img" &&
+ this._convScrollEnabled &&
+ !this._messageDisplayPending &&
+ this._lastElement
+ ) {
+ // An image loaded while auto-scroll is enabled and no further
+ // messages are currently being appended. So we need to scroll
+ // the last element fully back into view.
+ this._scrollToElement(this._lastElement);
+ }
+ }
+ }
+ customElements.define("conversation-browser", MozConversationBrowser, {
+ extends: "browser",
+ });
+}
diff --git a/comm/chat/content/imAccountOptionsHelper.js b/comm/chat/content/imAccountOptionsHelper.js
new file mode 100644
index 0000000000..cbe8c486d8
--- /dev/null
+++ b/comm/chat/content/imAccountOptionsHelper.js
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var accountOptionsHelper = {
+ /**
+ * Create a new label and a corresponding input.
+ *
+ * @param {string} aType - The input type ("number" or "text").
+ * @param {string} aValue - The initial value for the input.
+ * @param {string} aLabel - The text for the label.
+ * @param {string} aName - The id for the input.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createTextbox(aType, aValue, aLabel, aName, grid) {
+ let label = document.createXULElement("label");
+ label.textContent = aLabel;
+ label.setAttribute("control", aName);
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ let input = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "input"
+ );
+ if (aType == "number") {
+ input.classList.add("input-number-inline");
+ } else {
+ input.classList.add("input-inline");
+ }
+ if (aType) {
+ input.setAttribute("type", aType);
+ }
+ input.setAttribute("value", aValue);
+ input.setAttribute("id", aName);
+
+ grid.appendChild(input);
+ },
+
+ /**
+ * Create a new label and a corresponding menulist.
+ *
+ * @param {object[]} aList - The list of items to fill the menulist with.
+ * @param {string} aList[].label - The label for the menuitem.
+ * @param {string} aList[].value - The value for the menuitem.
+ * @param {string} aLabel - The text for the label.
+ * @param {string} aName - The id for the menulist.
+ * @param {Element} grid - A container with a two column grid display to
+ * append the new elements to.
+ */
+ createMenulist(aList, aLabel, aName, grid) {
+ let label = document.createXULElement("label");
+ label.setAttribute("value", aLabel);
+ label.setAttribute("control", aName);
+ label.classList.add("label-inline");
+ grid.appendChild(label);
+
+ let menulist = document.createXULElement("menulist");
+ menulist.setAttribute("id", aName);
+ menulist.setAttribute("flex", "1");
+ menulist.classList.add("input-inline");
+ let popup = menulist.appendChild(document.createXULElement("menupopup"));
+ for (let elt of aList) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", elt.name);
+ item.setAttribute("value", elt.value);
+ popup.appendChild(item);
+ }
+ grid.appendChild(menulist);
+ },
+
+ // Adds options with specific prefix for ids to UI according to their types
+ // with optional attributes for each type and returns true if at least one
+ // option has been added to UI, otherwise returns false.
+ addOptions(aIdPrefix, aOptions, aAttributes) {
+ let grid = document.getElementById("protoSpecific");
+ while (grid.hasChildNodes()) {
+ grid.lastChild.remove();
+ }
+
+ let haveOptions = false;
+ for (let opt of aOptions) {
+ let text = opt.label;
+ let name = aIdPrefix + opt.name;
+ switch (opt.type) {
+ case Ci.prplIPref.typeBool:
+ let chk = document.createXULElement("checkbox");
+ chk.setAttribute("label", text);
+ chk.setAttribute("id", name);
+ if (opt.getBool()) {
+ chk.setAttribute("checked", "true");
+ }
+ // Span two columns.
+ chk.classList.add("grid-item-span-row");
+ grid.appendChild(chk);
+ break;
+ case Ci.prplIPref.typeInt:
+ this.createTextbox("number", opt.getInt(), text, name, grid);
+ break;
+ case Ci.prplIPref.typeString:
+ this.createTextbox("text", opt.getString(), text, name, grid);
+ break;
+ case Ci.prplIPref.typeList:
+ this.createMenulist(opt.getList(), text, name, grid);
+ document.getElementById(name).value = opt.getListDefault();
+ break;
+ default:
+ throw new Error("unknown preference type " + opt.type);
+ }
+ if (aAttributes && aAttributes[opt.type]) {
+ let element = document.getElementById(name);
+ for (let attr of aAttributes[opt.type]) {
+ element.setAttribute(attr.name, attr.value);
+ }
+ }
+ haveOptions = true;
+ }
+ return haveOptions;
+ },
+};
diff --git a/comm/chat/content/jar.mn b/comm/chat/content/jar.mn
new file mode 100644
index 0000000000..6016af2220
--- /dev/null
+++ b/comm/chat/content/jar.mn
@@ -0,0 +1,18 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% content chat %content/chat/
+ content/chat/imAccountOptionsHelper.js
+ content/chat/chat-account-richlistitem.js
+ content/chat/chat-tooltip.js
+ content/chat/conversation-browser.js
+ content/chat/conv.html
+ content/chat/otr-add-fingerprint.js
+ content/chat/otr-add-fingerprint.xhtml
+ content/chat/otr-auth.js
+ content/chat/otr-auth.xhtml
+ content/chat/otr-finger.js
+ content/chat/otr-finger.xhtml
+ content/chat/otrWorker.js
diff --git a/comm/chat/content/moz.build b/comm/chat/content/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/chat/content/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/chat/content/otr-add-fingerprint.js b/comm/chat/content/otr-add-fingerprint.js
new file mode 100644
index 0000000000..fb6d6c037d
--- /dev/null
+++ b/comm/chat/content/otr-add-fingerprint.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+var { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+window.addEventListener("DOMContentLoaded", () => {
+ otrAddFinger.onload();
+});
+
+var otrAddFinger = {
+ onload() {
+ let args = window.arguments[0].wrappedJSObject;
+
+ this.fingerWarning = document.getElementById("fingerWarning");
+ this.fingerError = document.getElementById("fingerError");
+ this.keyCount = document.getElementById("keyCount");
+
+ document.l10n.setAttributes(
+ document.getElementById("otrDescription"),
+ "otr-add-finger-description",
+ {
+ name: args.screenname,
+ }
+ );
+
+ document.addEventListener("dialogaccept", event => {
+ let hex = document.getElementById("fingerprint").value;
+ let context = OTR.getContextFromRecipient(
+ args.account,
+ args.protocol,
+ args.screenname
+ );
+ let finger = OTR.addFingerprint(context, hex);
+ if (finger.isNull()) {
+ event.preventDefault();
+ return;
+ }
+ try {
+ // Ignore the return, this is just a test.
+ OTR.getUIConvFromContext(context);
+ } catch (error) {
+ // We expect that a conversation may not have been started.
+ context = null;
+ }
+ OTR.setTrust(finger, true, context);
+ });
+ },
+
+ addBlankSpace(value) {
+ return value
+ .replace(/\s/g, "")
+ .trim()
+ .replace(/(.{8})/g, "$1 ")
+ .trim();
+ },
+
+ oninput(input) {
+ let hex = input.value.replace(/\s/g, "");
+
+ if (/[^0-9A-F]/gi.test(hex)) {
+ this.keyCount.hidden = true;
+ this.fingerWarning.hidden = false;
+ this.fingerError.hidden = false;
+ } else {
+ this.keyCount.hidden = false;
+ this.fingerWarning.hidden = true;
+ this.fingerError.hidden = true;
+ }
+
+ document.querySelector("dialog").getButton("accept").disabled =
+ input.value && !input.validity.valid;
+
+ this.keyCount.value = `${hex.length}/40`;
+ input.value = this.addBlankSpace(input.value);
+ },
+
+ onblur(input) {
+ input.value = this.addBlankSpace(input.value);
+ },
+};
diff --git a/comm/chat/content/otr-add-fingerprint.xhtml b/comm/chat/content/otr-add-fingerprint.xhtml
new file mode 100644
index 0000000000..cb5c17cea5
--- /dev/null
+++ b/comm/chat/content/otr-add-fingerprint.xhtml
@@ -0,0 +1,91 @@
+<?xml version="1.0" ?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+<?xml-stylesheet href="chrome://chat/skin/otrFingerprintDialog.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="OTR:AddFinger"
+ width="540"
+ height="200"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="otr-add-finger-title"></title>
+ <link rel="localization" href="messenger/otr/add-finger.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://chat/content/otr-add-fingerprint.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept,cancel" buttondisabledaccept="true">
+ <hbox align="center" pack="center" class="header-container">
+ <vbox>
+ <html:img
+ class="header-icon"
+ src="chrome://messenger/skin/icons/login.svg"
+ alt=""
+ />
+ </vbox>
+ <vbox flex="1">
+ <description id="otrDescription" />
+ </vbox>
+ </hbox>
+ <hbox class="form-control" align="center">
+ <label
+ data-l10n-id="otr-add-finger-fingerprint"
+ class="label-box"
+ control="fingerprint"
+ />
+ <hbox class="input-control" align="center" flex="1">
+ <html:input
+ id="fingerprint"
+ type="text"
+ data-l10n-id="otr-add-finger-input"
+ class="input-field"
+ oninput="otrAddFinger.oninput(this);"
+ onblur="otrAddFinger.onblur(this);"
+ pattern="[ 0-9a-fA-F]*"
+ minlength="44"
+ maxlength="44"
+ />
+ </hbox>
+ <html:img
+ id="fingerWarning"
+ class="warning-icon"
+ src="chrome://global/skin/icons/warning.svg"
+ alt=""
+ width="16"
+ height="16"
+ hidden="hidden"
+ />
+ </hbox>
+ <vbox class="input-helper-container" flex="1" align="end">
+ <label
+ id="fingerError"
+ data-l10n-id="otr-add-finger-tooltip-error"
+ class="msg-error"
+ hidden="true"
+ />
+ <label id="keyCount" class="input-helper" value="0/40" />
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otr-auth.js b/comm/chat/content/otr-auth.js
new file mode 100644
index 0000000000..24199a6acc
--- /dev/null
+++ b/comm/chat/content/otr-auth.js
@@ -0,0 +1,198 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+const { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+window.addEventListener("DOMContentLoaded", event => {
+ otrAuth.onload();
+});
+
+var [mode, uiConv, contactInfo] = window.arguments;
+
+function showSection(selected, hideMenu) {
+ document.getElementById("how").hidden = !!hideMenu;
+ ["questionAndAnswer", "sharedSecret", "manualVerification", "ask"].forEach(
+ function (key) {
+ document.getElementById(key).hidden = key !== selected;
+ }
+ );
+ window.sizeToContent();
+}
+
+function startSMP(context, answer, question) {
+ OTR.sendSecret(context, answer, question);
+ OTR.authUpdate(context, 10);
+}
+
+function manualVerification(fingerprint, context) {
+ let opts = document.getElementById("verifiedOption");
+ let trust = opts.selectedItem.value === "yes";
+ OTR.setTrust(fingerprint, trust, context);
+}
+
+async function populateFingers(context, theirs, trust) {
+ let yours = OTR.privateKeyFingerprint(context.account, context.protocol);
+ if (!yours) {
+ throw new Error("Fingerprint should already be generated.");
+ }
+
+ let [yourFPLabel, theirFPLabel] = await document.l10n.formatValues([
+ { id: "auth-your-fp-value", args: { own_name: context.account } },
+ { id: "auth-their-fp-value", args: { their_name: context.username } },
+ ]);
+
+ document.getElementById("yourFPLabel").value = yourFPLabel;
+ document.getElementById("theirFPLabel").value = theirFPLabel;
+
+ document.getElementById("yourFPValue").value = yours;
+ document.getElementById("theirFPValue").value = theirs;
+
+ let opts = document.getElementById("verifiedOption");
+ let verified = trust ? "yes" : "no";
+ for (let item of opts.menupopup.children) {
+ if (verified === item.value) {
+ opts.selectedItem = item;
+ break;
+ }
+ }
+}
+
+var otrAuth = {
+ async onload() {
+ // This window implements the interactive authentication of a buddy's
+ // key. At open time, we're given several parameters, and the "mode"
+ // parameter tells us from where we've been called.
+ // mode == "pref" means that we have been opened from the preferences,
+ // and it means we cannot rely on the other user being online, and
+ // we there might be no uiConv active currently, so we fall back.
+
+ let nameSource =
+ mode === "pref" ? contactInfo.screenname : uiConv.normalizedName;
+ let title = await document.l10n.formatValue("auth-title", {
+ name: nameSource,
+ });
+ document.title = title;
+
+ document.addEventListener("dialogaccept", () => {
+ return this.accept();
+ });
+
+ document.addEventListener("dialogcancel", () => {
+ return this.cancel();
+ });
+
+ let context, theirs;
+ switch (mode) {
+ case "start":
+ context = OTR.getContext(uiConv.target);
+ theirs = OTR.hashToHuman(context.fingerprint);
+ populateFingers(context, theirs, context.trust);
+ showSection("questionAndAnswer");
+ break;
+ case "pref":
+ context = OTR.getContextFromRecipient(
+ contactInfo.account,
+ contactInfo.protocol,
+ contactInfo.screenname
+ );
+ theirs = contactInfo.fingerprint;
+ populateFingers(context, theirs, contactInfo.trust);
+ showSection("manualVerification", true);
+ this.oninput({ value: true });
+ break;
+ case "ask":
+ let receivedQuestionLabel = document.getElementById(
+ "receivedQuestionLabel"
+ );
+ let receivedQuestionDisplay =
+ document.getElementById("receivedQuestion");
+ let responseLabel = document.getElementById("responseLabel");
+ if (contactInfo.question) {
+ receivedQuestionLabel.hidden = false;
+ receivedQuestionDisplay.hidden = false;
+ receivedQuestionDisplay.value = contactInfo.question;
+ responseLabel.value = await document.l10n.formatValue("auth-answer");
+ } else {
+ receivedQuestionLabel.hidden = true;
+ receivedQuestionDisplay.hidden = true;
+ responseLabel.value = await document.l10n.formatValue("auth-secret");
+ }
+ showSection("ask", true);
+ break;
+ }
+ },
+
+ accept() {
+ // uiConv may not be present in pref mode
+ let context = uiConv ? OTR.getContext(uiConv.target) : null;
+ if (mode === "pref") {
+ manualVerification(contactInfo.fpointer, context);
+ } else if (mode === "start") {
+ let how = document.getElementById("howOption");
+ switch (how.selectedItem.value) {
+ case "questionAndAnswer":
+ let question = document.getElementById("question").value;
+ let answer = document.getElementById("answer").value;
+ startSMP(context, answer, question);
+ break;
+ case "sharedSecret":
+ let secret = document.getElementById("secret").value;
+ startSMP(context, secret);
+ break;
+ case "manualVerification":
+ manualVerification(context.fingerprint, context);
+ break;
+ default:
+ throw new Error("Unreachable!");
+ }
+ } else if (mode === "ask") {
+ let response = document.getElementById("response").value;
+ OTR.sendResponse(context, response);
+ OTR.authUpdate(context, contactInfo.progress);
+ } else {
+ throw new Error("Unreachable!");
+ }
+ return true;
+ },
+
+ cancel() {
+ if (mode === "ask") {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ // Close the ask-auth notification if it was previously triggered.
+ OTR.notifyObservers(
+ {
+ context,
+ },
+ "otr:cancel-ask-auth"
+ );
+ }
+ },
+
+ oninput(e) {
+ document.querySelector("dialog").getButton("accept").disabled = !e.value;
+ },
+
+ how() {
+ let how = document.getElementById("howOption").selectedItem.value;
+ switch (how) {
+ case "questionAndAnswer":
+ this.oninput(document.getElementById("answer"));
+ break;
+ case "sharedSecret":
+ this.oninput(document.getElementById("secret"));
+ break;
+ case "manualVerification":
+ this.oninput({ value: true });
+ break;
+ }
+ showSection(how);
+ },
+};
diff --git a/comm/chat/content/otr-auth.xhtml b/comm/chat/content/otr-auth.xhtml
new file mode 100644
index 0000000000..4269db475f
--- /dev/null
+++ b/comm/chat/content/otr-auth.xhtml
@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title><!-- auth-title --></title>
+ <link rel="localization" href="messenger/otr/auth.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script defer="defer" src="chrome://chat/content/otr-auth.js"></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog
+ buttons="accept,cancel"
+ buttondisabledaccept="true"
+ data-l10n-id="otr-auth"
+ data-l10n-attrs="buttonlabelaccept"
+ >
+ <html:fieldset id="how" hidden="hidden">
+ <html:legend data-l10n-id="auth-how"></html:legend>
+ <vbox>
+ <menulist id="howOption" oncommand="otrAuth.how();">
+ <menupopup>
+ <menuitem
+ data-l10n-id="auth-question-and-answer-label"
+ value="questionAndAnswer"
+ />
+ <menuitem
+ data-l10n-id="auth-shared-secret-label"
+ value="sharedSecret"
+ />
+ <menuitem
+ data-l10n-id="auth-manual-verification-label"
+ value="manualVerification"
+ />
+ </menupopup>
+ </menulist>
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="questionAndAnswer" hidden="hidden">
+ <html:legend data-l10n-id="auth-question-and-answer"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-qa-instruction"
+ ></description>
+ <label data-l10n-id="auth-question" control="question" flex="1" />
+ <html:input
+ id="question"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-question"
+ />
+ <label data-l10n-id="auth-answer" control="answer" flex="1" />
+ <html:input
+ id="answer"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-answer"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="sharedSecret" hidden="hidden">
+ <html:legend data-l10n-id="auth-shared-secret"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-secret-instruction"
+ ></description>
+ <label data-l10n-id="auth-secret" control="secret" flex="1" />
+ <html:input
+ id="secret"
+ type="text"
+ class="input-inline"
+ aria-labelledby="auth-secret"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="manualVerification" hidden="hidden">
+ <html:legend data-l10n-id="auth-manual-verification"></html:legend>
+ <vbox>
+ <description
+ style="width: 300px; white-space: pre-wrap"
+ data-l10n-id="auth-manual-instruction"
+ ></description>
+
+ <label id="yourFPLabel" />
+ <html:input
+ id="yourFPValue"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ aria-labelledby="yourFPLabel"
+ />
+ <label id="theirFPLabel" />
+ <html:input
+ id="theirFPValue"
+ type="text"
+ class="input-inline"
+ readonly="readonly"
+ aria-labelledby="theirFPLabel"
+ />
+
+ <hbox align="center">
+ <label data-l10n-id="auth-verified" />
+ <menulist id="verifiedOption">
+ <menupopup>
+ <menuitem data-l10n-id="auth-yes" value="yes" />
+ <menuitem data-l10n-id="auth-no" value="no" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </vbox>
+ </html:fieldset>
+
+ <html:fieldset id="ask" hidden="hidden">
+ <label
+ id="receivedQuestionLabel"
+ data-l10n-id="auth-question-received"
+ />
+ <vbox>
+ <description
+ id="receivedQuestion"
+ style="width: 300px; white-space: pre-wrap"
+ />
+ <label id="responseLabel" control="response" flex="1" />
+ <html:input
+ id="response"
+ type="text"
+ class="input-inline"
+ aria-labelledby="responseLabel"
+ oninput="otrAuth.oninput(this)"
+ />
+ </vbox>
+ </html:fieldset>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otr-finger.js b/comm/chat/content/otr-finger.js
new file mode 100644
index 0000000000..56c9422cf9
--- /dev/null
+++ b/comm/chat/content/otr-finger.js
@@ -0,0 +1,159 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { OTR } = ChromeUtils.importESModule("resource:///modules/OTR.sys.mjs");
+
+var l10n = new Localization(["messenger/otr/finger-sync.ftl"], true);
+
+window.addEventListener("DOMContentLoaded", event => {
+ otrFinger.onload();
+});
+
+var gFingers;
+var fingerTreeView = {
+ selection: null,
+ rowCount: 0,
+
+ setTree(tree) {},
+ getImageSrc(row, column) {},
+ getProgressMode(row, column) {},
+ getCellValue(row, column) {},
+
+ getCellText(row, column) {
+ let finger = gFingers[row];
+ switch (column.id) {
+ case "verified": {
+ let id = finger.trust ? "finger-yes" : "finger-no";
+ return l10n.formatValueSync(id);
+ }
+ default:
+ return finger[column.id] || "";
+ }
+ },
+
+ isSeparator(index) {
+ return false;
+ },
+
+ isSorted() {
+ return false;
+ },
+
+ isContainer(index) {
+ return false;
+ },
+
+ cycleHeader(column) {},
+
+ getRowProperties(row) {
+ return "";
+ },
+
+ getColumnProperties(column) {
+ return "";
+ },
+
+ getCellProperties(row, column) {
+ return "";
+ },
+};
+
+var fingerTree;
+var otrFinger = {
+ onload() {
+ fingerTree = document.getElementById("fingerTree");
+ gFingers = OTR.knownFingerprints(window.arguments[0].account);
+ fingerTreeView.rowCount = gFingers.length;
+ fingerTree.view = fingerTreeView;
+ document.getElementById("remove-all").disabled = !gFingers.length;
+ },
+
+ getSelections(tree) {
+ let selections = [];
+ let selection = tree.view.selection;
+ if (selection) {
+ let count = selection.getRangeCount();
+ let min = {};
+ let max = {};
+ for (let i = 0; i < count; i++) {
+ selection.getRangeAt(i, min, max);
+ for (let k = min.value; k <= max.value; k++) {
+ if (k != -1) {
+ selections.push(k);
+ }
+ }
+ }
+ }
+ return selections;
+ },
+
+ select() {
+ let selections = this.getSelections(fingerTree);
+ document.getElementById("remove").disabled = !selections.length;
+ },
+
+ remove() {
+ fingerTreeView.selection.selectEventsSuppressed = true;
+ // mark fingers for removal
+ for (let sel of this.getSelections(fingerTree)) {
+ gFingers[sel].purge = true;
+ }
+ this.commonRemove();
+ },
+
+ removeAll() {
+ let confirmAllTitle = l10n.formatValueSync("finger-remove-all-title");
+ let confirmAllText = l10n.formatValueSync("finger-remove-all-message");
+
+ let buttonPressed = Services.prompt.confirmEx(
+ window,
+ confirmAllTitle,
+ confirmAllText,
+ Services.prompt.BUTTON_POS_1_DEFAULT +
+ Services.prompt.STD_OK_CANCEL_BUTTONS +
+ Services.prompt.BUTTON_DELAY_ENABLE,
+ 0,
+ 0,
+ 0,
+ null,
+ {}
+ );
+ if (buttonPressed != 0) {
+ return;
+ }
+
+ for (let j = 0; j < gFingers.length; j++) {
+ gFingers[j].purge = true;
+ }
+ this.commonRemove();
+ },
+
+ commonRemove() {
+ // OTR.forgetFingerprints will null out removed fingers.
+ let removalComplete = OTR.forgetFingerprints(gFingers);
+ for (let j = 0; j < gFingers.length; j++) {
+ if (gFingers[j] === null) {
+ let k = j;
+ while (k < gFingers.length && gFingers[k] === null) {
+ k++;
+ }
+ gFingers.splice(j, k - j);
+ fingerTreeView.rowCount -= k - j;
+ fingerTree.rowCountChanged(j, j - k); // negative
+ }
+ }
+ fingerTreeView.selection.selectEventsSuppressed = false;
+
+ if (!removalComplete) {
+ let infoTitle = l10n.formatValueSync("finger-subset-title");
+ let infoText = l10n.formatValueSync("finger-subset-message");
+ Services.prompt.alert(window, infoTitle, infoText);
+ }
+
+ document.getElementById("remove-all").disabled = !gFingers.length;
+ },
+};
diff --git a/comm/chat/content/otr-finger.xhtml b/comm/chat/content/otr-finger.xhtml
new file mode 100644
index 0000000000..95b3024565
--- /dev/null
+++ b/comm/chat/content/otr-finger.xhtml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css" ?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="otr-finger-title"></title>
+ <link rel="localization" href="messenger/otr/finger.ftl" />
+ <script defer="defer" src="chrome://chat/content/otr-finger.js"></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog buttons="accept" style="width: 100vw; height: 100vh">
+ <label data-l10n-id="finger-intro" />
+ <separator class="thin" />
+ <vbox id="fingerprints" class="contentPane" flex="1">
+ <tree
+ id="fingerTree"
+ flex="1"
+ width="800"
+ style="height: 20em"
+ onselect="otrFinger.select()"
+ >
+ <treecols>
+ <treecol
+ id="screenname"
+ data-l10n-id="finger-screen-name"
+ style="flex: 20 20 auto"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="fingerprint"
+ data-l10n-id="finger-fingerprint"
+ style="flex: 120 120 auto"
+ />
+ <splitter class="tree-splitter" />
+ <treecol
+ id="verified"
+ data-l10n-id="finger-verified"
+ style="flex: 10 10 auto"
+ />
+ <splitter class="tree-splitter" />
+ </treecols>
+ <treechildren />
+ </tree>
+ <separator class="thin" />
+ <hbox>
+ <button
+ id="remove"
+ data-l10n-id="finger-remove"
+ disabled="true"
+ oncommand="otrFinger.remove()"
+ />
+ <button
+ id="remove-all"
+ data-l10n-id="finger-remove-all"
+ disabled="true"
+ oncommand="otrFinger.removeAll()"
+ />
+ </hbox>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/chat/content/otrWorker.js b/comm/chat/content/otrWorker.js
new file mode 100644
index 0000000000..32d96ea9dd
--- /dev/null
+++ b/comm/chat/content/otrWorker.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/chrome-worker, node */
+importScripts("resource://gre/modules/workers/require.js");
+var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+var Funcs = {};
+
+// Only what we need from libotr.js
+Funcs.generateKey = function (path, otrl_version, address) {
+ let libotr = ctypes.open(path);
+
+ let abi = ctypes.default_abi;
+ let gcry_error_t = ctypes.unsigned_int;
+
+ // Initialize the OTR library. Pass the version of the API you are using.
+ let otrl_init = libotr.declare(
+ "otrl_init",
+ abi,
+ gcry_error_t,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int
+ );
+
+ // Do the private key generation calculation. You may call this from a
+ // background thread. When it completes, call
+ // otrl_privkey_generate_finish from the _main_ thread.
+ let otrl_privkey_generate_calculate = libotr.declare(
+ "otrl_privkey_generate_calculate",
+ abi,
+ gcry_error_t,
+ ctypes.void_t.ptr
+ );
+
+ otrl_init.apply(libotr, otrl_version);
+
+ let newkey = ctypes.voidptr_t(ctypes.UInt64("0x" + address));
+ let err = otrl_privkey_generate_calculate(newkey);
+ libotr.close();
+ if (err) {
+ throw new Error("otrl_privkey_generate_calculate (" + err + ")");
+ }
+};
+
+var worker = new PromiseWorker.AbstractWorker();
+
+worker.dispatch = function (method, args = []) {
+ return Funcs[method](...args);
+};
+
+worker.postMessage = function (res, ...args) {
+ self.postMessage(res, ...args);
+};
+
+worker.close = function () {
+ self.close();
+};
+
+self.addEventListener("message", msg => worker.handleMessage(msg));
diff --git a/comm/chat/locales/Makefile.in b/comm/chat/locales/Makefile.in
new file mode 100644
index 0000000000..adb18b6727
--- /dev/null
+++ b/comm/chat/locales/Makefile.in
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+LOCALE_TOPDIR=$(commtopsrcdir)
+LOCALE_RELATIVEDIR=chat/locales
diff --git a/comm/chat/locales/en-US/accounts.dtd b/comm/chat/locales/en-US/accounts.dtd
new file mode 100644
index 0000000000..c555baeede
--- /dev/null
+++ b/comm/chat/locales/en-US/accounts.dtd
@@ -0,0 +1,33 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- Account manager window for Instantbird -->
+<!ENTITY accounts.title "Accounts - &brandShortName;">
+<!-- Instant messaging account status window for Thunderbird -->
+<!ENTITY accountsWindow.title "Instant messaging status">
+
+<!ENTITY accountManager.newAccount.label "New Account">
+<!ENTITY accountManager.newAccount.accesskey "N">
+<!ENTITY accountManager.close.label "Close">
+<!ENTITY accountManager.close.accesskey "l">
+<!-- This should match account.commandkey in instantbird.dtd -->
+<!ENTITY accountManager.close.commandkey "a">
+<!-- This title must be short, displayed with a big font size -->
+<!ENTITY accountManager.noAccount.title "No account configured yet">
+<!ENTITY accountManager.noAccount.description "Click on the &accountManager.newAccount.label; button to let &brandShortName; guide you through the process of configuring one.">
+<!ENTITY account.autoSignOn.label "Sign-on at startup">
+<!ENTITY account.autoSignOn.accesskey "S">
+<!ENTITY account.connect.label "Connect">
+<!ENTITY account.connect.accesskey "o">
+<!ENTITY account.disconnect.label "Disconnect">
+<!ENTITY account.disconnect.accesskey "i">
+<!ENTITY account.edit.label "Properties">
+<!ENTITY account.edit.accesskey "P">
+<!ENTITY account.cancelReconnection.label "Cancel reconnection">
+<!ENTITY account.cancelReconnection.accesskey "A">
+<!ENTITY account.copyDebugLog.label "Copy Debug Log">
+<!ENTITY account.copyDebugLog.accesskey "C">
+<!ENTITY account.connecting "Connecting…">
+<!ENTITY account.disconnecting "Disconnecting…">
+<!ENTITY account.disconnected "Not Connected">
diff --git a/comm/chat/locales/en-US/accounts.properties b/comm/chat/locales/en-US/accounts.properties
new file mode 100644
index 0000000000..051ba0d496
--- /dev/null
+++ b/comm/chat/locales/en-US/accounts.properties
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (passwordPromptTitle, passwordPromptText):
+# %S is replaced with the name of the account
+passwordPromptTitle=Password for %S
+passwordPromptText=Please enter your password for %S in order to connect it.
+passwordPromptSaveCheckbox=Use Password Manager to remember this password.
diff --git a/comm/chat/locales/en-US/commands.properties b/comm/chat/locales/en-US/commands.properties
new file mode 100644
index 0000000000..d4e3a9122d
--- /dev/null
+++ b/comm/chat/locales/en-US/commands.properties
@@ -0,0 +1,27 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (commands):
+# %S is a comma separated list of command names.
+commands=Commands: %S.\nUse /help &lt;command&gt; for more information.
+# LOCALIZATION NOTE (noCommand, noHelp):
+# %S is the command name the user typed.
+noCommand=No '%S' command.
+noHelp=No help message for the '%S' command, sorry!
+
+sayHelpString=say &lt;message&gt;: send a message without processing commands.
+rawHelpString=raw &lt;message&gt;: send a message without escaping HTML entities.
+helpHelpString=help &lt;name&gt;: show the help message for the &lt;name&gt; command, or the list of possible commands when used without parameter.
+
+# LOCALIZATION NOTE (statusCommand):
+# %1$S is replaced with a status command name
+# (one of "back", "away", "busy", "dnd", or "offline").
+# %2$S is replaced with the localized version of that status type
+# (one of the 5 strings below).
+statusCommand=%1$S &lt;status message&gt;: set the status to %2$S with an optional status message.
+back=available
+away=away
+busy=unavailable
+dnd=unavailable
+offline=offline
diff --git a/comm/chat/locales/en-US/contacts.properties b/comm/chat/locales/en-US/contacts.properties
new file mode 100644
index 0000000000..33af79c1d3
--- /dev/null
+++ b/comm/chat/locales/en-US/contacts.properties
@@ -0,0 +1,8 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (defaultGroup):
+# This is the name of the group that will automatically be created when adding a
+# buddy without specifying a group.
+defaultGroup=Contacts
diff --git a/comm/chat/locales/en-US/conversations.properties b/comm/chat/locales/en-US/conversations.properties
new file mode 100644
index 0000000000..1a5564a6ec
--- /dev/null
+++ b/comm/chat/locales/en-US/conversations.properties
@@ -0,0 +1,80 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (targetChanged):
+# %1$S is the new conversation title (display name of the new target),
+# %2$S is the protocol name used for the new target.
+targetChanged=The conversation will continue with %1$S, using %2$S.
+
+# LOCALIZATION NOTE (statusChanged):
+# %1$S is the display name of the contact.
+# %2$S is the new status type (a value from status.properties).
+statusChanged=%1$S is now %2$S.
+# LOCALIZATION NOTE (statusChangedWithStatusText):
+# %1$S is the display name of the contact.
+# %2$S is the new status type (a value from status.properties).
+# %3$S is the status text (eg. "I'm currently away from the computer").
+statusChangedWithStatusText=%1$S is now %2$S: %3$S.
+# LOCALIZATION NOTE (statusChangedFromUnknown[WithStatusText]):
+# special case of the previous 2 strings for when the status was
+# previously unknown. These 2 strings should not mislead the user
+# into thinking the person's status has just changed.
+statusChangedFromUnknown=%1$S is %2$S.
+statusChangedFromUnknownWithStatusText=%1$S is %2$S: %3$S.
+# LOCALIZATION NOTE (statusKnown[WithStatusText]):
+# special case of the previous 2 strings for when an account has just
+# been reconnected, so the status is now known. These 2 strings should not
+# mislead the user into thinking the person's status has just changed.
+statusKnown=Your account has been reconnected (%1$S is %2$S).
+statusKnownWithStatusText=Your account has been reconnected (%1$S is %2$S: %3$S).
+# LOCALIZATION NOTE (statusUnknown):
+# %S is the display name of the contact.
+statusUnknown=Your account is disconnected (the status of %S is no longer known).
+
+accountDisconnected=Your account is disconnected.
+accountReconnected=Your account has been reconnected.
+
+# LOCALIZATION NOTE (autoReply):
+# %S is replaced by the text of a message that was sent as an automatic reply.
+autoReply=Auto-reply - %S
+
+# LOCALIZATION NOTE (noTopic):
+# Displayed instead of the topic when no topic is set.
+noTopic=No topic message for this room.
+
+# LOCALIZATION NOTE (topicSet):
+# %1$S is the conversation name, %2$S is the topic.
+topicSet=The topic for %1$S is: %2$S.
+# LOCALIZATION NOTE (topicNotSet):
+# %S is the conversation name.
+topicNotSet=There is no topic for %S.
+# LOCALIZATION NOTE (topicChanged):
+# %1$S is the user who changed the topic, %2$S is the new topic.
+topicChanged=%1$S has changed the topic to: %2$S.
+# LOCALIZATION NOTE (topicCleared):
+# %1$S is the user who cleared the topic.
+topicCleared=%1$S has cleared the topic.
+
+# LOCALIZATION NOTE (nickSet):
+# This is displayed as a system message when a participant changes his/her
+# nickname in a conversation.
+# %1$S is the old nick.
+# %2$S is the new nick.
+nickSet=%1$S is now known as %2$S.
+# LOCALIZATION NOTE (nickSet.you):
+# This is displayed as a system message when your nickname is changed.
+# %S is your new nick.
+nickSet.you=You are now known as %S.
+
+# LOCALIZATION NOTE (messenger.conversations.selections.ellipsis):
+# ellipsis is used when copying a part of a message to show that the message was cut
+messenger.conversations.selections.ellipsis=[…]
+
+# LOCALIZATION NOTE (messenger.conversations.selections.{system,content,action}MessagesTemplate):
+# These 3 templates are used to format selected messages before copying them.
+# Do not translate the texts between % characters, but feel free to adjust
+# whitespace and separators to make them fit your locale.
+messenger.conversations.selections.systemMessagesTemplate=%time% - %message%
+messenger.conversations.selections.contentMessagesTemplate=%time% - %sender%: %message%
+messenger.conversations.selections.actionMessagesTemplate=%time% * %sender% %message%
diff --git a/comm/chat/locales/en-US/facebook.properties b/comm/chat/locales/en-US/facebook.properties
new file mode 100644
index 0000000000..2e00cbcb2e
--- /dev/null
+++ b/comm/chat/locales/en-US/facebook.properties
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+facebook.chat.name=Facebook Chat
+facebook.disabled=Facebook Chat is no longer supported due to Facebook disabling their XMPP gateway.
diff --git a/comm/chat/locales/en-US/imtooltip.properties b/comm/chat/locales/en-US/imtooltip.properties
new file mode 100644
index 0000000000..bf08302100
--- /dev/null
+++ b/comm/chat/locales/en-US/imtooltip.properties
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+buddy.username=Username
+buddy.account=Account
+contact.tags=Tags
+
+encryption.tag=Encryption Status
+message.status=Message encrypted
diff --git a/comm/chat/locales/en-US/irc.properties b/comm/chat/locales/en-US/irc.properties
new file mode 100644
index 0000000000..68e71b9332
--- /dev/null
+++ b/comm/chat/locales/en-US/irc.properties
@@ -0,0 +1,209 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (irc.usernameHint):
+# This is displayed inside the accountUsernameInfoWithDescription
+# string defined in imAccounts.properties when the user is
+# configuring an IRC account.
+irc.usernameHint=nick
+
+# LOCALIZATION NOTE (connection.error.*):
+# These will show in the account manager if the account is
+# disconnected because of an error.
+connection.error.lost=Lost connection with server
+connection.error.timeOut=Connection timed out
+connection.error.invalidUsername=%S is not an allowed username
+connection.error.invalidPassword=Invalid server password
+connection.error.passwordRequired=Password required
+
+# LOCALIZATION NOTE (joinChat.*):
+# These show up on the join chat menu. An underscore is for the access key.
+joinChat.channel=_Channel
+joinChat.password=_Password
+
+# LOCALIZATION NOTE (options.*):
+# These are the protocol specific options shown in the account manager and
+# account wizard windows.
+options.server=Server
+options.port=Port
+options.ssl=Use SSL
+options.encoding=Character Set
+options.quitMessage=Quit message
+options.partMessage=Part message
+options.showServerTab=Show messages from the server
+options.alternateNicks=Alternate nicks
+
+# LOCALIZATION NOTE (ctcp.version):
+# %1$S is the nickname of the user whose version was requested.
+# %2$S is the version response from the client.
+ctcp.version=%1$S is using "%2$S".
+# LOCALIZATION NOTE (ctcp.time):
+# %1$S is the nickname of the user whose time was requested.
+# %2$S is the time response.
+ctcp.time=The time for %1$S is %2$S.
+
+# LOCALZIATION NOTE (command.*):
+# These are the help messages for each command, the %S is the command name
+# Each command first gives the parameter it accepts and then a description of
+# the command.
+command.action=%S &lt;action to perform&gt;: Perform an action.
+command.ban=%S &lt;nick!user@host&gt;: Ban the users matching the given pattern.
+command.ctcp=%S &lt;nick&gt; &lt;msg&gt;: Sends a CTCP message to the nick.
+command.chanserv=%S &lt;command&gt;: Send a command to ChanServ.
+command.deop=%S &lt;nick1&gt;[,&lt;nick2&gt;]*: Remove channel operator status from someone. You must be a channel operator to do this.
+command.devoice=%S &lt;nick1&gt;[,&lt;nick2&gt;]*: Remove channel voice status from someone, preventing them from speaking if the channel is moderated (+m). You must be a channel operator to do this.
+command.invite2=%S &lt;nick&gt;[ &lt;nick&gt;]* [&lt;channel&gt;]: Invite one or more nicks to join you in the current channel, or to join the specified channel.
+command.join=%S &lt;room1&gt;[ &lt;key1&gt;][,&lt;room2&gt;[ &lt;key2&gt;]]*: Enter one or more channels, optionally providing a channel key for each if needed.
+command.kick=%S &lt;nick&gt; [&lt;message&gt;]: Remove someone from a channel. You must be a channel operator to do this.
+command.list=%S: Display a list of chat rooms on the network. Warning, some servers may disconnect you upon doing this.
+command.memoserv=%S &lt;command&gt;: Send a command to MemoServ.
+command.modeUser2=%S &lt;nick&gt; [(+|-)&lt;mode&gt;]: Get, set or unset a user's mode.
+command.modeChannel2=%S [&lt;channel&gt;] [(+|-)&lt;new mode&gt; [&lt;parameter&gt;][,&lt;parameter&gt;]*]: Get, set, or unset a channel mode.
+command.msg=%S &lt;nick&gt; &lt;message&gt;: Send a private message to a user (as opposed to a channel).
+command.nick=%S &lt;new nickname&gt;: Change your nickname.
+command.nickserv=%S &lt;command&gt;: Send a command to NickServ.
+command.notice=%S &lt;target&gt; &lt;message&gt;: Send a notice to a user or channel.
+command.op=%S &lt;nick1&gt;[,&lt;nick2&gt;]*: Grant channel operator status to someone. You must be a channel operator to do this.
+command.operserv=%S &lt;command&gt;: Send a command to OperServ.
+command.part=%S [message]: Leave the current channel with an optional message.
+command.ping=%S [&lt;nick&gt;]: Asks how much lag a user (or the server if no user specified) has.
+command.quit=%S &lt;message&gt;: Disconnect from the server, with an optional message.
+command.quote=%S &lt;command&gt;: Send a raw command to the server.
+command.time=%S: Displays the current local time at the IRC server.
+command.topic=%S [&lt;new topic&gt;]: Set this channel's topic.
+command.umode=%S (+|-)&lt;new mode&gt;: Set or unset a user mode.
+command.version=%S &lt;nick&gt;: Request the version of a user's client.
+command.voice=%S &lt;nick1&gt;[,&lt;nick2&gt;]*: Grant channel voice status to someone. You must be a channel operator to do this.
+command.whois2=%S [&lt;nick&gt;]: Get information on a user.
+
+# LOCALIZATION NOTE (message.*):
+# These are shown as system messages in the conversation.
+# %1$S is the nick and %2$S is the nick and host of the user who joined.
+message.join=%1$S [%2$S] entered the room.
+message.rejoined=You have rejoined the room.
+# %1$S is the nick of who kicked you.
+# %2$S is message.kicked.reason, if a kick message was given.
+message.kicked.you=You have been kicked by %1$S%2$S.
+# %1$S is the nick that is kicked, %2$S the nick of the person who kicked
+# %1$S. %3$S is message.kicked.reason, if a kick message was given.
+message.kicked=%1$S has been kicked by %2$S%3$S.
+# %S is the kick message
+message.kicked.reason=: %S
+# %1$S is the new mode, %2$S is the nickname of the user whose mode
+# was changed, and %3$S is who set the mode.
+message.usermode=Mode %1$S for %2$S set by %3$S.
+# %1$S is the new channel mode and %2$S is who set the mode.
+message.channelmode=Channel mode %1$S set by %2$S.
+# %S is the user's mode.
+message.yourmode=Your mode is %S.
+# Could not change the nickname. %S is the user's nick.
+message.nick.fail=Could not use the desired nickname. Your nick remains %S.
+# The parameter is the message.parted.reason, if a part message is given.
+message.parted.you=You have left the room (Part%1$S).
+# %1$S is the user's nick, %2$S is message.parted.reason, if a part message is given.
+message.parted=%1$S has left the room (Part%2$S).
+# %S is the part message supplied by the user.
+message.parted.reason=: %S
+# %1$S is the user's nick, %2$S is message.quit2 if a quit message is given.
+message.quit=%1$S has left the room (Quit%2$S).
+# The parameter is the quit message given by the user.
+message.quit2=: %S
+# %1$S is the nickname of the user that invited us, %2$S is the conversation
+# name.
+message.inviteReceived=%1$S has invited you to %2$S.
+# %1$S is the nickname of the invited user, %2$S is the conversation name
+# they were invited to.
+message.invited=%1$S was successfully invited to %2$S.
+# %1$S is the nickname of the invited user, %2$S is the conversation name
+# they were invited to but are already in
+message.alreadyInChannel=%1$S is already in %2$S.
+# %S is the nickname of the user who was summoned.
+message.summoned=%S was summoned.
+# %S is the nickname of the user whose WHOIS information follows this message.
+message.whois=WHOIS information for %S:
+# %1$S is the nickname of the (offline) user whose WHOWAS information follows this message.
+message.whowas=%1$S is offline. WHOWAS information for %1$S:
+# %1$S is the entry description (from tooltip.*), %2$S is its value.
+message.whoisEntry=\ua0\ua0\ua0\ua0%1$S: %2$S
+# %S is the nickname that is not known to the server.
+message.unknownNick=%S is an unknown nickname.
+# %1$S is the nickname of the user who changed the mode and %2$S is the new
+# channel key (password).
+message.channelKeyAdded=%1$S changed the channel password to %2$S.
+message.channelKeyRemoved=%S removed the channel password.
+# This will be followed by a list of ban masks.
+message.banMasks=Users connected from the following locations are banned from %S:
+message.noBanMasks=There are no banned locations for %S.
+message.banMaskAdded=Users connected from locations matching %1$S have been banned by %2$S.
+message.banMaskRemoved=Users connected from locations matching %1$S are no longer banned by %2$S.
+# LOCALIZATION NOTE (message.ping): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# %1$S is the nickname of the user or the server that was pinged.
+# #2 is the delay (in milliseconds).
+message.ping=Ping reply from %1$S in #2 millisecond.;Ping reply from %1$S in #2 milliseconds.
+
+
+# LOCALIZATION NOTE (error.*):
+# These are shown as error messages in the conversation or server tab.
+# %S is the channel name.
+error.noChannel=There is no channel: %S.
+error.tooManyChannels=Cannot join %S; you've joined too many channels.
+# %1$S is your new nick, %2$S is the kill message from the server.
+error.nickCollision=Nick already in use, changing nick to %1$S [%2$S].
+error.erroneousNickname=%S is not an allowed nickname.
+error.banned=You are banned from this server.
+error.bannedSoon=You will soon be banned from this server.
+error.mode.wrongUser=You cannot change modes for other users.
+# %S is the nickname or channel name that isn't available.
+error.noSuchNick=%S is not online.
+error.wasNoSuchNick=There was no nickname: %S
+error.noSuchChannel=There is no channel: %S.
+error.unavailable=%S is temporarily unavailable.
+# %S is the channel name.
+error.channelBanned=You have been banned from %S.
+error.cannotSendToChannel=You cannot send messages to %S.
+error.channelFull=The channel %S is full.
+error.inviteOnly=You must be invited to join %S.
+error.nonUniqueTarget=%S is not a unique user@host or shortname or you have tried to join too many channels at once.
+error.notChannelOp=You are not a channel operator on %S.
+error.notChannelOwner=You are not a channel owner of %S.
+error.wrongKey=Cannot join %S, invalid channel password.
+error.sendMessageFailed=An error occurred while sending your last message. Please try again once the connection has been reestablished.
+# %1$S is the channel the user tried to join, %2$S is the channel
+# he was forwarded to.
+error.channelForward=You may not join %1$S, and were automatically redirected to %2$S.
+# %S is the mode that the user tried to set but was not recognized
+# by the server as a valid mode.
+error.unknownMode='%S' is not a valid user mode on this server.
+
+# LOCALIZATION NOTE (tooltip.*):
+# These are the descriptions given in a tooltip with information received
+# from a whois response.
+# The human readable ("realname") description of the user.
+tooltip.realname=Name
+tooltip.server=Connected to
+# The username and hostname that the user connects from (usually based on the
+# reverse DNS of the user's IP, but often mangled by the server to
+# protect users).
+tooltip.connectedFrom=Connected from
+tooltip.registered=Registered
+tooltip.registeredAs=Registered as
+tooltip.secure=Using a secure connection
+# The away message of the user
+tooltip.away=Away
+tooltip.ircOp=IRC Operator
+tooltip.bot=Bot
+tooltip.lastActivity=Last activity
+# %S is the timespan elapsed since the last activity.
+tooltip.timespan=%S ago
+tooltip.channels=Currently on
+
+# %1$S is the server name, %2$S is some generic server information (usually a
+# location or the date the user was last seen).
+tooltip.serverValue=%1$S (%2$S)
+
+# LOCALIZATION NOTE (yes, no):
+# These are used to turn true/false values into a yes/no response.
+yes=Yes
+no=No
diff --git a/comm/chat/locales/en-US/logger.properties b/comm/chat/locales/en-US/logger.properties
new file mode 100644
index 0000000000..2228c50a4c
--- /dev/null
+++ b/comm/chat/locales/en-US/logger.properties
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (badLogfile):
+# %S is the filename of the log file.
+badLogfile=Empty or corrupt log file: %S
diff --git a/comm/chat/locales/en-US/matrix.ftl b/comm/chat/locales/en-US/matrix.ftl
new file mode 100644
index 0000000000..8fa0485239
--- /dev/null
+++ b/comm/chat/locales/en-US/matrix.ftl
@@ -0,0 +1,24 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+### Matrix Protocol strings
+
+## Conversation names when a room has no user readable name.
+
+room-name-empty = Empty conversation
+
+# Variables:
+# $oldName (String) - The previous name the conversation had before it was
+# removed.
+room-name-empty-had-name = Empty conversation (was { $oldName })
+
+# Variables:
+# $participant (String) - The name of one participant that isn't the user.
+# $otherParticipantCount (Number) - The count of other participants apart from
+# the user and $participant.
+room-name-others2 =
+ { $otherParticipantCount ->
+ [one] { $participant } and { $otherParticipantCount } other
+ *[other] { $participant } and { $otherParticipantCount } others
+ }
diff --git a/comm/chat/locales/en-US/matrix.properties b/comm/chat/locales/en-US/matrix.properties
new file mode 100644
index 0000000000..ba0d85dc8b
--- /dev/null
+++ b/comm/chat/locales/en-US/matrix.properties
@@ -0,0 +1,255 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (matrix.usernameHint):
+# This is displayed inside the accountUsernameInfoWithDescription
+# string defined in imAccounts.properties when the user is
+# configuring a Matrix account.
+matrix.usernameHint=Matrix ID
+
+# LOCALIZATION NOTE (options.*):
+# These are the protocol specific options shown in the account manager and
+# account wizard windows.
+options.saveToken=Store access token
+options.deviceDisplayName=Device display name
+options.homeserver=Server
+options.backupPassphrase=Key Backup Passphrase
+
+# LOCALIZATION NOTE (options.encryption.*):
+# These are strings used to build the status information of the encryption
+# storage, shown in the account manager. %S is one of the statuses and the
+# strings are combined with a pipe (|) between.
+options.encryption.enabled=Cryptographic Functions: %S
+options.encryption.secretStorage=Secret Storage: %S
+options.encryption.keyBackup=Encryption Key Backup: %S
+options.encryption.crossSigning=Cross Signing: %S
+options.encryption.statusOk=ok
+options.encryption.statusNotOk=not ready
+options.encryption.needBackupPassphrase=Please enter your backup key passphrase in the protocol options.
+options.encryption.setUpSecretStorage=To set up secret storage, please use another client and afterwards enter the generated backup key passphrase in the "General" tab.
+options.encryption.setUpBackupAndCrossSigning=To activate encryption key backups and cross signing, enter your backup key passphrase in the "General" tab or verify the identity of one of the sessions below.
+# %1$S is the session ID, %2$S is the session display name
+options.encryption.session=%1$S (%2$S)
+
+# LOCALIZATION NOTE (connection.*):
+# These will be displayed in the account manager in order to show the progress
+# of the connection.
+# (These will be displayed in account.connection.progress from
+# accounts.properties, which adds … at the end, so do not include
+# periods at the end of these messages.)
+connection.requestAuth=Waiting for your authorization
+connection.requestAccess=Finalizing authentication
+
+# LOCALIZATION NOTE (connection.error.*):
+# These will show in the account manager if an error occurs during the
+# connection attempt.
+connection.error.noSupportedFlow=Server offers no compatible login flow.
+connection.error.authCancelled=You cancelled the authorization process.
+connection.error.sessionEnded=Session was logged out.
+connection.error.serverNotFound=Could not identify the Matrix server for the given Matrix account.
+
+# LOCALIZATION NOTE (chatRoomField.*):
+# These are the name of fields displayed in the 'Join Chat' dialog
+# for Matrix accounts.
+# The _ character won't be displayed; it indicates the next
+# character of the string should be used as the access key for this
+# field.
+chatRoomField.room=_Room
+
+# LOCALIZATION NOTE (tooltip.*):
+# These are the descriptions given in a tooltip with information received
+# from the "User" object.
+# The human readable name of the user.
+tooltip.displayName=Display name
+# %S is the timespan elapsed since the last activity.
+tooltip.timespan=%S ago
+tooltip.lastActive=Last activity
+
+# LOCALIZATION NOTE (powerLevel.*):
+# These are the string representations of different standard power levels and strings.
+# %S are one of the power levels, Default/Moderator/Admin/Restricted/Custom.
+powerLevel.default=Default
+powerLevel.moderator=Moderator
+powerLevel.admin=Admin
+powerLevel.restricted=Restricted
+powerLevel.custom=Custom
+# %1$S is the power level name
+# %2$S is the power level number
+powerLevel.detailed=%1$S (%2$S)
+powerLevel.defaultRole=Default role: %S
+powerLevel.inviteUser=Invite users: %S
+powerLevel.kickUsers=Kick users: %S
+powerLevel.ban=Ban users: %S
+powerLevel.roomAvatar=Change room avatar: %S
+powerLevel.mainAddress=Change main address for the room: %S
+powerLevel.history=Change history visibility: %S
+powerLevel.roomName=Change room name: %S
+powerLevel.changePermissions=Change permissions: %S
+powerLevel.server_acl=Send m.room.server_acl events: %S
+powerLevel.upgradeRoom=Upgrade the room: %S
+powerLevel.remove=Remove messages: %S
+powerLevel.events_default=Events default: %S
+powerLevel.state_default=Change setting: %S
+powerLevel.encryption=Enable Room encryption: %S
+powerLevel.topic=Set room topic: %S
+
+# LOCALIZATION NOTE (detail.*):
+# These are the string representations of different matrix properties.
+# %S will typically be strings with the actual values.
+# Example placeholder: "Foo bar"
+detail.name=Name: %S
+# Example placeholder: "My first room"
+detail.topic=Topic: %S
+# Example placeholder: "5"
+detail.version=Room Version: %S
+# Example placeholder: "#thunderbird:mozilla.org"
+detail.roomId=RoomID: %S
+# %S are all admin users. Example: "@foo:example.com, @bar:example.com"
+detail.admin=Admin: %S
+# %S are all moderators. Example: "@lorem:mozilla.org, @ipsum:mozilla.org"
+detail.moderator=Moderator: %S
+# Example placeholder: "#thunderbird:matrix.org"
+detail.alias=Alias: %S
+# Example placeholder: "can_join"
+detail.guest=Guest Access: %S
+# This is a heading, followed by the powerLevel.* strings
+detail.power=Power Levels:
+
+# LOCALIZATION NOTE (command.*):
+# These are the help messages for each command, the %S is the command name
+# Each command first gives the parameter it accepts and then a description of
+# the command.
+command.ban=%S &lt;userId&gt; [&lt;reason&gt;]: Ban the user with the userId from the room with optional reason message. Requires permission to ban users.
+command.invite=%S &lt;userId&gt;: Invite the user to the room.
+command.kick=%S &lt;userId&gt; [&lt;reason&gt;]: Kick the user with the userId from the room with optional reason message. Requires permission to kick users.
+command.nick=%S &lt;display_name&gt;: Change your display name.
+command.op=%S &lt;userId&gt; [&lt;power level&gt;]: Define the power level of the user. Enter an integer value, User: 0, Moderator: 50 and Admin: 100. Default will be 50 if no argument is provided. Requires permission to change member's power levels. Does not work on admins other than yourself.
+command.deop=%S &lt;userId&gt;: Reset the user to power level 0 (User). Requires permission to change member's power levels. Does not work on admins other than yourself.
+command.leave=%S: Leave the current room.
+command.topic=%S &lt;topic&gt;: Set the topic for the room. Requires permissions to change the room topic.
+command.unban=%S &lt;userId&gt;: Unban a user who is banned from the room. Requires permission to ban users.
+command.visibility=%S [&lt;visibility&gt;]: Set the visibility of the current room in the current Home Server's room directory. Enter an integer value, Private: 0 and Public: 1. Default will be Private (0) if no argument is provided. Requires permission to change room visibility.
+command.guest=%S &lt;guest access&gt; &lt;history visibility&gt;: Set the access and history visibility of the current room for the guest users. Enter two integer values, the first for the guest access (not allowed: 0 and allowed: 1) and the second for the history visibility (not visible: 0 and visible: 1). Requires permission to change history visibility.
+command.roomname=%S &lt;name&gt;: Set the name for the room. Requires permission to change the room name.
+command.detail=%S: Display the details of the room.
+command.addalias=%S &lt;alias&gt;: Create an alias for the room. Expected room alias of the form '#localname:domain'. Requires permission to add aliases.
+command.removealias=%S &lt;alias&gt;: Remove the alias for the room. Expected room alias of the form '#localname:domain'. Requires permission to remove aliases.
+command.upgraderoom=%S &lt;newVersion&gt;: Upgrade room to given version. Requires permission to upgrade the room.
+command.me=%S &lt;action&gt;: Perform an action.
+command.msg=%S &lt;userId&gt; &lt;message&gt;: Send a direct message to the given user.
+command.join=%S &lt;roomId&gt;: Join the given room.
+
+# LOCALIZATION NOTE (message.*):
+# These are shown as system messages in the conversation.
+# %1$S is the name of the user who banned.
+# %2$S is the name of the user who got banned.
+message.banned=%1$S banned %2$S.
+# Same as message.banned but with a reason.
+# %3$S is the reason the user was banned.
+message.bannedWithReason=%1$S banned %2$S. Reason: %3$S
+# %1$S is the name of the user who accepted the invitation.
+# %2$S is the name of the user who sent the invitation.
+message.acceptedInviteFor=%1$S accepted the invitation for %2$S.
+# %S is the name of the user who accepted an invitation.
+message.acceptedInvite=$S accepted an invitation.
+# %1$S is the name of the user who invited.
+# %2$S is the name of the user who got invited.
+message.invited=%1$S invited %2$S.
+# %1$S is the name of the user who changed their display name.
+# %2$S is the old display name.
+# %3$S is the new display name.
+message.displayName.changed=%1$S changed their display name from %2$S to %3$S.
+# %1$S is the name of the user who set their display name.
+# %2$S is the newly set display name.
+message.displayName.set=%1$S set their display name to %2$S.
+# %1$S is the name of the user who removed their display name.
+# %2$S is the old display name which has been removed.
+message.displayName.remove=%1$S removed their display name %2$S.
+# %S is the name of the user who has joined the room.
+message.joined=%S has joined the room.
+# %S is the name of the user who has rejected the invitation.
+message.rejectedInvite=%S has rejected the invitation.
+# %S is the name of the user who has left the room.
+message.left=%S has left the room.
+# %1$S is the name of the user who unbanned.
+# %2$S is the name of the user who got unbanned.
+message.unbanned=%1$S unbanned %2$S.
+# %1$S is the name of the user who kicked.
+# %2$S is the name of the user who got kicked.
+message.kicked=%1$S kicked %2$S.
+# Same as message.kicked but with a third parameter for the reason.
+# %3$S is the reason for the kick.
+message.kickedWithReason=%1$S kicked %2$S. Reason: %3$S
+# %1$S is the name of the user who withdrew invitation.
+# %2$S is the name of the user whose invitation has been withdrawn.
+message.withdrewInvite=%1$S withdrew %2$S's invitation.
+# Same as message.withdrewInvite but with a third parameter for the reason.
+# %3$S is the reason the invite was withdrawn.
+message.withdrewInviteWithReason=%1$S withdrew %2$S's invitation. Reason: %3$S
+# %S is the name of the user who has removed the room name.
+message.roomName.remove=%S removed the room name.
+# %1$S is the name of the user who changed the room name.
+# %2$S is the new room name.
+message.roomName.changed=%1$S changed the room name to %2$S.
+# %1$S is the name of the user who changed the power level.
+# %2$S is a list of "message.powerLevel.fromTo" strings representing power level changes separated by commas
+# power level changes, separated by commas if there are multiple changes.
+message.powerLevel.changed=%1$S changed the power level of %2$S.
+# %1$S is the name of the target user whose power level has been changed.
+# %2$S is the old power level.
+# %2$S is the new power level.
+message.powerLevel.fromTo=%1$S from %2$S to %3$S
+# %S is the name of the user who has allowed guests to join the room.
+message.guest.allowed=%S has allowed guests to join the room.
+# %S is the name of the user who has prevented guests to join the room.
+message.guest.prevented=%S has prevented guests from joining the room.
+# %S is the name of the user who has made future room history visible to anyone.
+message.history.anyone=%S made future room history visible to anyone.
+# %S is the name of the user who has made future room history visible to all room members.
+message.history.shared=%S made future room history visible to all room members.
+# %S is the name of the user who has made future room history visible to all room members, from the point they are invited.
+message.history.invited=%S made future room history visible to all room members, from the point they are invited.
+# %S is the name of the user who has made future room history visible to all room members, from the point they joined.
+message.history.joined=%S made future room history visible to all room members, from the point they joined.
+# %1$S is the name of the user who changed the address.
+# %2$S is the old address.
+# %3$S is the new address.
+message.alias.main=%1$S set the main address for this room from %2$S to %3$S.
+# %1$S is the name of the user who added the address.
+# %2$S is a comma delimited list of added addresses.
+message.alias.added=%1$S added %2$S as alternative address for this room.
+# %1$S is the name of the user who removed the address.
+# %2$S is a comma delimited list of removed addresses.
+message.alias.removed=%1$S removed %2$S as alternative address for this room.
+# %1$S is the name of the user that edited the alias addresses.
+# %2$S is a comma delimited list of removed addresses.
+# %3$S is a comma delmited list of added addresses.
+message.alias.removedAndAdded=%1$S removed %2$S and added %3$S as address for this room.
+message.spaceNotSupported=This room is a space, which is not supported.
+message.encryptionStart=Messages in this conversation are now end-to-end encrypted.
+# %1$S is the name of the user who sent the verification request.
+# %2$S is the name of the user that is receiving the verification request.
+message.verification.request2=%1$S wants to verify %2$S.
+# %1$S is the name of the user who cancelled the verification request.
+# %2$S is the reason given why the verification was cancelled.
+message.verification.cancel2=%1$S cancelled the verification with the reason: %2$S
+message.verification.done=Verification completed.
+message.decryptionError=Could not decrypt the contents of this message. To request encryption keys from your other devices, right click this message.
+message.decrypting=Decrypting…
+message.redacted=Message was redacted.
+# %1$S is the username of the user that reacted.
+# %2$S is the username of the user that sent the message the reaction was added to.
+# %3$S is the content (typically an emoji) of the reaction.
+message.reaction=%1$S reacted to %2$S with %3$S.
+
+# Label in the message context menu
+message.action.requestKey=Re-request Keys
+message.action.redact=Redact
+message.action.report=Report Message
+message.action.retry=Retry Sending
+message.action.cancel=Cancel Message
+
+# LOCALIZATION NOTE (error.*)
+# These are strings shown as system messages when an action the user took fails.
+error.sendMessageFailed=An error occurred while sending your message "%1$S".
diff --git a/comm/chat/locales/en-US/status.properties b/comm/chat/locales/en-US/status.properties
new file mode 100644
index 0000000000..af88441cd0
--- /dev/null
+++ b/comm/chat/locales/en-US/status.properties
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+availableStatusType=Available
+awayStatusType=Away
+unavailableStatusType=Unavailable
+offlineStatusType=Offline
+invisibleStatusType=Invisible
+idleStatusType=Idle
+mobileStatusType=Mobile
+# LOCALIZATION NOTE (unknownStatusType):
+# the status of a buddy is unknown when it's in the list of a disconnected account
+unknownStatusType=Unknown
+
+# LOCALIZATION NOTE (statusWithStatusMessage):
+# Used to display the status of a buddy together with its status message.
+# %1$S is the status type, %2$S is the status message text.
+statusWithStatusMessage=%1$S - %2$S
+
+# LOCALIZATION NOTE (messenger.status.defaultIdleAwayMessage):
+# This will be the away message put automatically when the user is idle.
+messenger.status.defaultIdleAwayMessage=I am currently away from the computer.
diff --git a/comm/chat/locales/en-US/twitter.properties b/comm/chat/locales/en-US/twitter.properties
new file mode 100644
index 0000000000..c379791459
--- /dev/null
+++ b/comm/chat/locales/en-US/twitter.properties
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (twitter.protocolName)
+# This name is used whenever the name of the protocol is shown.
+twitter.protocolName=Twitter
+
+twitter.disabled=Twitter is no longer supported due to Twitter disabling their streaming protocol.
diff --git a/comm/chat/locales/en-US/xmpp.properties b/comm/chat/locales/en-US/xmpp.properties
new file mode 100644
index 0000000000..8a53616e36
--- /dev/null
+++ b/comm/chat/locales/en-US/xmpp.properties
@@ -0,0 +1,274 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (connection.*)
+# These will be displayed in the account manager in order to show the progress
+# of the connection.
+# (These will be displayed in account.connection.progress from
+# accounts.properties, which adds … at the end, so do not include
+# periods at the end of these messages.)
+connection.initializingStream=Initializing stream
+connection.initializingEncryption=Initializing encryption
+connection.authenticating=Authenticating
+connection.gettingResource=Getting resource
+connection.downloadingRoster=Downloading contact list
+connection.srvLookup=Looking up the SRV record
+
+# LOCALIZATION NOTE (connection.error.*)
+# These will show in the account manager if an error occurs during the
+# connection attempt.
+connection.error.invalidUsername=Invalid username (your username should contain an '@' character)
+connection.error.failedToCreateASocket=Failed to create a socket (Are you offline?)
+connection.error.serverClosedConnection=The server closed the connection
+connection.error.resetByPeer=Connection reset by peer
+connection.error.timedOut=The connection timed out
+connection.error.receivedUnexpectedData=Received unexpected data
+connection.error.incorrectResponse=Received an incorrect response
+connection.error.startTLSRequired=The server requires encryption but you disabled it
+connection.error.startTLSNotSupported=The server doesn't support encryption but your configuration requires it
+connection.error.failedToStartTLS=Failed to start encryption
+connection.error.noAuthMec=No authentication mechanism offered by the server
+connection.error.noCompatibleAuthMec=None of the authentication mechanisms offered by the server are supported
+connection.error.notSendingPasswordInClear=The server only supports authentication by sending the password in cleartext
+connection.error.authenticationFailure=Authentication failure
+connection.error.notAuthorized=Not authorized (Did you enter the wrong password?)
+connection.error.failedToGetAResource=Failed to get a resource
+connection.error.failedMaxResourceLimit=This account is connected from too many places at the same time.
+connection.error.failedResourceNotValid=Resource is not valid.
+connection.error.XMPPNotSupported=This server does not support XMPP
+
+# LOCALIZATION NOTE (conversation.error.notDelivered):
+# This is displayed in a conversation as an error message when a message
+# the user has sent wasn't delivered.
+# %S is replaced by the text of the message that wasn't delivered.
+conversation.error.notDelivered=This message could not be delivered: %S
+# This is displayed in a conversation as an error message when joining a MUC
+# fails.
+# %S is the name of the MUC.
+conversation.error.joinFailed=Could not join: %S
+# This is displayed in a conversation as an error message when the user is
+# banned from a room.
+# %S is the name of the MUC room.
+conversation.error.joinForbidden=Couldn't join %S as you are banned from this room.
+conversation.error.joinFailedNotAuthorized=Registration required: You are not authorized to join this room.
+conversation.error.creationFailedNotAllowed=Access restricted: You are not allowed to create rooms.
+# This is displayed in a conversation as an error message when remote server
+# is not found.
+# %S is the name of MUC room.
+conversation.error.joinFailedRemoteServerNotFound=Could not join the room %S as the server the room is hosted on could not be reached.
+conversation.error.changeTopicFailedNotAuthorized=You are not authorized to set the topic of this room.
+# This is displayed in a conversation as an error message when the user sends
+# a message to a room that he is not in.
+# %1$S is the name of MUC room.
+# %2$S is the text of the message that wasn't delivered.
+conversation.error.sendFailedAsNotInRoom=Message could not be sent to %1$S as you are no longer in the room: %2$S
+# This is displayed in a conversation as an error message when the user sends
+# a message to a room that the recipient is not in.
+# %1$S is the jid of the recipient.
+# %2$S is the text of the message that wasn't delivered.
+conversation.error.sendFailedAsRecipientNotInRoom=Message could not be sent to %1$S as the recipient is no longer in the room: %2$S
+# These are displayed in a conversation as a system error message.
+conversation.error.remoteServerNotFound=Could not reach the recipient's server.
+conversation.error.unknownSendError=An unknown error occurred on sending this message.
+# %S is the name of the message recipient.
+conversation.error.sendServiceUnavailable=It is not possible to send messages to %S at this time.
+# %S is the nick of participant that is not in room.
+conversation.error.nickNotInRoom=%S is not in the room.
+conversation.error.banCommandAnonymousRoom=You can't ban participants from anonymous rooms. Try /kick instead.
+conversation.error.banKickCommandNotAllowed=You don't have the required privileges to remove this participant from the room.
+conversation.error.banKickCommandConflict=Sorry, you can't remove yourself from the room.
+conversation.error.changeNickFailedConflict=Could not change your nick to %S as this nick is already in use.
+conversation.error.changeNickFailedNotAcceptable=Could not change your nick to %S as nicks are locked down in this room.
+conversation.error.inviteFailedForbidden=You don't have the required privileges to invite users to this room.
+# %S is the jid of user that is invited.
+conversation.error.failedJIDNotFound=Could not reach %S.
+# %S is the jid that is invalid.
+conversation.error.invalidJID=%S is an invalid jid (Jabber identifiers must be of the form user@domain).
+conversation.error.commandFailedNotInRoom=You have to rejoin the room to be able to use this command.
+# %S is the name of the recipient.
+conversation.error.resourceNotAvailable=You must talk first as %S could be connected with more than one client.
+
+# LOCALIZATION NOTE (conversation.error.version.*):
+# %S is the name of the recipient.
+conversation.error.version.unknown=%S's client does not support querying for its software version.
+
+# LOCALIZATION NOTE (tooltip.*):
+# These are the titles of lines of information that will appear in
+# the tooltip showing details about a contact or conversation.
+# LOCALIZATION NOTE (tooltip.status):
+# %S will be replaced by the XMPP resource identifier
+tooltip.status=Status (%S)
+tooltip.statusNoResource=Status
+tooltip.subscription=Subscription
+tooltip.fullName=Full Name
+tooltip.nickname=Nickname
+tooltip.email=Email
+tooltip.birthday=Birthday
+tooltip.userName=Username
+tooltip.title=Title
+tooltip.organization=Organization
+tooltip.locality=Locality
+tooltip.country=Country
+tooltip.telephone=Telephone number
+
+# LOCALIZATION NOTE (chatRoomField.*):
+# These are the name of fields displayed in the 'Join Chat' dialog
+# for XMPP accounts.
+# The _ character won't be displayed; it indicates the next
+# character of the string should be used as the access key for this
+# field.
+chatRoomField.room=_Room
+chatRoomField.server=_Server
+chatRoomField.nick=_Nick
+chatRoomField.password=_Password
+
+# LOCALIZATION NOTE (conversation.muc.*):
+# These are displayed as a system message when a chatroom invitation is
+# received.
+# %1$S is the inviter.
+# %2$S is the room.
+# %3$S is the reason which is a message provided by the person sending the
+# invitation.
+conversation.muc.invitationWithReason2=%1$S has invited you to join %2$S: %3$S
+# %3$S is the password of the room.
+# %4$S is the reason which is a message provided by the person sending the
+# invitation.
+conversation.muc.invitationWithReason2.password=%1$S has invited you to join %2$S with password %3$S: %4$S
+conversation.muc.invitationWithoutReason=%1$S has invited you to join %2$S
+# %3$S is the password of the room.
+conversation.muc.invitationWithoutReason.password=%1$S has invited you to join %2$S with password %3$S
+
+# LOCALIZATION NOTE (conversation.muc.join):
+# This is displayed as a system message when a participant joins room.
+# %S is the nick of the participant.
+conversation.message.join=%S entered the room.
+
+# LOCALIZATION NOTE (conversation.muc.rejoined):
+# This is displayed as a system message when a participant rejoins room after
+# parting it.
+conversation.message.rejoined=You have rejoined the room.
+
+# LOCALIZATION NOTE (conversation.message.parted.*):
+# These are displayed as a system message when a participant parts a room.
+# %S is the part message supplied by the user.
+conversation.message.parted.you=You have left the room.
+conversation.message.parted.you.reason=You have left the room: %S
+# %1$S is the participant that is leaving.
+# %2$S is the part message supplied by the participant.
+conversation.message.parted=%1$S has left the room.
+conversation.message.parted.reason=%1$S has left the room: %2$S
+
+# LOCALIZATION NOTE (conversation.message.invitationDeclined*):
+# %1$S is the invitee that declined the invitation.
+# %2$S is the decline message supplied by the invitee.
+conversation.message.invitationDeclined=%1$S has declined your invitation.
+conversation.message.invitationDeclined.reason=%1$S has declined your invitation: %2$S
+
+# LOCALIZATION NOTE (conversation.message.banned.*):
+# These are displayed as a system message when a participant is banned from
+# a room.
+# %1$S is the participant that is banned.
+# %2$S is the reason.
+# %3$S is the person who is banning.
+conversation.message.banned=%1$S has been banned from the room.
+conversation.message.banned.reason=%1$S has been banned from the room: %2$S
+# %1$S is the person who is banning.
+# %2$S is the participant that is banned.
+# %3$S is the reason.
+conversation.message.banned.actor=%1$S has banned %2$S from the room.
+conversation.message.banned.actor.reason=%1$S has banned %2$S from the room: %3$S
+conversation.message.banned.you=You have been banned from the room.
+# %1$S is the reason.
+conversation.message.banned.you.reason=You have been banned from the room: %1$S
+# %1$S is the person who is banning.
+# %2$S is the reason.
+conversation.message.banned.you.actor=%1$S has banned you from the room.
+conversation.message.banned.you.actor.reason=%1$S has banned you from the room: %2$S
+
+# LOCALIZATION NOTE (conversation.message.kicked.*):
+# These are displayed as a system message when a participant is kicked from
+# a room.
+# %1$S is the participant that is kicked.
+# %2$S is the reason.
+conversation.message.kicked=%1$S has been kicked from the room.
+conversation.message.kicked.reason=%1$S has been kicked from the room: %2$S
+# %1$S is the person who is kicking.
+# %2$S is the participant that is kicked.
+# %3$S is the reason.
+conversation.message.kicked.actor=%1$S has kicked %2$S from the room.
+conversation.message.kicked.actor.reason=%1$S has kicked %2$S from the room: %3$S
+conversation.message.kicked.you=You have been kicked from the room.
+# %1$S is the reason.
+conversation.message.kicked.you.reason=You have been kicked from the room: %1$S
+# %1$S is the person who is kicking.
+# %2$S is the reason.
+conversation.message.kicked.you.actor=%1$S has kicked you from the room.
+conversation.message.kicked.you.actor.reason=%1$S has kicked you from the room: %2$S
+
+# LOCALIZATION NOTE (conversation.message.removedNonMember.*):
+# These are displayed as a system message when a participant is removed from
+# a room because the room has been changed to members-only.
+# %1$S is the participant that is removed.
+# %2$S is the person who changed the room configuration.
+conversation.message.removedNonMember=%1$S has been removed from the room because its configuration was changed to members-only.
+conversation.message.removedNonMember.actor=%1$S has been removed from the room because %2$S has changed it to members-only.
+conversation.message.removedNonMember.you=You have been removed from the room because its configuration has been changed to members-only.
+# %1$S is the person who changed the room configuration.
+conversation.message.removedNonMember.you.actor=You have been removed from the room because %1$S has changed it to members-only.
+
+# LOCALIZATION NOTE (conversation.message.MUCShutdown):
+# These are displayed as a system message when a participant is removed from
+# a room because of a system shutdown.
+conversation.message.mucShutdown=You have been removed from the room because of a system shutdown.
+
+# LOCALIZATION NOTE (conversation.message.version*):
+# %1$S is the name of the user whose version was requested.
+# %2$S is the client name response from the client.
+# %3$S is the client version response from the client.
+# %4$S is the operating system(OS) response from the client.
+conversation.message.version=%1$S is using "%2$S %3$S".
+conversation.message.versionWithOS=%1$S is using "%2$S %3$S" on %4$S.
+
+# LOCALIZATION NOTE (options.*):
+# These are the protocol specific options shown in the account manager and
+# account wizard windows.
+options.resource=Resource
+options.priority=Priority
+options.connectionSecurity=Connection security
+options.connectionSecurity.requireEncryption=Require encryption
+options.connectionSecurity.opportunisticTLS=Use encryption if available
+options.connectionSecurity.allowUnencryptedAuth=Allow sending the password unencrypted
+options.connectServer=Server
+options.connectPort=Port
+options.domain=Domain
+
+# LOCALIZATION NOTE (*.protocolName)
+# This name is used whenever the name of the protocol is shown.
+gtalk.protocolName=Google Talk
+odnoklassniki.protocolName=Odnoklassniki
+
+# LOCALIZATION NOTE (gtalk.disabled):
+# Google Talk was disabled on June 16, 2022. The message below is a localized
+# error message to be displayed to users with Google Talk accounts.
+gtalk.disabled=Google Talk is no longer supported due to Google disabling their XMPP gateway.
+
+# LOCALIZATION NOTE (odnoklassniki.usernameHint):
+# This is displayed inside the accountUsernameInfoWithDescription
+# string defined in imAccounts.properties when the user is
+# configuring a Odnoklassniki account.
+odnoklassniki.usernameHint=Profile ID
+
+# LOCALZIATION NOTE (command.*):
+# These are the help messages for each command.
+command.join3=%S [&lt;room&gt;[@&lt;server&gt;][/&lt;nick&gt;]] [&lt;password&gt;]: Join a room, optionally providing a different server, or nickname, or the room password.
+command.part2=%S [&lt;message&gt;]: Leave the current room with an optional message.
+command.topic=%S [&lt;new topic&gt;]: Set this room's topic.
+command.ban=%S &lt;nick&gt;[&lt;message&gt;]: Ban someone from the room. You must be a room administrator to do this.
+command.kick=%S &lt;nick&gt;[&lt;message&gt;]: Remove someone from the room. You must be a room moderator to do this.
+command.invite=%S &lt;jid&gt;[&lt;message&gt;]: Invite a user to join the current room with an optional message.
+command.inviteto=%S &lt;room jid&gt;[&lt;password&gt;]: Invite your conversation partner to join a room, together with its password if required.
+command.me=%S &lt;action to perform&gt;: Perform an action.
+command.nick=%S &lt;new nickname&gt;: Change your nickname.
+command.msg=%S &lt;nick&gt; &lt;message&gt;: Send a private message to a participant in the room.
+command.version=%S: Request information about the client your conversation partner is using.
diff --git a/comm/chat/locales/en-US/yahoo.properties b/comm/chat/locales/en-US/yahoo.properties
new file mode 100644
index 0000000000..89ee0093c1
--- /dev/null
+++ b/comm/chat/locales/en-US/yahoo.properties
@@ -0,0 +1,5 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+yahoo.disabled=Yahoo Messenger is no longer supported due to Yahoo disabling their legacy protocol.
diff --git a/comm/chat/locales/jar.mn b/comm/chat/locales/jar.mn
new file mode 100644
index 0000000000..20d8c3c055
--- /dev/null
+++ b/comm/chat/locales/jar.mn
@@ -0,0 +1,24 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[localization] @AB_CD@.jar:
+ chat (%**/*.ftl)
+
+@AB_CD@.jar:
+% locale chat @AB_CD@ %locale/@AB_CD@/chat/
+ locale/@AB_CD@/chat/accounts.dtd (%accounts.dtd)
+ locale/@AB_CD@/chat/accounts.properties (%accounts.properties)
+ locale/@AB_CD@/chat/imtooltip.properties (%imtooltip.properties)
+ locale/@AB_CD@/chat/commands.properties (%commands.properties)
+ locale/@AB_CD@/chat/contacts.properties (%contacts.properties)
+ locale/@AB_CD@/chat/conversations.properties (%conversations.properties)
+ locale/@AB_CD@/chat/facebook.properties (%facebook.properties)
+ locale/@AB_CD@/chat/irc.properties (%irc.properties)
+ locale/@AB_CD@/chat/logger.properties (%logger.properties)
+ locale/@AB_CD@/chat/matrix.properties (%matrix.properties)
+ locale/@AB_CD@/chat/status.properties (%status.properties)
+ locale/@AB_CD@/chat/twitter.properties (%twitter.properties)
+ locale/@AB_CD@/chat/xmpp.properties (%xmpp.properties)
+ locale/@AB_CD@/chat/yahoo.properties (%yahoo.properties)
diff --git a/comm/chat/locales/moz.build b/comm/chat/locales/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/chat/locales/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/chat/modules/CLib.sys.mjs b/comm/chat/modules/CLib.sys.mjs
new file mode 100644
index 0000000000..35226b565b
--- /dev/null
+++ b/comm/chat/modules/CLib.sys.mjs
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ctypes } from "resource://gre/modules/ctypes.sys.mjs";
+
+var OS = Services.appinfo.OS.toLowerCase();
+
+// type defs
+
+var FILE = ctypes.StructType("FILE");
+var fname_t = ctypes.char.ptr;
+var wchar_t = ctypes.char16_t;
+
+// Set the abi and path to CLib based on the OS.
+var libcAbi, libcPath;
+var strdup = "strdup";
+var fopen = "fopen";
+
+switch (OS) {
+ case "win32":
+ case "winnt":
+ libcAbi = ctypes.winapi_abi;
+ libcPath = ctypes.libraryName("msvcrt");
+ strdup = "_strdup";
+ fopen = "_wfopen";
+ fname_t = wchar_t.ptr;
+ break;
+ case "darwin":
+ case "dragonfly":
+ case "netbsd":
+ case "openbsd":
+ libcAbi = ctypes.default_abi;
+ libcPath = ctypes.libraryName("c");
+ break;
+ case "freebsd":
+ libcAbi = ctypes.default_abi;
+ libcPath = "libc.so.7";
+ break;
+ case "linux":
+ libcAbi = ctypes.default_abi;
+ libcPath = "libc.so.6";
+ break;
+ default:
+ throw new Error("Unknown OS");
+}
+
+var libc = ctypes.open(libcPath);
+
+export var CLib = {
+ FILE,
+ memcmp: libc.declare(
+ "memcmp",
+ libcAbi,
+ ctypes.int,
+ ctypes.void_t.ptr,
+ ctypes.void_t.ptr,
+ ctypes.size_t
+ ),
+ free: libc.declare("free", libcAbi, ctypes.void_t, ctypes.void_t.ptr),
+ strdup: libc.declare(strdup, libcAbi, ctypes.char.ptr, ctypes.char.ptr),
+ fclose: libc.declare("fclose", libcAbi, ctypes.int, FILE.ptr),
+ fopen: libc.declare(fopen, libcAbi, FILE.ptr, fname_t, fname_t),
+};
diff --git a/comm/chat/modules/IMServices.sys.mjs b/comm/chat/modules/IMServices.sys.mjs
new file mode 100644
index 0000000000..eb6036b608
--- /dev/null
+++ b/comm/chat/modules/IMServices.sys.mjs
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export const IMServices = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "accounts",
+ "@mozilla.org/chat/accounts-service;1",
+ "imIAccountsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "core",
+ "@mozilla.org/chat/core-service;1",
+ "imICoreService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "cmd",
+ "@mozilla.org/chat/commands-service;1",
+ "imICommandsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "contacts",
+ "@mozilla.org/chat/contacts-service;1",
+ "imIContactsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "conversations",
+ "@mozilla.org/chat/conversations-service;1",
+ "imIConversationsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "tags",
+ "@mozilla.org/chat/tags-service;1",
+ "imITagsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ IMServices,
+ "logs",
+ "@mozilla.org/chat/logger;1",
+ "imILogger"
+);
diff --git a/comm/chat/modules/InteractiveBrowser.sys.mjs b/comm/chat/modules/InteractiveBrowser.sys.mjs
new file mode 100644
index 0000000000..700bea8a61
--- /dev/null
+++ b/comm/chat/modules/InteractiveBrowser.sys.mjs
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class CancelledError extends Error {
+ constructor() {
+ super("Interactive browser request was cancelled");
+ }
+}
+
+export var InteractiveBrowser = {
+ /**
+ * URL to redirect to for completion of the redirect.
+ *
+ * @type {string}
+ */
+ COMPLETION_URL: "https://localhost",
+
+ /**
+ * Open an interactive browser prompt that should be redirected to the completion URL.
+ *
+ * @param {string} url - URL to start the interaction from.
+ * @param {string} promptText - Prompt for the user for context to the interaction.
+ * @returns {Promise<object>} Resolves when the redirect succeeds, else rejects.
+ */
+ waitForRedirect(url, promptText) {
+ return this._browserRequest(url).then(({ window, webProgress, signal }) => {
+ window.document.title = promptText;
+ return this._listenForRedirect({
+ window,
+ webProgress,
+ signal,
+ });
+ });
+ },
+
+ /**
+ * Open a browser window to request an interaction from the user.
+ *
+ * @param {string} url - URL to load in the browser window
+ * @returns {Promise<object>} If the url is loaded, resolves with an object
+ * containing the |window|, |webRequest| and a |signal|. The |signal| is an
+ * AbortSignal that gets triggered, when the "request is cancelled", i.e. the
+ * window is closed.
+ */
+ _browserRequest(url) {
+ return new Promise((resolve, reject) => {
+ let browserRequest = {
+ promptText: "",
+ iconURI: "",
+ url,
+ _active: true,
+ abortController: new AbortController(),
+ cancelled() {
+ if (!this._active) {
+ return;
+ }
+ reject(new CancelledError());
+ this.abortController.abort();
+ this._active = false;
+ },
+ loaded(window, webProgress) {
+ if (!this._active) {
+ return;
+ }
+ resolve({ window, webProgress, signal: this.abortController.signal });
+ },
+ };
+ Services.obs.notifyObservers(browserRequest, "browser-request");
+ });
+ },
+
+ /**
+ * Listen for a browser window to redirect to the specified URL.
+ *
+ * @param {Window} param0.window - Window to listen in.
+ * @param {nsIWebProgress} param0.webProgress - Web progress instance.
+ * @param {AbortSignal} param0.signal - Abort signal indicating that this should no longer listen for redirects.
+ * @returns {Promise<string>} Resolves with the resulting redirect URL.
+ */
+ _listenForRedirect({ window, webProgress, signal }) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsISupportsWeakReference,
+ ]),
+ _abortListener: () => {
+ listener._cleanUp();
+ reject(new CancelledError());
+ },
+ _cleanUp() {
+ signal.removeEventListener("abort", listener._abortListener);
+ webProgress.removeProgressListener(this);
+ window.close();
+ },
+ _checkForRedirect(currentUrl) {
+ if (!currentUrl.startsWith(InteractiveBrowser.COMPLETION_URL)) {
+ return;
+ }
+ resolve(currentUrl);
+
+ this._cleanUp();
+ },
+ onStateChange(aWebProgress, request, stateFlags, aStatus) {
+ const wpl = Ci.nsIWebProgressListener;
+ if (stateFlags & (wpl.STATE_START | wpl.STATE_IS_NETWORK)) {
+ try {
+ this._checkForRedirect(request.name);
+ } catch (error) {
+ // Ignore |name| not implemented exception
+ if (error.result !== Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ throw error;
+ }
+ }
+ }
+ },
+ onLocationChange(webProgress, request, location) {
+ this._checkForRedirect(location.spec);
+ },
+ onProgressChange() {},
+ onStatusChange() {},
+ onSecurityChange() {},
+ };
+
+ if (signal.aborted) {
+ reject(new CancelledError());
+ return;
+ }
+ signal.addEventListener("abort", listener._abortListener);
+ webProgress.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL);
+ const browser = window.document.getElementById("requestFrame");
+ if (browser.currentURI.spec) {
+ listener._checkForRedirect(browser.currentURI.spec);
+ }
+ });
+ },
+};
diff --git a/comm/chat/modules/NormalizedMap.sys.mjs b/comm/chat/modules/NormalizedMap.sys.mjs
new file mode 100644
index 0000000000..863de6874f
--- /dev/null
+++ b/comm/chat/modules/NormalizedMap.sys.mjs
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * A Map that automatically normalizes keys before accessing the values.
+ *
+ * The constructor takes two parameters:
+ * aNormalize: A function which takes a string and returns the "normalized"
+ * version of it.
+ * aIterable: A iterable to prefill the map with, keys will be normalized.
+ *
+ * Returns a Map object that will automatically run aNormalize on any operations
+ * involving keys.
+ */
+export class NormalizedMap extends Map {
+ constructor(aNormalize, aIterable = []) {
+ if (typeof aNormalize != "function") {
+ throw new Error("NormalizedMap must have a normalize function!");
+ }
+ // Create the wrapped Map; use the provided iterable after normalizing the
+ // keys.
+ let entries = [...aIterable].map(([key, val]) => [aNormalize(key), val]);
+ super(entries);
+ // Note: In derived classes, super() must be called before using 'this'.
+ this._normalize = aNormalize;
+ }
+
+ // Dummy normalize function.
+ _normalize(aKey) {
+ return aKey;
+ }
+
+ // Anything that accepts a key as an input needs to be manually overridden.
+ delete(key) {
+ return super.delete(this._normalize(key));
+ }
+ get(key) {
+ return super.get(this._normalize(key));
+ }
+ has(key) {
+ return super.has(this._normalize(key));
+ }
+ set(key, val) {
+ super.set(this._normalize(key), val);
+ return this;
+ }
+}
diff --git a/comm/chat/modules/OTR.sys.mjs b/comm/chat/modules/OTR.sys.mjs
new file mode 100644
index 0000000000..33784c6bd0
--- /dev/null
+++ b/comm/chat/modules/OTR.sys.mjs
@@ -0,0 +1,1506 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { BasePromiseWorker } from "resource://gre/modules/PromiseWorker.sys.mjs";
+import { ctypes } from "resource://gre/modules/ctypes.sys.mjs";
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { CLib } from "resource:///modules/CLib.sys.mjs";
+import { OTRLibLoader } from "resource:///modules/OTRLib.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/otr/otr.ftl"], true)
+);
+
+function _str(id) {
+ return lazy.l10n.formatValueSync(id);
+}
+
+function _strArgs(id, args) {
+ return lazy.l10n.formatValueSync(id, args);
+}
+
+// some helpers
+
+function setInterval(fn, delay) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(fn, delay, Ci.nsITimer.TYPE_REPEATING_SLACK);
+ return timer;
+}
+
+function clearInterval(timer) {
+ timer.cancel();
+}
+
+// See: https://developer.mozilla.org/en-US/docs/Mozilla/js-ctypes/Using_js-ctypes/Working_with_data#Determining_if_two_pointers_are_equal
+function comparePointers(p, q) {
+ p = ctypes.cast(p, ctypes.uintptr_t).value.toString();
+ q = ctypes.cast(q, ctypes.uintptr_t).value.toString();
+ return p === q;
+}
+
+function trustFingerprint(fingerprint) {
+ return (
+ !fingerprint.isNull() &&
+ !fingerprint.contents.trust.isNull() &&
+ fingerprint.contents.trust.readString().length > 0
+ );
+}
+
+// Report whether you think the given user is online. Return 1 if you think
+// they are, 0 if you think they aren't, -1 if you're not sure.
+function isOnline(conv) {
+ let ret = -1;
+ if (conv.buddy) {
+ ret = conv.buddy.online ? 1 : 0;
+ }
+ return ret;
+}
+
+/**
+ *
+ * @param {string} filename - File in the profile.
+ * @returns {string} Full path to given file in the profile directory.
+ */
+function profilePath(filename) {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ filename
+ );
+}
+
+// OTRLib context wrapper
+
+function Context(context) {
+ this._context = context;
+}
+
+Context.prototype = {
+ constructor: Context,
+ get username() {
+ return this._context.contents.username.readString();
+ },
+ get account() {
+ return this._context.contents.accountname.readString();
+ },
+ get protocol() {
+ return this._context.contents.protocol.readString();
+ },
+ get msgstate() {
+ return this._context.contents.msgstate;
+ },
+ get fingerprint() {
+ return this._context.contents.active_fingerprint;
+ },
+ get trust() {
+ return trustFingerprint(this.fingerprint);
+ },
+};
+
+// otr module
+
+var OTRLib;
+
+export var OTR = {
+ hasRan: false,
+ libLoaded: false,
+ once() {
+ this.hasRan = true;
+ try {
+ OTRLib = OTRLibLoader.init();
+ if (!OTRLib) {
+ return;
+ }
+ if (OTRLib && OTRLib.init()) {
+ this.initUiOps();
+ OTR.libLoaded = true;
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ },
+
+ privateKeyPath: profilePath("otr.private_key"),
+ fingerprintsPath: profilePath("otr.fingerprints"),
+ instanceTagsPath: profilePath("otr.instance_tags"),
+
+ init(opts) {
+ opts = opts || {};
+
+ if (!this.hasRan) {
+ this.once();
+ }
+
+ if (!OTR.libLoaded) {
+ return;
+ }
+
+ this.userstate = OTRLib.otrl_userstate_create();
+
+ // A map of UIConvs, keyed on the target.id
+ this._convos = new Map();
+ this._observers = [];
+ this._buffer = [];
+ this._pendingSystemMessages = [];
+ this._poll_timer = null;
+
+ // Async sending may fail in the transport protocols, so periodically
+ // drop old messages from the internal buffer. Should be rare.
+ const pluck_time = 1 * 60 * 1000;
+ this._pluck_timer = setInterval(() => {
+ let buf = this._buffer;
+ let i = 0;
+ while (i < buf.length) {
+ if (Date.now() - buf[i].time > pluck_time) {
+ this.log("dropping an old message: " + buf[i].display);
+ buf.splice(i, 1);
+ } else {
+ i += 1;
+ }
+ }
+ this._pendingSystemMessages = this._pendingSystemMessages.filter(
+ info => info.time + pluck_time < Date.now()
+ );
+ }, pluck_time);
+ },
+
+ close() {
+ if (this._poll_timer) {
+ clearInterval(this._poll_timer);
+ this._poll_timer = null;
+ }
+ if (this._pluck_timer) {
+ clearInterval(this._pluck_timer);
+ this._pluck_timer = null;
+ }
+ this._buffer = null;
+ },
+
+ log(msg) {
+ this.notifyObservers(msg, "otr:log");
+ },
+
+ // load stored files from my profile
+ loadFiles() {
+ return Promise.all([
+ IOUtils.exists(this.privateKeyPath).then(exists => {
+ if (
+ exists &&
+ OTRLib.otrl_privkey_read(this.userstate, this.privateKeyPath)
+ ) {
+ throw new Error("Failed to read private keys.");
+ }
+ }),
+ IOUtils.exists(this.fingerprintsPath).then(exists => {
+ if (
+ exists &&
+ OTRLib.otrl_privkey_read_fingerprints(
+ this.userstate,
+ this.fingerprintsPath,
+ null,
+ null
+ )
+ ) {
+ throw new Error("Failed to read fingerprints.");
+ }
+ }),
+ IOUtils.exists(this.instanceTagsPath).then(exists => {
+ if (
+ exists &&
+ OTRLib.otrl_instag_read(this.userstate, this.instanceTagsPath)
+ ) {
+ throw new Error("Failed to read instance tags.");
+ }
+ }),
+ ]);
+ },
+
+ // generate a private key in a worker
+ generatePrivateKey(account, protocol) {
+ let newkey = new ctypes.void_t.ptr();
+ let err = OTRLib.otrl_privkey_generate_start(
+ OTR.userstate,
+ account,
+ protocol,
+ newkey.address()
+ );
+ if (err || newkey.isNull()) {
+ return Promise.reject("otrl_privkey_generate_start (" + err + ")");
+ }
+
+ let keyPtrSrc = newkey.toSource();
+ let re = new RegExp(
+ '^ctypes\\.voidptr_t\\(ctypes\\.UInt64\\("0x([0-9a-fA-F]+)"\\)\\)$'
+ );
+ let address;
+ let match = re.exec(keyPtrSrc);
+ if (match) {
+ address = match[1];
+ }
+
+ if (!address) {
+ OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey);
+ throw new Error(
+ "generatePrivateKey failed to parse ptr.toSource(): " + keyPtrSrc
+ );
+ }
+
+ let worker = new BasePromiseWorker("chrome://chat/content/otrWorker.js");
+ return worker
+ .post("generateKey", [OTRLib.path, OTRLib.otrl_version, address])
+ .then(function () {
+ let err = OTRLib.otrl_privkey_generate_finish(
+ OTR.userstate,
+ newkey,
+ OTR.privateKeyPath
+ );
+ if (err) {
+ throw new Error("otrl_privkey_generate_calculate (" + err + ")");
+ }
+ })
+ .catch(function (err) {
+ if (!newkey.isNull()) {
+ OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey);
+ }
+ throw err;
+ });
+ },
+
+ generatePrivateKeySync(account, protocol) {
+ let newkey = new ctypes.void_t.ptr();
+ let err = OTRLib.otrl_privkey_generate_start(
+ OTR.userstate,
+ account,
+ protocol,
+ newkey.address()
+ );
+ if (err || newkey.isNull()) {
+ return "otrl_privkey_generate_start (" + err + ")";
+ }
+
+ err = OTRLib.otrl_privkey_generate_calculate(newkey);
+ if (!err) {
+ err = OTRLib.otrl_privkey_generate_finish(
+ OTR.userstate,
+ newkey,
+ OTR.privateKeyPath
+ );
+ }
+ if (err && !newkey.isNull()) {
+ OTRLib.otrl_privkey_generate_cancelled(OTR.userstate, newkey);
+ }
+
+ if (err) {
+ return "otrl_privkey_generate_calculate (" + err + ")";
+ }
+ return null;
+ },
+
+ // write fingerprints to file synchronously
+ writeFingerprints() {
+ if (
+ OTRLib.otrl_privkey_write_fingerprints(
+ this.userstate,
+ this.fingerprintsPath
+ )
+ ) {
+ throw new Error("Failed to write fingerprints.");
+ }
+ },
+
+ // generate instance tag synchronously
+ generateInstanceTag(account, protocol) {
+ if (
+ OTRLib.otrl_instag_generate(
+ this.userstate,
+ this.instanceTagsPath,
+ account,
+ protocol
+ )
+ ) {
+ throw new Error("Failed to generate instance tag.");
+ }
+ },
+
+ // get my fingerprint
+ privateKeyFingerprint(account, protocol) {
+ let fingerprint = OTRLib.otrl_privkey_fingerprint(
+ this.userstate,
+ new OTRLib.fingerprint_t(),
+ account,
+ protocol
+ );
+ return fingerprint.isNull() ? null : fingerprint.readString();
+ },
+
+ // return a human readable string for a fingerprint
+ hashToHuman(fingerprint) {
+ let hash;
+ try {
+ hash = fingerprint.contents.fingerprint;
+ } catch (e) {}
+ if (!hash || hash.isNull()) {
+ throw new Error("No fingerprint found.");
+ }
+ let human = new OTRLib.fingerprint_t();
+ OTRLib.otrl_privkey_hash_to_human(human, hash);
+ return human.readString();
+ },
+
+ base64encode(data, dataLen) {
+ // CData objects are initialized with zeroes. The plus one gives us
+ // our null byte so that readString below is safe.
+ let buf = ctypes.char.array(Math.floor((dataLen + 2) / 3) * 4 + 1)();
+ OTRLib.otrl_base64_encode(buf, data, dataLen); // ignore returned size
+ return buf.readString(); // str
+ },
+
+ base64decode(str) {
+ let size = str.length;
+ // +1 here so that we're safe in calling readString on data in the tests.
+ let data = ctypes.unsigned_char.array(Math.floor((size + 3) / 4) * 3 + 1)();
+ OTRLib.otrl_base64_decode(data, str, size); // ignore returned len
+ // We aren't returning the dataLen since we know the hash length in our
+ // one use case so far.
+ return data;
+ },
+
+ // Fetch list of known fingerprints, either for the given account,
+ // or for all accounts, if parameter is null.
+ knownFingerprints(forAccount) {
+ let fps = [];
+ for (
+ let context = this.userstate.contents.context_root;
+ !context.isNull();
+ context = context.contents.next
+ ) {
+ // skip child contexts
+ if (!comparePointers(context.contents.m_context, context)) {
+ continue;
+ }
+ let wContext = new Context(context);
+
+ if (forAccount) {
+ if (
+ forAccount.normalizedName != wContext.account ||
+ forAccount.protocol.normalizedName != wContext.protocol
+ ) {
+ continue;
+ }
+ }
+
+ for (
+ let fingerprint = context.contents.fingerprint_root.next;
+ !fingerprint.isNull();
+ fingerprint = fingerprint.contents.next
+ ) {
+ let trust = trustFingerprint(fingerprint);
+ fps.push({
+ fpointer: fingerprint.contents.address(),
+ fingerprint: OTR.hashToHuman(fingerprint),
+ screenname: wContext.username,
+ trust,
+ purge: false,
+ });
+ }
+ }
+ return fps;
+ },
+
+ /**
+ * Returns true, if all requested fps were removed.
+ * Returns false, if at least one fps couldn't get removed,
+ * because it's currently actively used.
+ */
+ forgetFingerprints(fps) {
+ let result = true;
+ let write = false;
+ fps.forEach(function (obj, i) {
+ if (!obj.purge) {
+ return;
+ }
+ obj.purge = false; // reset early
+ let fingerprint = obj.fpointer;
+ if (fingerprint.isNull()) {
+ return;
+ }
+ // don't remove if fp is active and we're in an encrypted state
+ let context = fingerprint.contents.context.contents.m_context;
+ for (
+ let context_itr = context;
+ !context_itr.isNull() &&
+ comparePointers(context_itr.contents.m_context, context);
+ context_itr = context_itr.contents.next
+ ) {
+ if (
+ context_itr.contents.msgstate ===
+ OTRLib.messageState.OTRL_MSGSTATE_ENCRYPTED &&
+ comparePointers(context_itr.contents.active_fingerprint, fingerprint)
+ ) {
+ result = false;
+ return;
+ }
+ }
+ write = true;
+ OTRLib.otrl_context_forget_fingerprint(fingerprint, 1);
+ fps[i] = null; // null out removed fps
+ });
+ if (write) {
+ OTR.writeFingerprints();
+ }
+ return result;
+ },
+
+ addFingerprint(context, hex) {
+ let fingerprint = new OTRLib.hash_t();
+ if (hex.length != 40) {
+ throw new Error("Invalid fingerprint value.");
+ }
+ let bytes = hex.match(/.{1,2}/g);
+ for (let i = 0; i < 20; i++) {
+ fingerprint[i] = parseInt(bytes[i], 16);
+ }
+ return OTRLib.otrl_context_find_fingerprint(
+ context._context,
+ fingerprint,
+ 1,
+ null
+ );
+ },
+
+ getFingerprintsForRecipient(account, protocol, recipient) {
+ let fingers = OTR.knownFingerprints();
+ return fingers.filter(function (fg) {
+ return (
+ fg.account == account &&
+ fg.protocol == protocol &&
+ fg.screenname == recipient
+ );
+ });
+ },
+
+ isFingerprintTrusted(fingerprint) {
+ return !!OTRLib.otrl_context_is_fingerprint_trusted(fingerprint);
+ },
+
+ // update trust in fingerprint
+ setTrust(fingerprint, trust, context) {
+ // ignore if no change in trust
+ if (context && trust === context.trust) {
+ return;
+ }
+ OTRLib.otrl_context_set_trust(fingerprint, trust ? "verified" : "");
+ this.writeFingerprints();
+ if (context) {
+ this.notifyTrust(context);
+ }
+ },
+
+ notifyTrust(context) {
+ this.notifyObservers(context, "otr:msg-state");
+ this.notifyObservers(context, "otr:trust-state");
+ },
+
+ authUpdate(context, progress, success) {
+ this.notifyObservers(
+ {
+ context,
+ progress,
+ success,
+ },
+ "otr:auth-update"
+ );
+ },
+
+ // expose message states
+ getMessageState() {
+ return OTRLib.messageState;
+ },
+
+ // get context from conv
+ getContext(conv) {
+ let context = OTRLib.otrl_context_find(
+ this.userstate,
+ conv.normalizedName,
+ conv.account.normalizedName,
+ // TODO: check why sometimes normalizedName is undefined, and if
+ // that's ok. Fallback wasn't necessary in the original code.
+ conv.account.protocol.normalizedName || "",
+ OTRLib.instag.OTRL_INSTAG_BEST,
+ 1,
+ null,
+ null,
+ null
+ );
+ return new Context(context);
+ },
+
+ getContextFromRecipient(account, protocol, recipient) {
+ let context = OTRLib.otrl_context_find(
+ this.userstate,
+ recipient,
+ account,
+ protocol,
+ OTRLib.instag.OTRL_INSTAG_BEST,
+ 1,
+ null,
+ null,
+ null
+ );
+ return new Context(context);
+ },
+
+ getUIConvFromContext(context) {
+ return this.getUIConvForRecipient(
+ context.account,
+ context.protocol,
+ context.username
+ );
+ },
+
+ getUIConvForRecipient(account, protocol, recipient) {
+ let uiConvs = this._convos.values();
+ let uiConv = uiConvs.next();
+ while (!uiConv.done) {
+ let conv = uiConv.value.target;
+ if (
+ conv.account.normalizedName === account &&
+ conv.account.protocol.normalizedName === protocol &&
+ conv.normalizedName === recipient
+ ) {
+ // console.log("=== getUIConvForRecipient found, account: " + account + " protocol: " + protocol + " recip: " + recipient);
+ return uiConv.value;
+ }
+ uiConv = uiConvs.next();
+ }
+ throw new Error("Couldn't find conversation.");
+ },
+
+ getUIConvFromConv(conv) {
+ // return this._convos.get(conv.id);
+ return IMServices.conversations.getUIConversation(conv);
+ },
+
+ disconnect(conv, remove) {
+ OTRLib.otrl_message_disconnect(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ conv.account.normalizedName,
+ conv.account.protocol.normalizedName,
+ conv.normalizedName,
+ OTRLib.instag.OTRL_INSTAG_BEST
+ );
+ if (remove) {
+ let uiConv = this.getUIConvFromConv(conv);
+ if (uiConv) {
+ this.removeConversation(uiConv);
+ }
+ } else {
+ this.notifyObservers(this.getContext(conv), "otr:disconnected");
+ }
+ },
+
+ getAccountPref(prefName, accountId, defaultVal) {
+ return Services.prefs.getBoolPref(
+ "messenger.account." + accountId + ".options." + prefName,
+ defaultVal
+ );
+ },
+
+ sendQueryMsg(conv) {
+ let req = this.getAccountPref(
+ "otrRequireEncryption",
+ conv.account.id,
+ Services.prefs.getBoolPref("chat.otr.default.requireEncryption")
+ );
+ let query = OTRLib.otrl_proto_default_query_msg(
+ conv.account.normalizedName,
+ req ? OTRLib.OTRL_POLICY_ALWAYS : OTRLib.OTRL_POLICY_OPPORTUNISTIC
+ );
+ if (query.isNull()) {
+ console.error(new Error("Sending query message failed."));
+ return;
+ }
+ // Use the default msg to format the version.
+ // We don't support v1 of the protocol so this should be fine.
+ let queryMsg = /^\?OTR.*?\?/.exec(query.readString())[0] + "\n";
+ // Avoid sending any numbers in the query message, because receiving
+ // software could misinterpret it as a protocol version.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1536108
+ let noNumbersName = conv.account.normalizedName.replace(/[0-9]/g, "#");
+ queryMsg += _strArgs("query-msg", { name: noNumbersName });
+ this.sendOTRSystemMessage(conv, queryMsg);
+ OTRLib.otrl_message_free(query);
+ },
+
+ _pendingSystemMessages: null,
+ /**
+ * Wrapper for system messages sent by OTR to ensure they are correctly
+ * handled through the OutgoingMessage event handlers.
+ *
+ * @param {prplIConversation} conv
+ * @param {string} message
+ */
+ sendOTRSystemMessage(conv, message) {
+ this._pendingSystemMessages.push({
+ message,
+ convId: conv.id,
+ time: Date.now(),
+ });
+ conv.sendMsg(message, false, false);
+ },
+
+ trustState: {
+ TRUST_NOT_PRIVATE: 0,
+ TRUST_UNVERIFIED: 1,
+ TRUST_PRIVATE: 2,
+ TRUST_FINISHED: 3,
+ },
+
+ // Check the attributes of the OTR context, and derive how that maps
+ // to one of the above trust states, which we'll show to the user.
+ // If we have an encrypted channel, it depends on the presence of a
+ // context.trust object, if we treat is as private or unverified.
+ trust(context) {
+ let level = this.trustState.TRUST_NOT_PRIVATE;
+ switch (context.msgstate) {
+ case OTRLib.messageState.OTRL_MSGSTATE_ENCRYPTED:
+ level = context.trust
+ ? this.trustState.TRUST_PRIVATE
+ : this.trustState.TRUST_UNVERIFIED;
+ break;
+ case OTRLib.messageState.OTRL_MSGSTATE_FINISHED:
+ level = this.trustState.TRUST_FINISHED;
+ break;
+ }
+ return level;
+ },
+
+ /** @param {Context} wContext - wrapped context. */
+ getAccountPrefBranch(wContext) {
+ let account = IMServices.accounts
+ .getAccounts()
+ .find(
+ acc =>
+ wContext.account == acc.normalizedName &&
+ wContext.protocol == acc.protocol.normalizedName
+ );
+ if (!account) {
+ return null;
+ }
+ return Services.prefs.getBranch(`messenger.account.${account.id}.`);
+ },
+
+ // uiOps callbacks
+
+ /**
+ * Return the OTR policy for the given context.
+ */
+ policy_cb(opdata, context) {
+ let wContext = new Context(context);
+ let pb = OTR.getAccountPrefBranch(wContext);
+ if (!pb) {
+ return new ctypes.unsigned_int(0);
+ }
+ try {
+ let conv = OTR.getUIConvFromContext(wContext);
+ // Ensure we never try to layer OTR on top of protocol native encryption.
+ if (
+ conv.encryptionState !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ return new ctypes.unsigned_int(0);
+ }
+ } catch (error) {
+ // No conversation found for the context, fall through to default logic.
+ }
+ let prefRequire = pb.getBoolPref(
+ "options.otrRequireEncryption",
+ Services.prefs.getBoolPref("chat.otr.default.requireEncryption")
+ );
+ return prefRequire
+ ? OTRLib.OTRL_POLICY_ALWAYS
+ : OTRLib.OTRL_POLICY_OPPORTUNISTIC;
+ },
+
+ /**
+ * Create a private key for the given accountname/protocol if desired.
+ */
+ create_privkey_cb(opdata, accountname, protocol) {
+ let args = {
+ account: accountname.readString(),
+ protocol: protocol.readString(),
+ };
+ this.notifyObservers(args, "otr:generate");
+ },
+
+ /**
+ * Report whether you think the given user is online. Return 1 if you
+ * think they are, 0 if you think they aren't, -1 if you're not sure.
+ */
+ is_logged_in_cb(opdata, accountname, protocol, recipient) {
+ let conv = this.getUIConvForRecipient(
+ accountname.readString(),
+ protocol.readString(),
+ recipient.readString()
+ ).target;
+ return isOnline(conv);
+ },
+
+ /**
+ * Send the given IM to the given recipient from the given
+ * accountname/protocol.
+ */
+ inject_message_cb(opdata, accountname, protocol, recipient, message) {
+ let aMsg = message.readString();
+ this.log("inject_message_cb (msglen:" + aMsg.length + "): " + aMsg);
+ this.sendOTRSystemMessage(
+ this.getUIConvForRecipient(
+ accountname.readString(),
+ protocol.readString(),
+ recipient.readString()
+ ).target,
+ aMsg
+ );
+ },
+
+ /**
+ * new fingerprint for the given user has been received.
+ */
+ new_fingerprint_cb(opdata, us, accountname, protocol, username, fingerprint) {
+ let context = OTRLib.otrl_context_find(
+ us,
+ username,
+ accountname,
+ protocol,
+ OTRLib.instag.OTRL_INSTAG_MASTER,
+ 1,
+ null,
+ null,
+ null
+ );
+
+ let seen = false;
+ let fp = context.contents.fingerprint_root.next;
+ while (!fp.isNull()) {
+ if (
+ CLib.memcmp(fingerprint, fp.contents.fingerprint, new ctypes.size_t(20))
+ ) {
+ seen = true;
+ break;
+ }
+ fp = fp.contents.next;
+ }
+
+ let wContext = new Context(context);
+ let defaultNudge = Services.prefs.getBoolPref(
+ "chat.otr.default.verifyNudge"
+ );
+ let prefNudge = defaultNudge;
+ let pb = OTR.getAccountPrefBranch(wContext);
+ if (pb) {
+ prefNudge = pb.getBoolPref("options.otrVerifyNudge", defaultNudge);
+ }
+
+ // Only nudge on new fingerprint, as opposed to always.
+ if (!prefNudge) {
+ this.notifyObservers(
+ wContext,
+ "otr:unverified",
+ seen ? "seen" : "unseen"
+ );
+ }
+ },
+
+ /**
+ * The list of known fingerprints has changed. Write them to disk.
+ */
+ write_fingerprint_cb(opdata) {
+ this.writeFingerprints();
+ },
+
+ /**
+ * A ConnContext has entered a secure state.
+ */
+ gone_secure_cb(opdata, context) {
+ let wContext = new Context(context);
+ let defaultNudge = Services.prefs.getBoolPref(
+ "chat.otr.default.verifyNudge"
+ );
+ let prefNudge = defaultNudge;
+ let pb = OTR.getAccountPrefBranch(wContext);
+ if (pb) {
+ prefNudge = pb.getBoolPref("options.otrVerifyNudge", defaultNudge);
+ }
+ let strid = wContext.trust
+ ? "context-gone-secure-private"
+ : "context-gone-secure-unverified";
+ this.notifyObservers(wContext, "otr:msg-state");
+ this.sendAlert(wContext, _strArgs(strid, { name: wContext.username }));
+ if (prefNudge && !wContext.trust) {
+ this.notifyObservers(wContext, "otr:unverified", "unseen");
+ }
+ },
+
+ /**
+ * A ConnContext has left a secure state.
+ */
+ gone_insecure_cb(opdata, context) {
+ // This isn't used. See: https://bugs.otr.im/lib/libotr/issues/48
+ },
+
+ /**
+ * We have completed an authentication, using the D-H keys we already
+ * knew.
+ *
+ * @param is_reply indicates whether we initiated the AKE.
+ */
+ still_secure_cb(opdata, context, is_reply) {
+ // Indicate the private conversation was refreshed.
+ if (!is_reply) {
+ context = new Context(context);
+ this.notifyObservers(context, "otr:msg-state");
+ this.sendAlert(
+ context,
+ _strArgs("context-still-secure", { name: context.username })
+ );
+ }
+ },
+
+ /**
+ * Find the maximum message size supported by this protocol.
+ */
+ max_message_size_cb(opdata, context) {
+ context = new Context(context);
+ // These values are, for the most part, from pidgin-otr's mms_table.
+ switch (context.protocol) {
+ case "irc":
+ case "prpl-irc":
+ return 417;
+ case "facebook":
+ case "gtalk":
+ case "odnoklassniki":
+ case "jabber":
+ case "xmpp":
+ return 65536;
+ case "prpl-yahoo":
+ return 799;
+ case "prpl-msn":
+ return 1409;
+ case "prpl-icq":
+ return 2346;
+ case "prpl-gg":
+ return 1999;
+ case "prpl-aim":
+ case "prpl-oscar":
+ return 2343;
+ case "prpl-novell":
+ return 1792;
+ default:
+ return 0;
+ }
+ },
+
+ /**
+ * We received a request from the buddy to use the current "extra"
+ * symmetric key.
+ */
+ received_symkey_cb(opdata, context, use, usedata, usedatalen, symkey) {
+ // Ignore until we have a use.
+ },
+
+ /**
+ * Return a string according to the error event.
+ */
+ otr_error_message_cb(opdata, context, err_code) {
+ context = new Context(context);
+ let msg;
+ switch (err_code) {
+ case OTRLib.errorCode.OTRL_ERRCODE_ENCRYPTION_ERROR:
+ msg = _str("error-enc");
+ break;
+ case OTRLib.errorCode.OTRL_ERRCODE_MSG_NOT_IN_PRIVATE:
+ msg = _strArgs("error-not-priv", context.username);
+ break;
+ case OTRLib.errorCode.OTRL_ERRCODE_MSG_UNREADABLE:
+ msg = _str("error-unreadable");
+ break;
+ case OTRLib.errorCode.OTRL_ERRCODE_MSG_MALFORMED:
+ msg = _str("error-malformed");
+ break;
+ default:
+ return null;
+ }
+ return CLib.strdup(msg);
+ },
+
+ /**
+ * Deallocate a string returned by otr_error_message_cb.
+ */
+ otr_error_message_free_cb(opdata, err_msg) {
+ if (!err_msg.isNull()) {
+ CLib.free(err_msg);
+ }
+ },
+
+ /**
+ * Return a string that will be prefixed to any resent message.
+ */
+ resent_msg_prefix_cb(opdata, context) {
+ return CLib.strdup(_str("resent"));
+ },
+
+ /**
+ * Deallocate a string returned by resent_msg_prefix.
+ */
+ resent_msg_prefix_free_cb(opdata, prefix) {
+ if (!prefix.isNull()) {
+ CLib.free(prefix);
+ }
+ },
+
+ /**
+ * Update the authentication UI with respect to SMP events.
+ */
+ handle_smp_event_cb(opdata, smp_event, context, progress_percent, question) {
+ context = new Context(context);
+ switch (smp_event) {
+ case OTRLib.smpEvent.OTRL_SMPEVENT_NONE:
+ break;
+ case OTRLib.smpEvent.OTRL_SMPEVENT_ASK_FOR_ANSWER:
+ case OTRLib.smpEvent.OTRL_SMPEVENT_ASK_FOR_SECRET:
+ this.notifyObservers(
+ {
+ context,
+ progress: progress_percent,
+ question: question.isNull() ? null : question.readString(),
+ },
+ "otr:auth-ask"
+ );
+ break;
+ case OTRLib.smpEvent.OTRL_SMPEVENT_CHEATED:
+ OTR.abortSMP(context);
+ /* falls through */
+ case OTRLib.smpEvent.OTRL_SMPEVENT_IN_PROGRESS:
+ case OTRLib.smpEvent.OTRL_SMPEVENT_SUCCESS:
+ case OTRLib.smpEvent.OTRL_SMPEVENT_FAILURE:
+ case OTRLib.smpEvent.OTRL_SMPEVENT_ABORT:
+ this.authUpdate(
+ context,
+ progress_percent,
+ smp_event === OTRLib.smpEvent.OTRL_SMPEVENT_SUCCESS
+ );
+ break;
+ case OTRLib.smpEvent.OTRL_SMPEVENT_ERROR:
+ OTR.abortSMP(context);
+ break;
+ default:
+ this.log("smp event: " + smp_event);
+ }
+ },
+
+ /**
+ * Handle and send the appropriate message(s) to the sender/recipient
+ * depending on the message events.
+ */
+ handle_msg_event_cb(opdata, msg_event, context, message, err) {
+ context = new Context(context);
+ switch (msg_event) {
+ case OTRLib.messageEvent.OTRL_MSGEVENT_NONE:
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_ENCRYPTION_REQUIRED:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-encryption-required-part1", {
+ name: context.username,
+ })
+ );
+ this.sendAlert(context, _str("msgevent-encryption-required-part2"));
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_ENCRYPTION_ERROR:
+ this.sendAlert(context, _str("msgevent-encryption-error"));
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_CONNECTION_ENDED:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-connection-ended", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_SETUP_ERROR:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-setup-error", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_MSG_REFLECTED:
+ this.sendAlert(context, _str("msgevent-msg-reflected"));
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_MSG_RESENT:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-msg-resent", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_NOT_IN_PRIVATE:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-rcvdmsg-not-private", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNREADABLE:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-rcvdmsg-unreadable", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_MALFORMED:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-rcvdmsg-malformed", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_LOG_HEARTBEAT_RCVD:
+ this.log(
+ _strArgs("msgevent-log-heartbeat-rcvd", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_LOG_HEARTBEAT_SENT:
+ this.log(
+ _strArgs("msgevent-log-heartbeat-sent", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_GENERAL_ERR:
+ this.sendAlert(context, _str("msgevent-rcvdmsg-general-err"));
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNENCRYPTED:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-rcvdmsg-unencrypted", {
+ name: context.username,
+ msg: message.isNull() ? "" : message.readString(),
+ })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_UNRECOGNIZED:
+ this.sendAlert(
+ context,
+ _strArgs("msgevent-rcvdmsg-unrecognized", { name: context.username })
+ );
+ break;
+ case OTRLib.messageEvent.OTRL_MSGEVENT_RCVDMSG_FOR_OTHER_INSTANCE:
+ this.log(
+ _strArgs("msgevent-rcvdmsg-for-other-instance", {
+ name: context.username,
+ })
+ );
+ break;
+ default:
+ this.log("msg event: " + msg_event);
+ }
+ },
+
+ /**
+ * Create an instance tag for the given accountname/protocol if
+ * desired.
+ */
+ create_instag_cb(opdata, accountname, protocol) {
+ this.generateInstanceTag(accountname.readString(), protocol.readString());
+ },
+
+ /**
+ * When timer_control is called, turn off any existing periodic timer.
+ * Additionally, if interval > 0, set a new periodic timer to go off
+ * every interval seconds.
+ */
+ timer_control_cb(opdata, interval) {
+ if (this._poll_timer) {
+ clearInterval(this._poll_timer);
+ this._poll_timer = null;
+ }
+ if (interval > 0) {
+ this._poll_timer = setInterval(() => {
+ OTRLib.otrl_message_poll(this.userstate, this.uiOps.address(), null);
+ }, interval * 1000);
+ }
+ },
+
+ // end of uiOps
+
+ initUiOps() {
+ this.uiOps = new OTRLib.OtrlMessageAppOps();
+
+ let methods = [
+ "policy",
+ "create_privkey",
+ "is_logged_in",
+ "inject_message",
+ "update_context_list", // not implemented
+ "new_fingerprint",
+ "write_fingerprint",
+ "gone_secure",
+ "gone_insecure",
+ "still_secure",
+ "max_message_size",
+ "account_name", // not implemented
+ "account_name_free", // not implemented
+ "received_symkey",
+ "otr_error_message",
+ "otr_error_message_free",
+ "resent_msg_prefix",
+ "resent_msg_prefix_free",
+ "handle_smp_event",
+ "handle_msg_event",
+ "create_instag",
+ "convert_msg", // not implemented
+ "convert_free", // not implemented
+ "timer_control",
+ ];
+
+ for (let i = 0; i < methods.length; i++) {
+ let m = methods[i];
+ if (!this[m + "_cb"]) {
+ this.uiOps[m] = null;
+ continue;
+ }
+ // keep a pointer to this in memory to avoid crashing
+ this[m + "_cb"] = OTRLib[m + "_cb_t"](this[m + "_cb"].bind(this));
+ this.uiOps[m] = this[m + "_cb"];
+ }
+ },
+
+ sendAlert(context, msg) {
+ this.getUIConvFromContext(context).systemMessage(msg, false, true);
+ },
+
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "sending-message":
+ this.onSend(aObject);
+ break;
+ case "received-message":
+ this.onReceive(aObject);
+ break;
+ case "new-ui-conversation":
+ this.addConversation(aObject);
+ break;
+ case "conversation-update-type":
+ if (this._convos.has(aObject.target.id)) {
+ this._convos.get(aObject.target.id).removeObserver(this);
+ }
+ this.addConversation(aObject);
+ break;
+ case "update-conv-encryption": {
+ // Disable OTR encryption when the chat protocol initiates encryption
+ // for the conversation.
+ const context = this.getContext(aObject);
+ const trust = this.trust(context);
+ if (
+ trust === this.trustState.TRUST_NOT_PRIVATE ||
+ trust === this.trustState.TRUST_PRIVATE
+ ) {
+ this.disconnect(aObject, false);
+ }
+ break;
+ }
+ }
+ },
+
+ addConversation(uiConv) {
+ let conv = uiConv.target;
+ if (conv.isChat) {
+ return;
+ }
+ this._convos.set(conv.id, uiConv);
+ uiConv.addObserver(this);
+ },
+
+ removeConversation(uiConv) {
+ uiConv.removeObserver(this);
+ this._convos.delete(uiConv.target.id);
+ this.clearMsgs(uiConv.target.id);
+ },
+
+ sendSecret(context, secret, question) {
+ let str = ctypes.char.array()(secret);
+ let strlen = new ctypes.size_t(str.length - 1);
+ OTRLib.otrl_message_initiate_smp_q(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ context._context,
+ question ? question : null,
+ str,
+ strlen
+ );
+ },
+
+ sendResponse(context, response) {
+ let str = ctypes.char.array()(response);
+ let strlen = new ctypes.size_t(str.length - 1);
+ OTRLib.otrl_message_respond_smp(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ context._context,
+ str,
+ strlen
+ );
+ },
+
+ abortSMP(context) {
+ OTRLib.otrl_message_abort_smp(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ context._context
+ );
+ },
+
+ onSend(om) {
+ if (om.cancelled) {
+ return;
+ }
+
+ let conv = om.conversation;
+ if (conv.isChat) {
+ return;
+ }
+
+ if (om.action) {
+ // embed /me into the message text for encrypted actions.
+ let context = this.getContext(conv);
+ if (context.msgstate != this.trustState.TRUST_NOT_PRIVATE) {
+ om.cancelled = true;
+ conv.sendMsg("/me " + om.message, false, false);
+ }
+ return;
+ }
+
+ // Skip if OTR sent this message.
+ let pendingIndex = this._pendingSystemMessages.findIndex(
+ info => info.convId == conv.id && info.message == om.message
+ );
+ if (pendingIndex > -1) {
+ this._pendingSystemMessages.splice(pendingIndex, 1);
+ return;
+ }
+
+ let newMessage = new ctypes.char.ptr();
+
+ this.log("pre sending: " + om.message);
+
+ let err = OTRLib.otrl_message_sending(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ conv.account.normalizedName,
+ conv.account.protocol.normalizedName,
+ conv.normalizedName,
+ OTRLib.instag.OTRL_INSTAG_BEST,
+ om.message,
+ null,
+ newMessage.address(),
+ OTRLib.fragPolicy.OTRL_FRAGMENT_SEND_ALL_BUT_LAST,
+ null,
+ null,
+ null
+ );
+
+ let msg = om.message;
+
+ if (err) {
+ om.cancelled = true;
+ console.error(new Error("Failed to send message. Returned code: " + err));
+ } else if (!newMessage.isNull()) {
+ msg = newMessage.readString();
+ // https://bugs.otr.im/lib/libotr/issues/52
+ if (!msg) {
+ om.cancelled = true;
+ }
+ }
+
+ if (!om.cancelled) {
+ // OTR handshakes only work while both peers are online.
+ // Sometimes we want to include a special whitespace suffix,
+ // which the OTR protocol uses to signal that the sender is willing
+ // to start an OTR session. Don't do that for offline messages.
+ // See: https://bugs.otr.im/lib/libotr/issues/102
+ if (isOnline(conv) === 0) {
+ let ind = msg.indexOf(OTRLib.OTRL_MESSAGE_TAG_BASE);
+ if (ind > -1) {
+ msg = msg.substring(0, ind);
+ let context = this.getContext(conv);
+ context._context.contents.otr_offer = OTRLib.otr_offer.OFFER_NOT;
+ }
+ }
+
+ this.bufferMsg(conv.id, om.message, msg);
+ om.message = msg;
+ }
+
+ this.log("post sending (" + !om.cancelled + "): " + om.message);
+ OTRLib.otrl_message_free(newMessage);
+ },
+
+ /**
+ *
+ * @param {imIMessage} im - Incoming message.
+ */
+ onReceive(im) {
+ if (im.cancelled || im.system) {
+ return;
+ }
+
+ let conv = im.conversation;
+ if (conv.isChat) {
+ return;
+ }
+
+ // After outgoing messages have been handled in onSend,
+ // they are again passed back to us, here in onReceive.
+ // This is our chance to prevent both outgoing and incoming OTR
+ // messages from being logged here.
+ if (im.originalMessage.startsWith("?OTR")) {
+ im.otrEncrypted = true;
+ }
+
+ if (im.outgoing) {
+ this.log("outgoing message to display: " + im.displayMessage);
+ this.pluckMsg(im);
+ return;
+ }
+
+ let newMessage = new ctypes.char.ptr();
+ let tlvs = new OTRLib.OtrlTLV.ptr();
+
+ let err = OTRLib.otrl_message_receiving(
+ this.userstate,
+ this.uiOps.address(),
+ null,
+ conv.account.normalizedName,
+ conv.account.protocol.normalizedName,
+ conv.normalizedName,
+ im.displayMessage,
+ newMessage.address(),
+ tlvs.address(),
+ null,
+ null,
+ null
+ );
+
+ // An OTR message was properly decrypted.
+ if (!newMessage.isNull()) {
+ im.displayMessage = newMessage.readString();
+ // Check if it was an encrypted action message.
+ if (im.displayMessage.startsWith("/me ")) {
+ im.action = true;
+ im.displayMessage = im.displayMessage.slice(4);
+ }
+ }
+
+ // search tlvs for a disconnect msg
+ // https://bugs.otr.im/lib/libotr/issues/54
+ let tlv = OTRLib.otrl_tlv_find(tlvs, OTRLib.tlvs.OTRL_TLV_DISCONNECTED);
+ if (!tlv.isNull()) {
+ let context = this.getContext(conv);
+ this.notifyObservers(context, "otr:disconnected");
+ this.sendAlert(
+ context,
+ _strArgs("tlv-disconnected", { name: conv.normalizedName })
+ );
+ }
+
+ if (err) {
+ this.log("error (" + err + ") ignoring: " + im.displayMessage);
+ im.cancelled = true; // ignore
+ }
+
+ OTRLib.otrl_tlv_free(tlvs);
+ OTRLib.otrl_message_free(newMessage);
+ },
+
+ // observer interface
+
+ addObserver(observer) {
+ if (!this._observers.includes(observer)) {
+ this._observers.push(observer);
+ }
+ },
+
+ removeObserver(observer) {
+ this._observers = this._observers.filter(o => o !== observer);
+ },
+
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ },
+
+ // buffer messages
+
+ /**
+ * Remove messages that were making it through the system related to a
+ * conversation.
+ *
+ * @param {number} convId - ID of the conversation to purge all messages for.
+ */
+ clearMsgs(convId) {
+ this._buffer = this._buffer.filter(msg => msg.convId !== convId);
+ this._pendingSystemMessages = this._pendingSystemMessages.filter(
+ info => info.convId !== convId
+ );
+ },
+
+ /**
+ * Save unencrypted outgoing message to a buffer so we can restore it later
+ * on when displaying it.
+ *
+ * @param {number} convId - ID of the conversation.
+ * @param {string} display - Message to display.
+ * @param {string} sent - Message that was sent.
+ */
+ bufferMsg(convId, display, sent) {
+ this._buffer.push({
+ convId,
+ display,
+ sent,
+ time: Date.now(),
+ });
+ },
+
+ /**
+ * Get the unencrypted version of an outgoing OTR encrypted message that we
+ * are handling in the incoming message path for displaying. Also discards
+ * magic OTR bytes and such for displaying.
+ *
+ * @param {imIMessage} incomingMessage - Message with an outgoing tag.
+ * @returns
+ */
+ pluckMsg(incomingMessage) {
+ for (let i = 0; i < this._buffer.length; i++) {
+ let bufferedInfo = this._buffer[i];
+ if (
+ bufferedInfo.convId === incomingMessage.conversation.id &&
+ bufferedInfo.sent === incomingMessage.displayMessage
+ ) {
+ incomingMessage.displayMessage = bufferedInfo.display;
+ this._buffer.splice(i, 1);
+ this.log("displaying: " + bufferedInfo.display);
+ return;
+ }
+ }
+ // don't display if message wasn't buffered
+ if (incomingMessage.otrEncrypted) {
+ incomingMessage.cancelled = true;
+ this.log("not displaying: " + incomingMessage.displayMessage);
+ }
+ },
+};
+
+// exports
diff --git a/comm/chat/modules/OTRLib.sys.mjs b/comm/chat/modules/OTRLib.sys.mjs
new file mode 100644
index 0000000000..b9fddbe89e
--- /dev/null
+++ b/comm/chat/modules/OTRLib.sys.mjs
@@ -0,0 +1,1151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const otrl_version = [4, 1, 1];
+
+import { CLib } from "resource:///modules/CLib.sys.mjs";
+
+import { ctypes } from "resource://gre/modules/ctypes.sys.mjs";
+
+var systemOS = Services.appinfo.OS.toLowerCase();
+
+var abi = ctypes.default_abi;
+
+var libotr, libotrPath;
+
+function getLibraryFilename(baseName, suffix) {
+ return ctypes.libraryName(baseName) + suffix;
+}
+
+function getSystemVersionedFilename() {
+ let baseName;
+ let suffix;
+
+ switch (systemOS) {
+ case "winnt":
+ baseName = "libotr-5";
+ suffix = "";
+ break;
+ case "darwin":
+ baseName = "otr.5";
+ suffix = "";
+ break;
+ default:
+ baseName = "otr";
+ suffix = ".5";
+ break;
+ }
+
+ return getLibraryFilename(baseName, suffix);
+}
+
+function getDistributionFilename() {
+ let baseName;
+ let suffix;
+
+ if (systemOS === "winnt") {
+ baseName = "libotr";
+ suffix = "";
+ } else {
+ baseName = "otr";
+ suffix = "";
+ }
+
+ return getLibraryFilename(baseName, suffix);
+}
+
+function getDistributionFullPath() {
+ let binPath = Services.dirsvc.get("XpcomLib", Ci.nsIFile).path;
+ let binDir = PathUtils.parent(binPath);
+ return PathUtils.join(binDir, getDistributionFilename());
+}
+
+function tryLoadOTR(filename, info) {
+ libotrPath = filename;
+ try {
+ libotr = ctypes.open(filename);
+ } catch (e) {
+ return `Tried to load ${filename}${info}`;
+ }
+ return "";
+}
+
+function loadExternalOTRLib() {
+ const systemInfo = " from system's standard library locations.";
+
+ let info = "";
+ // Try to load using an absolute path from our install directory
+ if (!libotr) {
+ info += tryLoadOTR(getDistributionFullPath(), "");
+ }
+
+ // Try to load using our expected filename from system directories
+ if (!libotr) {
+ info += ", " + tryLoadOTR(getDistributionFilename(), systemInfo);
+ }
+
+ // Try to load using a versioned library name
+ if (!libotr) {
+ info += ", " + tryLoadOTR(getSystemVersionedFilename(), systemInfo);
+ }
+
+ // Try other filenames
+
+ if (!libotr && systemOS == "winnt") {
+ info += ", " + tryLoadOTR(getLibraryFilename("otr.5", ""), systemInfo);
+ }
+
+ if (!libotr && systemOS == "winnt") {
+ info += ", " + tryLoadOTR(getLibraryFilename("otr-5", ""), systemInfo);
+ }
+
+ if (!libotr) {
+ info += ", " + tryLoadOTR(getLibraryFilename("otr", ""), systemInfo);
+ }
+
+ if (!libotr) {
+ throw new Error("Cannot load required OTR library; " + info);
+ }
+}
+
+export var OTRLibLoader = {
+ init() {
+ loadExternalOTRLib();
+ if (libotr) {
+ enableOTRLibJS();
+ }
+ return OTRLib;
+ },
+};
+
+// Helper function to open files with the path properly encoded.
+var callWithFILEp = function () {
+ // Windows filenames are in UTF-16.
+ let charType = systemOS === "winnt" ? "jschar" : "char";
+
+ let args = Array.from(arguments);
+ let func = args.shift() + "_FILEp";
+ let mode = ctypes[charType].array()(args.shift());
+ let ind = args.shift();
+ let filename = ctypes[charType].array()(args[ind]);
+
+ let file = CLib.fopen(filename, mode);
+ if (file.isNull()) {
+ return 1;
+ }
+
+ // Swap filename with file.
+ args[ind] = file;
+
+ let ret = OTRLib[func].apply(OTRLib, args);
+ CLib.fclose(file);
+ return ret;
+};
+
+// type defs
+
+const FILE = CLib.FILE;
+
+const time_t = ctypes.long;
+const gcry_error_t = ctypes.unsigned_int;
+const gcry_cipher_hd_t = ctypes.StructType("gcry_cipher_handle").ptr;
+const gcry_md_hd_t = ctypes.StructType("gcry_md_handle").ptr;
+const gcry_mpi_t = ctypes.StructType("gcry_mpi").ptr;
+
+const otrl_instag_t = ctypes.unsigned_int;
+const OtrlPolicy = ctypes.unsigned_int;
+const OtrlTLV = ctypes.StructType("s_OtrlTLV");
+const ConnContext = ctypes.StructType("context");
+const ConnContextPriv = ctypes.StructType("context_priv");
+const OtrlMessageAppOps = ctypes.StructType("s_OtrlMessageAppOps");
+const OtrlAuthInfo = ctypes.StructType("OtrlAuthInfo");
+const Fingerprint = ctypes.StructType("s_fingerprint");
+const s_OtrlUserState = ctypes.StructType("s_OtrlUserState");
+const OtrlUserState = s_OtrlUserState.ptr;
+const OtrlSMState = ctypes.StructType("OtrlSMState");
+const DH_keypair = ctypes.StructType("DH_keypair");
+const OtrlPrivKey = ctypes.StructType("s_OtrlPrivKey");
+const OtrlInsTag = ctypes.StructType("s_OtrlInsTag");
+const OtrlPendingPrivKey = ctypes.StructType("s_OtrlPendingPrivKey");
+
+const OTRL_PRIVKEY_FPRINT_HUMAN_LEN = 45;
+const fingerprint_t = ctypes.char.array(OTRL_PRIVKEY_FPRINT_HUMAN_LEN);
+const hash_t = ctypes.unsigned_char.array(20);
+
+const app_data_free_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+]).ptr;
+
+// enums
+
+const OtrlErrorCode = ctypes.int;
+const OtrlSMPEvent = ctypes.int;
+const OtrlMessageEvent = ctypes.int;
+const OtrlFragmentPolicy = ctypes.int;
+const OtrlConvertType = ctypes.int;
+const OtrlMessageState = ctypes.int;
+const OtrlAuthState = ctypes.int;
+const OtrlSessionIdHalf = ctypes.int;
+const OtrlSMProgState = ctypes.int;
+const NextExpectedSMP = ctypes.int;
+
+// callback signatures
+
+const policy_cb_t = ctypes.FunctionType(abi, OtrlPolicy, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+]).ptr;
+
+const create_privkey_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const is_logged_in_cb_t = ctypes.FunctionType(abi, ctypes.int, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const inject_message_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const update_context_list_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+]).ptr;
+
+const new_fingerprint_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ OtrlUserState,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.unsigned_char.array(20),
+]).ptr;
+
+const write_fingerprint_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+]).ptr;
+
+const gone_secure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+]).ptr;
+
+const gone_insecure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+]).ptr;
+
+const still_secure_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.int,
+]).ptr;
+
+const max_message_size_cb_t = ctypes.FunctionType(abi, ctypes.int, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+]).ptr;
+
+const account_name_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const account_name_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const received_symkey_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.unsigned_int,
+ ctypes.unsigned_char.ptr,
+ ctypes.size_t,
+ ctypes.unsigned_char.ptr,
+]).ptr;
+
+const otr_error_message_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ OtrlErrorCode,
+]).ptr;
+
+const otr_error_message_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const resent_msg_prefix_cb_t = ctypes.FunctionType(abi, ctypes.char.ptr, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+]).ptr;
+
+const resent_msg_prefix_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const handle_smp_event_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ OtrlSMPEvent,
+ ConnContext.ptr,
+ ctypes.unsigned_short,
+ ctypes.char.ptr,
+]).ptr;
+
+const handle_msg_event_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ OtrlMessageEvent,
+ ConnContext.ptr,
+ ctypes.char.ptr,
+ gcry_error_t,
+]).ptr;
+
+const create_instag_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const convert_msg_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ OtrlConvertType,
+ ctypes.char.ptr.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const convert_free_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.char.ptr,
+]).ptr;
+
+const timer_control_cb_t = ctypes.FunctionType(abi, ctypes.void_t, [
+ ctypes.void_t.ptr,
+ ctypes.unsigned_int,
+]).ptr;
+
+// defines
+
+s_OtrlUserState.define([
+ { context_root: ConnContext.ptr },
+ { privkey_root: OtrlPrivKey.ptr },
+ { instag_root: OtrlInsTag.ptr },
+ { pending_root: OtrlPendingPrivKey.ptr },
+ { timer_running: ctypes.int },
+]);
+
+Fingerprint.define([
+ { next: Fingerprint.ptr },
+ { tous: Fingerprint.ptr.ptr },
+ { fingerprint: ctypes.unsigned_char.ptr },
+ { context: ConnContext.ptr },
+ { trust: ctypes.char.ptr },
+]);
+
+DH_keypair.define([
+ { groupid: ctypes.unsigned_int },
+ { priv: gcry_mpi_t },
+ { pub: gcry_mpi_t },
+]);
+
+OtrlSMState.define([
+ { secret: gcry_mpi_t },
+ { x2: gcry_mpi_t },
+ { x3: gcry_mpi_t },
+ { g1: gcry_mpi_t },
+ { g2: gcry_mpi_t },
+ { g3: gcry_mpi_t },
+ { g3o: gcry_mpi_t },
+ { p: gcry_mpi_t },
+ { q: gcry_mpi_t },
+ { pab: gcry_mpi_t },
+ { qab: gcry_mpi_t },
+ { nextExpected: NextExpectedSMP },
+ { received_question: ctypes.int },
+ { sm_prog_state: OtrlSMProgState },
+]);
+
+OtrlAuthInfo.define([
+ { authstate: OtrlAuthState },
+ { context: ConnContext.ptr },
+ { our_dh: DH_keypair },
+ { our_keyid: ctypes.unsigned_int },
+ { encgx: ctypes.unsigned_char.ptr },
+ { encgx_len: ctypes.size_t },
+ { r: ctypes.unsigned_char.array(16) },
+ { hashgx: ctypes.unsigned_char.array(32) },
+ { their_pub: gcry_mpi_t },
+ { their_keyid: ctypes.unsigned_int },
+ { enc_c: gcry_cipher_hd_t },
+ { enc_cp: gcry_cipher_hd_t },
+ { mac_m1: gcry_md_hd_t },
+ { mac_m1p: gcry_md_hd_t },
+ { mac_m2: gcry_md_hd_t },
+ { mac_m2p: gcry_md_hd_t },
+ { their_fingerprint: ctypes.unsigned_char.array(20) },
+ { initiated: ctypes.int },
+ { protocol_version: ctypes.unsigned_int },
+ { secure_session_id: ctypes.unsigned_char.array(20) },
+ { secure_session_id_len: ctypes.size_t },
+ { session_id_half: OtrlSessionIdHalf },
+ { lastauthmsg: ctypes.char.ptr },
+ { commit_sent_time: time_t },
+]);
+
+ConnContext.define([
+ { next: ConnContext.ptr },
+ { tous: ConnContext.ptr.ptr },
+ { context_priv: ConnContextPriv.ptr },
+ { username: ctypes.char.ptr },
+ { accountname: ctypes.char.ptr },
+ { protocol: ctypes.char.ptr },
+ { m_context: ConnContext.ptr },
+ { recent_rcvd_child: ConnContext.ptr },
+ { recent_sent_child: ConnContext.ptr },
+ { recent_child: ConnContext.ptr },
+ { our_instance: otrl_instag_t },
+ { their_instance: otrl_instag_t },
+ { msgstate: OtrlMessageState },
+ { auth: OtrlAuthInfo },
+ { fingerprint_root: Fingerprint },
+ { active_fingerprint: Fingerprint.ptr },
+ { sessionid: ctypes.unsigned_char.array(20) },
+ { sessionid_len: ctypes.size_t },
+ { sessionid_half: OtrlSessionIdHalf },
+ { protocol_version: ctypes.unsigned_int },
+ { otr_offer: ctypes.int },
+ { app_data: ctypes.void_t.ptr },
+ { app_data_free: app_data_free_t },
+ { smstate: OtrlSMState.ptr },
+]);
+
+OtrlMessageAppOps.define([
+ { policy: policy_cb_t },
+ { create_privkey: create_privkey_cb_t },
+ { is_logged_in: is_logged_in_cb_t },
+ { inject_message: inject_message_cb_t },
+ { update_context_list: update_context_list_cb_t },
+ { new_fingerprint: new_fingerprint_cb_t },
+ { write_fingerprint: write_fingerprint_cb_t },
+ { gone_secure: gone_secure_cb_t },
+ { gone_insecure: gone_insecure_cb_t },
+ { still_secure: still_secure_cb_t },
+ { max_message_size: max_message_size_cb_t },
+ { account_name: account_name_cb_t },
+ { account_name_free: account_name_free_cb_t },
+ { received_symkey: received_symkey_cb_t },
+ { otr_error_message: otr_error_message_cb_t },
+ { otr_error_message_free: otr_error_message_free_cb_t },
+ { resent_msg_prefix: resent_msg_prefix_cb_t },
+ { resent_msg_prefix_free: resent_msg_prefix_free_cb_t },
+ { handle_smp_event: handle_smp_event_cb_t },
+ { handle_msg_event: handle_msg_event_cb_t },
+ { create_instag: create_instag_cb_t },
+ { convert_msg: convert_msg_cb_t },
+ { convert_free: convert_free_cb_t },
+ { timer_control: timer_control_cb_t },
+]);
+
+OtrlTLV.define([
+ { type: ctypes.unsigned_short },
+ { len: ctypes.unsigned_short },
+ { data: ctypes.unsigned_char.ptr },
+ { next: OtrlTLV.ptr },
+]);
+
+// policies
+
+// const OTRL_POLICY_ALLOW_V1 = 0x01;
+const OTRL_POLICY_ALLOW_V2 = 0x02;
+
+// const OTRL_POLICY_ALLOW_V3 = 0x04;
+// See https://bugzilla.mozilla.org/show_bug.cgi?id=1550474 re v3.
+
+const OTRL_POLICY_REQUIRE_ENCRYPTION = 0x08;
+const OTRL_POLICY_SEND_WHITESPACE_TAG = 0x10;
+const OTRL_POLICY_WHITESPACE_START_AKE = 0x20;
+
+// const OTRL_POLICY_ERROR_START_AKE = 0x40;
+// Disabled to avoid automatic resend and MITM, as explained in
+// https://github.com/arlolra/ctypes-otr/issues/55
+
+var OTRLib;
+
+function enableOTRLibJS() {
+ // this must be delayed until after "libotr" is initialized
+
+ OTRLib = {
+ path: libotrPath,
+
+ // libotr API version
+ otrl_version,
+
+ init() {
+ // apply version array as arguments to the init function
+ if (this.otrl_init.apply(this, this.otrl_version)) {
+ throw new Error("Couldn't initialize libotr.");
+ }
+ return true;
+ },
+
+ // proto.h
+
+ // If we ever see this sequence in a plaintext message, we'll assume the
+ // other side speaks OTR, and try to establish a connection.
+ OTRL_MESSAGE_TAG_BASE: " \t \t\t\t\t \t \t \t ",
+
+ OTRL_POLICY_OPPORTUNISTIC: new ctypes.unsigned_int(
+ OTRL_POLICY_ALLOW_V2 |
+ // OTRL_POLICY_ALLOW_V3 |
+ OTRL_POLICY_SEND_WHITESPACE_TAG |
+ OTRL_POLICY_WHITESPACE_START_AKE |
+ // OTRL_POLICY_ERROR_START_AKE |
+ 0
+ ),
+
+ OTRL_POLICY_ALWAYS: new ctypes.unsigned_int(
+ OTRL_POLICY_ALLOW_V2 |
+ // OTRL_POLICY_ALLOW_V3 |
+ OTRL_POLICY_REQUIRE_ENCRYPTION |
+ OTRL_POLICY_WHITESPACE_START_AKE |
+ // OTRL_POLICY_ERROR_START_AKE |
+ 0
+ ),
+
+ fragPolicy: {
+ OTRL_FRAGMENT_SEND_SKIP: 0,
+ OTRL_FRAGMENT_SEND_ALL: 1,
+ OTRL_FRAGMENT_SEND_ALL_BUT_FIRST: 2,
+ OTRL_FRAGMENT_SEND_ALL_BUT_LAST: 3,
+ },
+
+ // Return a pointer to a newly-allocated OTR query message, customized
+ // with our name. The caller should free() the result when he's done
+ // with it.
+ otrl_proto_default_query_msg: libotr.declare(
+ "otrl_proto_default_query_msg",
+ abi,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ OtrlPolicy
+ ),
+
+ // Initialize the OTR library. Pass the version of the API you are using.
+ otrl_init: libotr.declare(
+ "otrl_init",
+ abi,
+ gcry_error_t,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int,
+ ctypes.unsigned_int
+ ),
+
+ // instag.h
+
+ instag: {
+ OTRL_INSTAG_MASTER: new ctypes.unsigned_int(0),
+ OTRL_INSTAG_BEST: new ctypes.unsigned_int(1),
+ OTRL_INSTAG_RECENT: new ctypes.unsigned_int(2),
+ OTRL_INSTAG_RECENT_RECEIVED: new ctypes.unsigned_int(3),
+ OTRL_INSTAG_RECENT_SENT: new ctypes.unsigned_int(4),
+ OTRL_MIN_VALID_INSTAG: new ctypes.unsigned_int(0x100),
+ },
+
+ // Get a new instance tag for the given account and write to file. The FILE*
+ // must be open for writing.
+ otrl_instag_generate: callWithFILEp.bind(
+ null,
+ "otrl_instag_generate",
+ "wb",
+ 1
+ ),
+ otrl_instag_generate_FILEp: libotr.declare(
+ "otrl_instag_generate_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ // Read our instance tag from a file on disk into the given OtrlUserState.
+ // The FILE* must be open for reading.
+ otrl_instag_read: callWithFILEp.bind(null, "otrl_instag_read", "rb", 1),
+ otrl_instag_read_FILEp: libotr.declare(
+ "otrl_instag_read_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr
+ ),
+
+ // Write our instance tags to a file on disk. The FILE* must be open for
+ // writing.
+ otrl_instag_write: callWithFILEp.bind(null, "otrl_instag_write", "wb", 1),
+ otrl_instag_write_FILEp: libotr.declare(
+ "otrl_instag_write_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr
+ ),
+
+ // auth.h
+
+ authState: {
+ OTRL_AUTHSTATE_NONE: 0,
+ OTRL_AUTHSTATE_AWAITING_DHKEY: 1,
+ OTRL_AUTHSTATE_AWAITING_REVEALSIG: 2,
+ OTRL_AUTHSTATE_AWAITING_SIG: 3,
+ OTRL_AUTHSTATE_V1_SETUP: 4,
+ },
+
+ // b64.h
+
+ // base64 encode data. Insert no linebreaks or whitespace.
+ // The buffer base64data must contain at least ((datalen+2)/3)*4 bytes of
+ // space. This function will return the number of bytes actually used.
+ otrl_base64_encode: libotr.declare(
+ "otrl_base64_encode",
+ abi,
+ ctypes.size_t,
+ ctypes.char.ptr,
+ ctypes.unsigned_char.ptr,
+ ctypes.size_t
+ ),
+
+ // base64 decode data. Skip non-base64 chars, and terminate at the
+ // first '=', or the end of the buffer.
+ // The buffer data must contain at least ((base64len+3) / 4) * 3 bytes
+ // of space. This function will return the number of bytes actually
+ // used.
+ otrl_base64_decode: libotr.declare(
+ "otrl_base64_decode",
+ abi,
+ ctypes.size_t,
+ ctypes.unsigned_char.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t
+ ),
+
+ // context.h
+
+ otr_offer: {
+ OFFER_NOT: 0,
+ OFFER_SENT: 1,
+ OFFER_REJECTED: 2,
+ OFFER_ACCEPTED: 3,
+ },
+
+ messageState: {
+ OTRL_MSGSTATE_PLAINTEXT: 0,
+ OTRL_MSGSTATE_ENCRYPTED: 1,
+ OTRL_MSGSTATE_FINISHED: 2,
+ },
+
+ // Look up a connection context by name/account/protocol/instance from the
+ // given OtrlUserState.
+ otrl_context_find: libotr.declare(
+ "otrl_context_find",
+ abi,
+ ConnContext.ptr,
+ OtrlUserState,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ otrl_instag_t,
+ ctypes.int,
+ ctypes.int.ptr,
+ ctypes.void_t.ptr,
+ ctypes.void_t.ptr
+ ),
+
+ // Set the trust level for a given fingerprint.
+ otrl_context_set_trust: libotr.declare(
+ "otrl_context_set_trust",
+ abi,
+ ctypes.void_t,
+ Fingerprint.ptr,
+ ctypes.char.ptr
+ ),
+
+ // Find a fingerprint in a given context, perhaps adding it if not present.
+ otrl_context_find_fingerprint: libotr.declare(
+ "otrl_context_find_fingerprint",
+ abi,
+ Fingerprint.ptr,
+ ConnContext.ptr,
+ hash_t,
+ ctypes.int,
+ ctypes.int.ptr
+ ),
+
+ // Forget a fingerprint (and maybe the whole context).
+ otrl_context_forget_fingerprint: libotr.declare(
+ "otrl_context_forget_fingerprint",
+ abi,
+ ctypes.void_t,
+ Fingerprint.ptr,
+ ctypes.int
+ ),
+
+ // Return true iff the given fingerprint is marked as trusted.
+ otrl_context_is_fingerprint_trusted: libotr.declare(
+ "otrl_context_is_fingerprint_trusted",
+ abi,
+ ctypes.int,
+ Fingerprint.ptr
+ ),
+
+ // dh.h
+
+ sessionIdHalf: {
+ OTRL_SESSIONID_FIRST_HALF_BOLD: 0,
+ OTRL_SESSIONID_SECOND_HALF_BOLD: 1,
+ },
+
+ // sm.h
+
+ nextExpectedSMP: {
+ OTRL_SMP_EXPECT1: 0,
+ OTRL_SMP_EXPECT2: 1,
+ OTRL_SMP_EXPECT3: 2,
+ OTRL_SMP_EXPECT4: 3,
+ OTRL_SMP_EXPECT5: 4,
+ },
+
+ smProgState: {
+ OTRL_SMP_PROG_OK: 0,
+ OTRL_SMP_PROG_CHEATED: -2,
+ OTRL_SMP_PROG_FAILED: -1,
+ OTRL_SMP_PROG_SUCCEEDED: 1,
+ },
+
+ // userstate.h
+
+ // Create a new OtrlUserState.
+ otrl_userstate_create: libotr.declare(
+ "otrl_userstate_create",
+ abi,
+ OtrlUserState
+ ),
+
+ // privkey.h
+
+ // Generate a private DSA key for a given account, storing it into a file on
+ // disk, and loading it into the given OtrlUserState. Overwrite any
+ // previously generated keys for that account in that OtrlUserState.
+ otrl_privkey_generate: callWithFILEp.bind(
+ null,
+ "otrl_privkey_generate",
+ "w+b",
+ 1
+ ),
+ otrl_privkey_generate_FILEp: libotr.declare(
+ "otrl_privkey_generate_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ // Begin a private key generation that will potentially take place in
+ // a background thread. This routine must be called from the main
+ // thread. It will set *newkeyp, which you can pass to
+ // otrl_privkey_generate_calculate in a background thread. If it
+ // returns gcry_error(GPG_ERR_EEXIST), then a privkey creation for
+ // this accountname/protocol is already in progress, and *newkeyp will
+ // be set to NULL.
+ otrl_privkey_generate_start: libotr.declare(
+ "otrl_privkey_generate_start",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.void_t.ptr.ptr
+ ),
+
+ // Do the private key generation calculation. You may call this from a
+ // background thread. When it completes, call
+ // otrl_privkey_generate_finish from the _main_ thread.
+ otrl_privkey_generate_calculate: libotr.declare(
+ "otrl_privkey_generate_calculate",
+ abi,
+ gcry_error_t,
+ ctypes.void_t.ptr
+ ),
+
+ // Call this from the main thread only. It will write the newly created
+ // private key into the given file and store it in the OtrlUserState.
+ otrl_privkey_generate_finish: callWithFILEp.bind(
+ null,
+ "otrl_privkey_generate_finish",
+ "w+b",
+ 2
+ ),
+ otrl_privkey_generate_finish_FILEp: libotr.declare(
+ "otrl_privkey_generate_finish_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ ctypes.void_t.ptr,
+ FILE.ptr
+ ),
+
+ // Call this from the main thread only, in the event that the background
+ // thread generating the key is cancelled. The newkey is deallocated,
+ // and must not be used further.
+ otrl_privkey_generate_cancelled: libotr.declare(
+ "otrl_privkey_generate_cancelled",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ ctypes.void_t.ptr
+ ),
+
+ // Read a sets of private DSA keys from a file on disk into the given
+ // OtrlUserState.
+ otrl_privkey_read: callWithFILEp.bind(null, "otrl_privkey_read", "rb", 1),
+ otrl_privkey_read_FILEp: libotr.declare(
+ "otrl_privkey_read_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr
+ ),
+
+ // Read the fingerprint store from a file on disk into the given
+ // OtrlUserState.
+ otrl_privkey_read_fingerprints: callWithFILEp.bind(
+ null,
+ "otrl_privkey_read_fingerprints",
+ "rb",
+ 1
+ ),
+ otrl_privkey_read_fingerprints_FILEp: libotr.declare(
+ "otrl_privkey_read_fingerprints_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr,
+ ctypes.void_t.ptr,
+ ctypes.void_t.ptr
+ ),
+
+ // Write the fingerprint store from a given OtrlUserState to a file on disk.
+ otrl_privkey_write_fingerprints: callWithFILEp.bind(
+ null,
+ "otrl_privkey_write_fingerprints",
+ "wb",
+ 1
+ ),
+ otrl_privkey_write_fingerprints_FILEp: libotr.declare(
+ "otrl_privkey_write_fingerprints_FILEp",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ FILE.ptr
+ ),
+
+ // The length of a string representing a human-readable version of a
+ // fingerprint (including the trailing NUL).
+ OTRL_PRIVKEY_FPRINT_HUMAN_LEN,
+
+ // Human readable fingerprint type
+ fingerprint_t,
+
+ // fingerprint value
+ hash_t,
+
+ // Calculate a human-readable hash of our DSA public key. Return it in the
+ // passed fingerprint buffer. Return NULL on error, or a pointer to the given
+ // buffer on success.
+ otrl_privkey_fingerprint: libotr.declare(
+ "otrl_privkey_fingerprint",
+ abi,
+ ctypes.char.ptr,
+ OtrlUserState,
+ fingerprint_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ // Convert a 20-byte hash value to a 45-byte human-readable value.
+ otrl_privkey_hash_to_human: libotr.declare(
+ "otrl_privkey_hash_to_human",
+ abi,
+ ctypes.void_t,
+ fingerprint_t,
+ hash_t
+ ),
+
+ // Calculate a raw hash of our DSA public key. Return it in the passed
+ // fingerprint buffer. Return NULL on error, or a pointer to the given
+ // buffer on success.
+ otrl_privkey_fingerprint_raw: libotr.declare(
+ "otrl_privkey_fingerprint_raw",
+ abi,
+ ctypes.unsigned_char.ptr,
+ OtrlUserState,
+ hash_t,
+ ctypes.char.ptr,
+ ctypes.char.ptr
+ ),
+
+ // uiOps callbacks
+ policy_cb_t,
+ create_privkey_cb_t,
+ is_logged_in_cb_t,
+ inject_message_cb_t,
+ update_context_list_cb_t,
+ new_fingerprint_cb_t,
+ write_fingerprint_cb_t,
+ gone_secure_cb_t,
+ gone_insecure_cb_t,
+ still_secure_cb_t,
+ max_message_size_cb_t,
+ account_name_cb_t,
+ account_name_free_cb_t,
+ received_symkey_cb_t,
+ otr_error_message_cb_t,
+ otr_error_message_free_cb_t,
+ resent_msg_prefix_cb_t,
+ resent_msg_prefix_free_cb_t,
+ handle_smp_event_cb_t,
+ handle_msg_event_cb_t,
+ create_instag_cb_t,
+ convert_msg_cb_t,
+ convert_free_cb_t,
+ timer_control_cb_t,
+
+ // message.h
+
+ OtrlMessageAppOps,
+
+ errorCode: {
+ OTRL_ERRCODE_NONE: 0,
+ OTRL_ERRCODE_ENCRYPTION_ERROR: 1,
+ OTRL_ERRCODE_MSG_NOT_IN_PRIVATE: 2,
+ OTRL_ERRCODE_MSG_UNREADABLE: 3,
+ OTRL_ERRCODE_MSG_MALFORMED: 4,
+ },
+
+ smpEvent: {
+ OTRL_SMPEVENT_NONE: 0,
+ OTRL_SMPEVENT_ERROR: 1,
+ OTRL_SMPEVENT_ABORT: 2,
+ OTRL_SMPEVENT_CHEATED: 3,
+ OTRL_SMPEVENT_ASK_FOR_ANSWER: 4,
+ OTRL_SMPEVENT_ASK_FOR_SECRET: 5,
+ OTRL_SMPEVENT_IN_PROGRESS: 6,
+ OTRL_SMPEVENT_SUCCESS: 7,
+ OTRL_SMPEVENT_FAILURE: 8,
+ },
+
+ messageEvent: {
+ OTRL_MSGEVENT_NONE: 0,
+ OTRL_MSGEVENT_ENCRYPTION_REQUIRED: 1,
+ OTRL_MSGEVENT_ENCRYPTION_ERROR: 2,
+ OTRL_MSGEVENT_CONNECTION_ENDED: 3,
+ OTRL_MSGEVENT_SETUP_ERROR: 4,
+ OTRL_MSGEVENT_MSG_REFLECTED: 5,
+ OTRL_MSGEVENT_MSG_RESENT: 6,
+ OTRL_MSGEVENT_RCVDMSG_NOT_IN_PRIVATE: 7,
+ OTRL_MSGEVENT_RCVDMSG_UNREADABLE: 8,
+ OTRL_MSGEVENT_RCVDMSG_MALFORMED: 9,
+ OTRL_MSGEVENT_LOG_HEARTBEAT_RCVD: 10,
+ OTRL_MSGEVENT_LOG_HEARTBEAT_SENT: 11,
+ OTRL_MSGEVENT_RCVDMSG_GENERAL_ERR: 12,
+ OTRL_MSGEVENT_RCVDMSG_UNENCRYPTED: 13,
+ OTRL_MSGEVENT_RCVDMSG_UNRECOGNIZED: 14,
+ OTRL_MSGEVENT_RCVDMSG_FOR_OTHER_INSTANCE: 15,
+ },
+
+ convertType: {
+ OTRL_CONVERT_SENDING: 0,
+ OTRL_CONVERT_RECEIVING: 1,
+ },
+
+ // Deallocate a message allocated by other otrl_message_* routines.
+ otrl_message_free: libotr.declare(
+ "otrl_message_free",
+ abi,
+ ctypes.void_t,
+ ctypes.char.ptr
+ ),
+
+ // Handle a message about to be sent to the network.
+ otrl_message_sending: libotr.declare(
+ "otrl_message_sending",
+ abi,
+ gcry_error_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ otrl_instag_t,
+ ctypes.char.ptr,
+ OtrlTLV.ptr,
+ ctypes.char.ptr.ptr,
+ OtrlFragmentPolicy,
+ ConnContext.ptr.ptr,
+ ctypes.void_t.ptr,
+ ctypes.void_t.ptr
+ ),
+
+ // Handle a message just received from the network.
+ otrl_message_receiving: libotr.declare(
+ "otrl_message_receiving",
+ abi,
+ ctypes.int,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr.ptr,
+ OtrlTLV.ptr.ptr,
+ ConnContext.ptr.ptr,
+ ctypes.void_t.ptr,
+ ctypes.void_t.ptr
+ ),
+
+ // Put a connection into the PLAINTEXT state, first sending the
+ // other side a notice that we're doing so if we're currently ENCRYPTED,
+ // and we think he's logged in. Affects only the specified instance.
+ otrl_message_disconnect: libotr.declare(
+ "otrl_message_disconnect",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ otrl_instag_t
+ ),
+
+ // Call this function every so often, to clean up stale private state that
+ // may otherwise stick around in memory.
+ otrl_message_poll: libotr.declare(
+ "otrl_message_poll",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr
+ ),
+
+ // Initiate the Socialist Millionaires' Protocol.
+ otrl_message_initiate_smp: libotr.declare(
+ "otrl_message_initiate_smp",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t
+ ),
+
+ // Initiate the Socialist Millionaires' Protocol and send a prompt
+ // question to the buddy.
+ otrl_message_initiate_smp_q: libotr.declare(
+ "otrl_message_initiate_smp_q",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.char.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t
+ ),
+
+ // Respond to a buddy initiating the Socialist Millionaires' Protocol.
+ otrl_message_respond_smp: libotr.declare(
+ "otrl_message_respond_smp",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ConnContext.ptr,
+ ctypes.char.ptr,
+ ctypes.size_t
+ ),
+
+ // Abort the SMP. Called when an unexpected SMP message breaks the
+ // normal flow.
+ otrl_message_abort_smp: libotr.declare(
+ "otrl_message_abort_smp",
+ abi,
+ ctypes.void_t,
+ OtrlUserState,
+ OtrlMessageAppOps.ptr,
+ ctypes.void_t.ptr,
+ ConnContext.ptr
+ ),
+
+ // tlv.h
+
+ tlvs: {
+ OTRL_TLV_PADDING: new ctypes.unsigned_short(0x0000),
+ OTRL_TLV_DISCONNECTED: new ctypes.unsigned_short(0x0001),
+ OTRL_TLV_SMP1: new ctypes.unsigned_short(0x0002),
+ OTRL_TLV_SMP2: new ctypes.unsigned_short(0x0003),
+ OTRL_TLV_SMP3: new ctypes.unsigned_short(0x0004),
+ OTRL_TLV_SMP4: new ctypes.unsigned_short(0x0005),
+ OTRL_TLV_SMP_ABORT: new ctypes.unsigned_short(0x0006),
+ OTRL_TLV_SMP1Q: new ctypes.unsigned_short(0x0007),
+ OTRL_TLV_SYMKEY: new ctypes.unsigned_short(0x0008),
+ },
+
+ OtrlTLV,
+
+ // Return the first TLV with the given type in the chain, or NULL if one
+ // isn't found.
+ otrl_tlv_find: libotr.declare(
+ "otrl_tlv_find",
+ abi,
+ OtrlTLV.ptr,
+ OtrlTLV.ptr,
+ ctypes.unsigned_short
+ ),
+
+ // Deallocate a chain of TLVs.
+ otrl_tlv_free: libotr.declare(
+ "otrl_tlv_free",
+ abi,
+ ctypes.void_t,
+ OtrlTLV.ptr
+ ),
+ };
+}
+
+// exports
diff --git a/comm/chat/modules/OTRUI.sys.mjs b/comm/chat/modules/OTRUI.sys.mjs
new file mode 100644
index 0000000000..fdf4771607
--- /dev/null
+++ b/comm/chat/modules/OTRUI.sys.mjs
@@ -0,0 +1,998 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { OTR } from "resource:///modules/OTR.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["messenger/otr/otrUI.ftl"], true)
+);
+
+function _str(id) {
+ return lazy.l10n.formatValueSync(id);
+}
+
+function _strArgs(id, args) {
+ return lazy.l10n.formatValueSync(id, args);
+}
+
+const OTR_ADD_FINGER_DIALOG_URL =
+ "chrome://chat/content/otr-add-fingerprint.xhtml";
+
+const AUTH_STATUS_UNVERIFIED = "otr-auth-unverified";
+var authLabelMap;
+var trustMap;
+
+function initStrings() {
+ authLabelMap = new Map([
+ ["otr:auth-error", _str("auth-error")],
+ ["otr:auth-success", _str("auth-success")],
+ ["otr:auth-success-them", _str("auth-success-them")],
+ ["otr:auth-fail", _str("auth-fail")],
+ ["otr:auth-waiting", _str("auth-waiting")],
+ ]);
+
+ let sl = _str("start-label");
+ let al = _str("auth-label");
+ let rfl = _str("refresh-label");
+ let ral = _str("reauth-label");
+
+ trustMap = new Map([
+ [
+ OTR.trustState.TRUST_NOT_PRIVATE,
+ {
+ startLabel: sl,
+ authLabel: al,
+ disableStart: false,
+ disableEnd: true,
+ disableAuth: true,
+ class: "not-private",
+ },
+ ],
+ [
+ OTR.trustState.TRUST_UNVERIFIED,
+ {
+ startLabel: rfl,
+ authLabel: al,
+ disableStart: false,
+ disableEnd: false,
+ disableAuth: false,
+ class: "unverified",
+ },
+ ],
+ [
+ OTR.trustState.TRUST_PRIVATE,
+ {
+ startLabel: rfl,
+ authLabel: ral,
+ disableStart: false,
+ disableEnd: false,
+ disableAuth: false,
+ class: "private",
+ },
+ ],
+ [
+ OTR.trustState.TRUST_FINISHED,
+ {
+ startLabel: sl,
+ authLabel: al,
+ disableStart: false,
+ disableEnd: false,
+ disableAuth: true,
+ class: "finished",
+ },
+ ],
+ ]);
+}
+
+var windowRefs = new Map();
+
+export var OTRUI = {
+ enabled: false,
+ stringsLoaded: false,
+ globalDoc: null,
+ visibleConv: null,
+
+ debug: false,
+ logMsg(msg) {
+ if (!OTRUI.debug) {
+ return;
+ }
+ Services.console.logStringMessage(msg);
+ },
+
+ addMenuObserver() {
+ for (let win of Services.ww.getWindowEnumerator()) {
+ OTRUI.addMenus(win);
+ }
+ Services.obs.addObserver(OTRUI, "domwindowopened");
+ },
+
+ removeMenuObserver() {
+ for (let win of Services.ww.getWindowEnumerator()) {
+ OTRUI.removeMenus(win);
+ }
+ Services.obs.removeObserver(OTRUI, "domwindowopened");
+ },
+
+ addMenus(win) {
+ let doc = win.document;
+ // Account for unready windows
+ if (doc.readyState !== "complete") {
+ let listen = function () {
+ win.removeEventListener("load", listen);
+ OTRUI.addMenus(win);
+ };
+ win.addEventListener("load", listen);
+ }
+ },
+
+ removeMenus(win) {
+ let doc = win.document;
+ OTRUI.removeBuddyContextMenu(doc);
+ },
+
+ addBuddyContextMenu(buddyContextMenu, doc, contact) {
+ if (!buddyContextMenu || !OTR.libLoaded) {
+ return; // Not the buddy list context menu
+ }
+
+ let sep = doc.createXULElement("menuseparator");
+ sep.setAttribute("id", "otrsep");
+ let menuitem = doc.createXULElement("menuitem");
+ menuitem.setAttribute("label", _str("buddycontextmenu-label"));
+ menuitem.setAttribute("id", "otrcont");
+ menuitem.addEventListener("command", () => {
+ let args = OTRUI.contactWrapper(contact);
+ args.wrappedJSObject = args;
+ let features = "chrome,modal,centerscreen,resizable=no,minimizable=no";
+ Services.ww.openWindow(
+ null,
+ OTR_ADD_FINGER_DIALOG_URL,
+ "",
+ features,
+ args
+ );
+ });
+
+ buddyContextMenu.addEventListener("popupshowing", e => {
+ let target = e.target.triggerNode;
+ if (target.localName == "richlistitem") {
+ menuitem.hidden = false;
+ sep.hidden = false;
+ } else {
+ /* probably imconv */
+ menuitem.hidden = true;
+ sep.hidden = true;
+ }
+ });
+
+ buddyContextMenu.appendChild(sep);
+ buddyContextMenu.appendChild(menuitem);
+ },
+
+ removeBuddyContextMenu(doc) {
+ let s = doc.getElementById("otrsep");
+ if (s) {
+ s.remove();
+ }
+ let p = doc.getElementById("otrcont");
+ if (p) {
+ p.remove();
+ }
+ },
+
+ loopKeyGenSuccess() {
+ ChromeUtils.idleDispatch(OTRUI.genNextMissingKey);
+ },
+
+ loopKeyGenFailure(param) {
+ ChromeUtils.idleDispatch(OTRUI.genNextMissingKey);
+ OTRUI.reportKeyGenFailure(param);
+ },
+
+ reportKeyGenFailure(param) {
+ throw new Error(_strArgs("otr-genkey-failed", { error: String(param) }));
+ },
+
+ accountsToGenKey: [],
+
+ genNextMissingKey() {
+ if (OTRUI.accountsToGenKey.length == 0) {
+ return;
+ }
+
+ let acc = OTRUI.accountsToGenKey.pop();
+ let fp = OTR.privateKeyFingerprint(acc.name, acc.prot);
+ if (!fp) {
+ OTR.generatePrivateKey(acc.name, acc.prot).then(
+ OTRUI.loopKeyGenSuccess,
+ OTRUI.loopKeyGenFailure
+ );
+ } else {
+ ChromeUtils.idleDispatch(OTRUI.genNextMissingKey);
+ }
+ },
+
+ genMissingKeys() {
+ for (let acc of IMServices.accounts.getAccounts()) {
+ OTRUI.accountsToGenKey.push({
+ name: acc.normalizedName,
+ prot: acc.protocol.normalizedName,
+ });
+ }
+ ChromeUtils.idleDispatch(OTRUI.genNextMissingKey);
+ },
+
+ async init() {
+ if (!OTRUI.stringsLoaded) {
+ // HACK: calling initStrings may fail the first time due to synchronous
+ // loading of the .ftl files. If we load the files and wait for a known
+ // value asynchronously, no such failure will happen.
+ //
+ // If the value "start-label" is removed, this will fail.
+ //
+ // Also, we can't reuse this Localization object elsewhere because it
+ // fails to load values synchronously (even after calling setIsSync).
+ await new Localization(["messenger/otr/otrUI.ftl"]).formatValue(
+ "start-label"
+ );
+
+ initStrings();
+ OTRUI.stringsLoaded = true;
+ }
+
+ this.debug = Services.prefs.getBoolPref("chat.otr.trace", false);
+
+ OTR.init({});
+ if (!OTR.libLoaded) {
+ return;
+ }
+
+ this.enabled = true;
+ this.notificationbox = null;
+
+ OTR.addObserver(OTRUI);
+ OTR.loadFiles()
+ .then(function () {
+ Services.obs.addObserver(OTR, "new-ui-conversation");
+ Services.obs.addObserver(OTR, "conversation-update-type");
+ // Disabled until #76 is resolved.
+ // Services.obs.addObserver(OTRUI, "contact-added", false);
+ Services.obs.addObserver(OTRUI, "account-added");
+ // Services.obs.addObserver(OTRUI, "contact-signed-off", false);
+ Services.obs.addObserver(OTRUI, "conversation-loaded");
+ Services.obs.addObserver(OTRUI, "conversation-closed");
+ Services.obs.addObserver(OTRUI, "prpl-quit");
+
+ for (let conv of IMServices.conversations.getConversations()) {
+ OTRUI.initConv(conv);
+ }
+ OTRUI.addMenuObserver();
+
+ ChromeUtils.idleDispatch(OTRUI.genMissingKeys);
+ })
+ .catch(function (err) {
+ // console.log("===> " + err + "\n");
+ throw err;
+ });
+ },
+
+ disconnect(aConv) {
+ if (aConv) {
+ return OTR.disconnect(aConv, true);
+ }
+ let allGood = true;
+ for (let conv of IMServices.conversations.getConversations()) {
+ if (conv.isChat) {
+ continue;
+ }
+ if (!OTR.disconnect(conv, true)) {
+ allGood = false;
+ }
+ }
+ return allGood;
+ },
+
+ openAuth(window, name, mode, uiConv, contactInfo) {
+ let otrAuth = this.globalDoc.querySelector(".otr-auth");
+ otrAuth.disabled = true;
+ let win = window.openDialog(
+ "chrome://chat/content/otr-auth.xhtml",
+ "auth=" + name,
+ "centerscreen,resizable=no,minimizable=no",
+ mode,
+ uiConv,
+ contactInfo
+ );
+ windowRefs.set(name, win);
+ window.addEventListener("beforeunload", function () {
+ otrAuth.disabled = false;
+ windowRefs.delete(name);
+ });
+ },
+
+ closeAuth(context) {
+ let win = windowRefs.get(context.username);
+ if (win) {
+ win.close();
+ }
+ },
+
+ /**
+ * Hide the encryption state container and any pending notifications.
+ *
+ * @param {Element} otrContainer
+ * @param {Context} [context]
+ */
+ noOtrPossible(otrContainer, context) {
+ otrContainer.hidden = true;
+
+ if (context) {
+ OTRUI.hideUserNotifications(context);
+ } else {
+ OTRUI.hideAllOTRNotifications();
+ }
+ },
+
+ sendSystemAlert(uiConv, conv, bundleId) {
+ uiConv.systemMessage(
+ _strArgs(bundleId, { name: conv.normalizedName }),
+ false,
+ true
+ );
+ },
+
+ setNotificationBox(notificationbox) {
+ this.globalBox = notificationbox;
+ },
+
+ /*
+ * These states are only relevant if OTR is the only encryption available for
+ * the conversation. Protocol provided encryption takes priority.
+ * possible states:
+ * tab isn't a 1:1, isChat == true
+ * then OTR isn't possible, hide the button
+ * tab is a 1:1, isChat == false
+ * no conversation active, uiConv cannot be found
+ * then OTR isn't possible YET, hide the button
+ * conversation active, uiConv found
+ * disconnected?
+ * could the other side come back? should we keep the button?
+ * set the state based on the OTR library state
+ */
+
+ /**
+ * Store a reference to the document, as well as the current conversation.
+ *
+ * @param {Element} aObject - conversation-browser instance (most importantly, has a _conv field)
+ */
+ addButton(aObject) {
+ this.globalDoc = aObject.ownerDocument;
+ let _conv = aObject._conv;
+ OTRUI.visibleConv = _conv;
+ if (
+ _conv.encryptionState === Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ OTRUI.setMsgState(_conv, null, this.globalDoc, true);
+ }
+ },
+
+ /**
+ * Hide the encryption state information for the current conversation.
+ */
+ hideOTRButton() {
+ if (!OTR.libLoaded) {
+ return;
+ }
+ if (!this.globalDoc) {
+ return;
+ }
+ OTRUI.visibleConv = null;
+ let otrContainer = this.globalDoc.querySelector(".encryption-container");
+ OTRUI.noOtrPossible(otrContainer);
+ },
+
+ /**
+ * Sets the visible conversation of the OTR UI state and ensures
+ * the encryption state button is set up correctly.
+ *
+ * @param {prplIConversation} _conv
+ */
+ updateOTRButton(_conv) {
+ if (
+ _conv.encryptionState !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED
+ ) {
+ return;
+ }
+ if (!OTR.libLoaded) {
+ return;
+ }
+ if (!this.globalDoc) {
+ return;
+ }
+ OTRUI.visibleConv = _conv;
+ let convBinding;
+ for (let element of this.globalDoc.getElementById("conversationsBox")
+ .children) {
+ if (!element.hidden) {
+ convBinding = element;
+ break;
+ }
+ }
+ if (convBinding && convBinding._conv && convBinding._conv.target) {
+ OTRUI.setMsgState(_conv, null, this.globalDoc, false);
+ } else {
+ this.hideOTRButton();
+ }
+ },
+
+ /**
+ * Set encryption state on selector for conversation.
+ *
+ * @param {prplIConversation} _conv - Must match the visible conversation.
+ * @param {Context} [context] - The OTR context for the conversation.
+ * @param {DOMDocument} doc
+ * @param {boolean} [addSystemMessage] - If a system message with the conversation security.
+ */
+ setMsgState(_conv, context, doc, addSystemMessage) {
+ if (!this.visibleConv) {
+ return;
+ }
+ if (_conv != null && !(_conv === this.visibleConv)) {
+ return;
+ }
+
+ let otrContainer = doc.querySelector(".encryption-container");
+ let otrButton = doc.querySelector(".encryption-button");
+ if (_conv != null && _conv.isChat) {
+ OTRUI.noOtrPossible(otrContainer, context);
+ return;
+ }
+
+ if (!context && _conv != null) {
+ context = OTR.getContext(_conv);
+ if (!context) {
+ OTRUI.noOtrPossible(otrContainer, null);
+ }
+ }
+
+ try {
+ let uiConv = OTR.getUIConvFromContext(context);
+ if (uiConv != null && !(uiConv === this.visibleConv)) {
+ return;
+ }
+ if (
+ uiConv.encryptionState === Ci.prplIConversation.ENCRYPTION_ENABLED ||
+ uiConv.encryptionState === Ci.prplIConversation.ENCRYPTION_TRUSTED
+ ) {
+ return;
+ }
+
+ if (uiConv.isChat) {
+ OTRUI.noOtrPossible(otrContainer, context);
+ return;
+ }
+ if (addSystemMessage) {
+ let trust = OTRUI.getTrustSettings(context);
+ let id = "state-" + trust.class;
+ let msg;
+ if (OTR.trust(context) == OTR.trustState.TRUST_NOT_PRIVATE) {
+ msg = lazy.l10n.formatValueSync(id);
+ } else {
+ msg = lazy.l10n.formatValueSync(id, { name: context.username });
+ }
+ uiConv.systemMessage(msg, false, true);
+ }
+ } catch (e) {
+ OTRUI.noOtrPossible(otrContainer, context);
+ return;
+ }
+
+ otrContainer.hidden = false;
+ let otrStart = doc.querySelector(".otr-start");
+ let otrEnd = doc.querySelector(".otr-end");
+ let otrAuth = doc.querySelector(".otr-auth");
+ let trust = OTRUI.getTrustSettings(context);
+ otrButton.setAttribute(
+ "tooltiptext",
+ _strArgs("state-" + trust.class, { name: context.username })
+ );
+ otrButton.setAttribute("label", _str("state-" + trust.class + "-label"));
+ otrButton.className = "encryption-button encryption-" + trust.class;
+ otrStart.setAttribute("label", trust.startLabel);
+ otrStart.setAttribute("disabled", trust.disableStart);
+ otrEnd.setAttribute("disabled", trust.disableEnd);
+ otrAuth.setAttribute("label", trust.authLabel);
+ otrAuth.setAttribute("disabled", trust.disableAuth);
+ OTRUI.hideAllOTRNotifications();
+ OTRUI.showUserNotifications(context);
+ },
+
+ alertTrust(context) {
+ let uiConv = OTR.getUIConvFromContext(context);
+ let trust = OTRUI.getTrustSettings(context);
+ uiConv.systemMessage(
+ _strArgs("afterauth-" + trust.class, { name: context.username }),
+ false,
+ true
+ );
+ },
+
+ getTrustSettings(context) {
+ let result = trustMap.get(OTR.trust(context));
+ return result;
+ },
+
+ askAuth(aObject) {
+ let uiConv = OTR.getUIConvFromContext(aObject.context);
+ if (!uiConv) {
+ return;
+ }
+
+ let name = uiConv.target.normalizedName;
+ let msg = _strArgs("verify-request", { name });
+ // Trigger the update of the unread message counter.
+ uiConv.notifyVerifyOTR(msg);
+ Services.obs.notifyObservers(uiConv, "new-otr-verification-request");
+
+ let window = this.globalDoc.defaultView;
+ let buttons = [
+ {
+ label: _str("finger-verify"),
+ accessKey: _str("finger-verify-access-key"),
+ callback() {
+ OTRUI.openAuth(window, name, "ask", uiConv, aObject);
+ // prevent closing of notification bar when the button is hit
+ return true;
+ },
+ },
+ {
+ label: _str("finger-ignore"),
+ accessKey: _str("finger-ignore-access-key"),
+ callback() {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ },
+ },
+ ];
+
+ let notification = this.globalBox.appendNotification(
+ `ask-auth-${name}`,
+ {
+ label: msg,
+ priority: this.globalBox.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+
+ notification.removeAttribute("dismissable");
+ },
+
+ closeAskAuthNotification(aObject) {
+ let name = aObject.context.username;
+ let notification = this.globalBox.getNotificationWithValue(
+ `ask-auth-${name}`
+ );
+ if (!notification) {
+ return;
+ }
+
+ this.globalBox.removeNotification(notification);
+ },
+
+ closeUnverified(context) {
+ let uiConv = OTR.getUIConvFromContext(context);
+ if (!uiConv) {
+ return;
+ }
+
+ for (let notification of this.globalBox.allNotifications) {
+ if (
+ context.username == notification.getAttribute("user") &&
+ notification.getAttribute("value") == AUTH_STATUS_UNVERIFIED
+ ) {
+ notification.close();
+ }
+ }
+ },
+
+ hideUserNotifications(context) {
+ for (let notification of this.globalBox.allNotifications) {
+ if (context.username == notification.getAttribute("user")) {
+ notification.close();
+ }
+ }
+ },
+
+ hideAllOTRNotifications() {
+ for (let notification of this.globalBox.allNotifications) {
+ if (notification.getAttribute("protocol") == "otr") {
+ notification.setAttribute("hidden", "true");
+ }
+ }
+ },
+
+ showUserNotifications(context) {
+ let name = context.username;
+ for (let notification of this.globalBox.allNotifications) {
+ if (name == notification.getAttribute("user")) {
+ notification.removeAttribute("hidden");
+ }
+ }
+ },
+
+ notifyUnverified(context, seen) {
+ let uiConv = OTR.getUIConvFromContext(context);
+ if (!uiConv) {
+ return;
+ }
+
+ let name = context.username;
+ let window = this.globalDoc.defaultView;
+
+ let buttons = [
+ {
+ label: _str("finger-verify"),
+ accessKey: _str("finger-verify-access-key"),
+ callback() {
+ let name = uiConv.target.normalizedName;
+ OTRUI.openAuth(window, name, "start", uiConv);
+ // prevent closing of notification bar when the button is hit
+ return true;
+ },
+ },
+ {
+ label: _str("finger-ignore"),
+ accessKey: _str("finger-ignore-access-key"),
+ callback() {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ },
+ },
+ ];
+
+ let notification = this.globalBox.appendNotification(
+ name,
+ {
+ label: _strArgs(`finger-${seen}`, { name }),
+ priority: this.globalBox.PRIORITY_WARNING_MEDIUM,
+ },
+ buttons
+ );
+
+ // Set the user attribute so we can show and hide notifications based on the
+ // currently viewed conversation.
+ notification.setAttribute("user", name);
+ // Set custom attributes for CSS styling.
+ notification.setAttribute("protocol", "otr");
+ notification.setAttribute("status", AUTH_STATUS_UNVERIFIED);
+ // Prevent users from dismissing this notification.
+ notification.removeAttribute("dismissable");
+
+ if (!this.visibleConv) {
+ return;
+ }
+
+ if (name !== this.visibleConv.normalizedName) {
+ this.hideUserNotifications(context);
+ }
+ },
+
+ closeVerification(context) {
+ let uiConv = OTR.getUIConvFromContext(context);
+ if (!uiConv) {
+ return;
+ }
+
+ let prevNotification = OTRUI.globalBox.getNotificationWithValue(
+ context.username
+ );
+ if (prevNotification) {
+ prevNotification.close();
+ }
+ },
+
+ notifyVerification(context, key, cancelable, verifiable) {
+ let uiConv = OTR.getUIConvFromContext(context);
+ if (!uiConv) {
+ return;
+ }
+
+ OTRUI.closeVerification(context);
+
+ let buttons = [];
+ if (cancelable) {
+ buttons = [
+ {
+ label: _str("auth-cancel"),
+ accessKey: _str("auth-cancel-access-key"),
+ callback() {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ },
+ },
+ ];
+ }
+
+ if (verifiable) {
+ let window = this.globalDoc.defaultView;
+
+ buttons = [
+ {
+ label: _str("finger-verify"),
+ accessKey: _str("finger-verify-access-key"),
+ callback() {
+ let name = uiConv.target.normalizedName;
+ OTRUI.openAuth(window, name, "start", uiConv);
+ // prevent closing of notification bar when the button is hit
+ return true;
+ },
+ },
+ {
+ label: _str("finger-ignore"),
+ accessKey: _str("finger-ignore-access-key"),
+ callback() {
+ let context = OTR.getContext(uiConv.target);
+ OTR.abortSMP(context);
+ },
+ },
+ ];
+ }
+
+ // Change priority type based on the passed key.
+ let priority = this.globalBox.PRIORITY_WARNING_HIGH;
+ let dismissable = true;
+ switch (key) {
+ case "otr:auth-error":
+ case "otr:auth-fail":
+ priority = this.globalBox.PRIORITY_CRITICAL_HIGH;
+ break;
+ case "otr:auth-waiting":
+ priority = this.globalBox.PRIORITY_INFO_MEDIUM;
+ dismissable = false;
+ break;
+
+ default:
+ break;
+ }
+
+ OTRUI.closeUnverified(context);
+ let notification = this.globalBox.appendNotification(
+ context.username,
+ {
+ label: authLabelMap.get(key),
+ priority,
+ },
+ buttons
+ );
+
+ // Set the user attribute so we can show and hide notifications based on the
+ // currently viewed conversation.
+ notification.setAttribute("user", context.username);
+ // Set custom attributes for CSS styling.
+ notification.setAttribute("protocol", "otr");
+ notification.setAttribute("status", key);
+
+ // The notification API don't currently support a "success" PRIORITY flag,
+ // so we need to manually set it if we need to.
+ if (["otr:auth-success", "otr:auth-success-them"].includes(key)) {
+ notification.setAttribute("type", "success");
+ }
+
+ if (!dismissable) {
+ // Prevent users from dismissing this notification if something is in
+ // progress or an action is required.
+ notification.removeAttribute("dismissable");
+ }
+ },
+
+ updateAuth(aObj) {
+ // let uiConv = OTR.getUIConvFromContext(aObj.context);
+ if (!aObj.progress) {
+ OTRUI.closeAuth(aObj.context);
+ OTRUI.notifyVerification(aObj.context, "otr:auth-error", false, false);
+ } else if (aObj.progress === 100) {
+ let key;
+ let verifiable = false;
+ if (aObj.success) {
+ if (aObj.context.trust) {
+ key = "otr:auth-success";
+ OTR.notifyTrust(aObj.context);
+ } else {
+ key = "otr:auth-success-them";
+ verifiable = true;
+ }
+ } else {
+ key = "otr:auth-fail";
+ if (!aObj.context.trust) {
+ OTR.notifyTrust(aObj.context);
+ }
+ }
+ OTRUI.notifyVerification(aObj.context, key, false, verifiable);
+ } else {
+ // TODO: show the aObj.progress to the user with a
+ // <progressmeter mode="determined" value="10" />
+ OTRUI.notifyVerification(aObj.context, "otr:auth-waiting", true, false);
+ }
+ OTRUI.closeAskAuthNotification(aObj);
+ },
+
+ onAccountCreated(acc) {
+ let account = acc.normalizedName;
+ let protocol = acc.protocol.normalizedName;
+ Promise.resolve();
+ if (OTR.privateKeyFingerprint(account, protocol) === null) {
+ OTR.generatePrivateKey(account, protocol).catch(
+ OTRUI.reportKeyGenFailure
+ );
+ }
+ },
+
+ contactWrapper(contact) {
+ // If the conversation already started.
+ if (contact.buddy) {
+ return {
+ account: contact.buddy.normalizedName,
+ protocol: contact.buddy.buddy.protocol.normalizedName,
+ screenname: contact.buddy.userName,
+ };
+ }
+
+ // For online and offline contacts without an open conversation.
+ return {
+ account:
+ contact.preferredBuddy.preferredAccountBuddy.account.normalizedName,
+ protocol: contact.preferredBuddy.protocol.normalizedName,
+ screenname: contact.preferredBuddy.preferredAccountBuddy.userName,
+ };
+ },
+
+ onContactAdded(contact) {
+ let args = OTRUI.contactWrapper(contact);
+ if (
+ OTR.getFingerprintsForRecipient(
+ args.account,
+ args.protocol,
+ args.screenname
+ ).length > 0
+ ) {
+ return;
+ }
+ args.wrappedJSObject = args;
+ let features = "chrome,modal,centerscreen,resizable=no,minimizable=no";
+ Services.ww.openWindow(null, OTR_ADD_FINGER_DIALOG_URL, "", features, args);
+ },
+
+ observe(aObject, aTopic, aMsg) {
+ let doc;
+ // console.log("====> observing topic: " + aTopic + " with msg: " + aMsg);
+ // console.log(aObject);
+
+ switch (aTopic) {
+ case "nsPref:changed":
+ break;
+ case "conversation-loaded":
+ doc = aObject.ownerDocument;
+ let windowtype = doc.documentElement.getAttribute("windowtype");
+ if (windowtype !== "mail:3pane") {
+ return;
+ }
+ OTRUI.addButton(aObject);
+ break;
+ case "conversation-closed":
+ if (aObject.isChat) {
+ return;
+ }
+ this.globalBox.removeAllNotifications();
+ OTRUI.closeAuth(OTR.getContext(aObject));
+ OTRUI.disconnect(aObject);
+ break;
+ // case "contact-signed-off":
+ // break;
+ case "prpl-quit":
+ OTRUI.disconnect(null);
+ break;
+ case "domwindowopened":
+ OTRUI.addMenus(aObject);
+ break;
+ case "otr:generate": {
+ let result = OTR.generatePrivateKeySync(
+ aObject.account,
+ aObject.protocol
+ );
+ if (result != null) {
+ OTRUI.reportKeyGenFailure(result);
+ }
+ break;
+ }
+ case "otr:disconnected":
+ case "otr:msg-state":
+ if (
+ aTopic === "otr:disconnected" ||
+ OTR.trust(aObject) !== OTR.trustState.TRUST_UNVERIFIED
+ ) {
+ OTRUI.closeAuth(aObject);
+ OTRUI.closeUnverified(aObject);
+ OTRUI.closeVerification(aObject);
+ }
+ OTRUI.setMsgState(null, aObject, this.globalDoc, false);
+ break;
+ case "otr:unverified":
+ if (!this.globalDoc) {
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (!win) {
+ return;
+ }
+ win.focus();
+ win.showChatTab();
+ this.globalDoc = win.document;
+ }
+ OTRUI.notifyUnverified(aObject, aMsg);
+ break;
+ case "otr:trust-state":
+ OTRUI.alertTrust(aObject);
+ break;
+ case "otr:log":
+ OTRUI.logMsg("otr: " + aObject);
+ break;
+ case "account-added":
+ OTRUI.onAccountCreated(aObject);
+ break;
+ case "contact-added":
+ OTRUI.onContactAdded(aObject);
+ break;
+ case "otr:auth-ask":
+ OTRUI.askAuth(aObject);
+ break;
+ case "otr:auth-update":
+ OTRUI.updateAuth(aObject);
+ break;
+ case "otr:cancel-ask-auth":
+ OTRUI.closeAskAuthNotification(aObject);
+ break;
+ }
+ },
+
+ initConv(binding) {
+ OTR.addConversation(binding._conv);
+ OTRUI.addButton(binding);
+ },
+
+ /**
+ * Restore the conversation to a state before OTR knew about it.
+ *
+ * @param {Element} binding - conversation-browser instance.
+ */
+ resetConv(binding) {
+ OTR.removeConversation(binding._conv);
+ },
+
+ destroy() {
+ if (!OTR.libLoaded) {
+ return;
+ }
+ OTRUI.disconnect(null);
+ Services.obs.removeObserver(OTR, "new-ui-conversation");
+ Services.obs.removeObserver(OTR, "conversation-update-type");
+ // Services.obs.removeObserver(OTRUI, "contact-added");
+ // Services.obs.removeObserver(OTRUI, "contact-signed-off");
+ Services.obs.removeObserver(OTRUI, "account-added");
+ Services.obs.removeObserver(OTRUI, "conversation-loaded");
+ Services.obs.removeObserver(OTRUI, "conversation-closed");
+ Services.obs.removeObserver(OTRUI, "prpl-quit");
+
+ for (let conv of IMServices.conversations.getConversations()) {
+ OTRUI.resetConv(conv);
+ }
+ OTR.removeObserver(OTRUI);
+ OTR.close();
+ OTRUI.removeMenuObserver();
+ },
+};
diff --git a/comm/chat/modules/ToLocaleFormat.sys.mjs b/comm/chat/modules/ToLocaleFormat.sys.mjs
new file mode 100644
index 0000000000..256a6fb5f0
--- /dev/null
+++ b/comm/chat/modules/ToLocaleFormat.sys.mjs
@@ -0,0 +1,208 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * JS implementation of the deprecated Date.toLocaleFormat.
+ * aFormat follows strftime syntax,
+ * http://pubs.opengroup.org/onlinepubs/007908799/xsh/strftime.html
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "dateTimeFormatter",
+ () =>
+ new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "full",
+ timeStyle: "long",
+ })
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "dateFormatter",
+ () =>
+ new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "full",
+ })
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "timeFormatter",
+ () =>
+ new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "long",
+ })
+);
+
+function Day(t) {
+ return Math.floor(t.valueOf() / 86400000);
+}
+function DayFromYear(y) {
+ return (
+ 365 * (y - 1970) +
+ Math.floor((y - 1969) / 4) -
+ Math.floor((y - 1901) / 100) +
+ Math.floor((y - 1601) / 400)
+ );
+}
+function DayWithinYear(t) {
+ return Day(t) - DayFromYear(t.getFullYear());
+}
+function weekday(aDate, option) {
+ return aDate.toLocaleString(undefined, { weekday: option });
+}
+function month(aDate, option) {
+ return aDate.toLocaleString(undefined, { month: option });
+}
+function hourMinSecTwoDigits(aDate) {
+ return aDate.toLocaleString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+function dayPeriod(aDate) {
+ let dtf = Intl.DateTimeFormat(undefined, { hour: "2-digit" });
+ let dayPeriodPart =
+ dtf.resolvedOptions().hour12 &&
+ dtf.formatToParts(aDate).find(part => part.type === "dayPeriod");
+ return dayPeriodPart ? dayPeriodPart.value : "";
+}
+function weekNumber(aDate, weekStart) {
+ let day = aDate.getDay();
+ if (weekStart) {
+ day = (day || 7) - weekStart;
+ }
+ return Math.max(Math.floor((DayWithinYear(aDate) + 7 - day) / 7), 0);
+}
+function weekNumberISO(t) {
+ let thisWeek = weekNumber(1, t);
+ let firstDayOfYear = (new Date(t.getFullYear(), 0, 1).getDay() || 7) - 1;
+ if (thisWeek === 0 && firstDayOfYear >= 4) {
+ return weekNumberISO(new Date(t.getFullYear() - 1, 11, 31));
+ }
+ if (t.getMonth() === 11 && t.getDate() - ((t.getDay() || 7) - 1) >= 29) {
+ return 1;
+ }
+ return thisWeek + (firstDayOfYear > 0 && firstDayOfYear < 4);
+}
+function weekYearISO(aDate) {
+ let thisWeek = weekNumber(1, aDate);
+ let firstDayOfYear = (new Date(aDate.getFullYear(), 0, 1).getDay() || 7) - 1;
+ if (thisWeek === 0 && firstDayOfYear >= 4) {
+ return aDate.getFullYear() - 1;
+ }
+ if (
+ aDate.getMonth() === 11 &&
+ aDate.getDate() - ((aDate.getDay() || 7) - 1) >= 29
+ ) {
+ return aDate.getFullYear() + 1;
+ }
+ return aDate.getFullYear();
+}
+function timeZoneOffset(aDate) {
+ let offset = aDate.getTimezoneOffset();
+ let tzoff = Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60);
+ return (offset < 0 ? "+" : "-") + String(tzoff).padStart(4, "0");
+}
+function timeZone(aDate) {
+ let dtf = Intl.DateTimeFormat(undefined, { timeZoneName: "short" });
+ let timeZoneNamePart = dtf
+ .formatToParts(aDate)
+ .find(part => part.type === "timeZoneName");
+ return timeZoneNamePart ? timeZoneNamePart.value : "";
+}
+
+const formatFunctions = {
+ a: aDate => weekday(aDate, "short"),
+ A: aDate => weekday(aDate, "long"),
+ b: aDate => month(aDate, "short"),
+ B: aDate => month(aDate, "long"),
+ c: aDate => lazy.dateTimeFormatter.format(aDate),
+ C: aDate => String(Math.trunc(aDate.getFullYear() / 100)),
+ d: aDate => String(aDate.getDate()),
+ D: aDate => ToLocaleFormat("%m/%d/%y", aDate),
+ e: aDate => String(aDate.getDate()),
+ F: aDate => ToLocaleFormat("%Y-%m-%d", aDate),
+ g: aDate => String(weekYearISO(aDate) % 100),
+ G: aDate => String(weekYearISO(aDate)),
+ h: aDate => month(aDate, "short"),
+ H: aDate => String(aDate.getHours()),
+ I: aDate => String(aDate.getHours() % 12 || 12),
+ j: aDate => String(DayWithinYear(aDate) + 1),
+ k: aDate => String(aDate.getHours()),
+ l: aDate => String(aDate.getHours() % 12 || 12),
+ m: aDate => String(aDate.getMonth() + 1),
+ M: aDate => String(aDate.getMinutes()),
+ n: () => "\n",
+ p: aDate => dayPeriod(aDate).toLocaleUpperCase(),
+ P: aDate => dayPeriod(aDate).toLocaleLowerCase(),
+ r: aDate => hourMinSecTwoDigits(aDate),
+ R: aDate => ToLocaleFormat("%H:%M", aDate),
+ s: aDate => String(Math.trunc(aDate.getTime() / 1000)),
+ S: aDate => String(aDate.getSeconds()),
+ t: () => "\t",
+ T: aDate => ToLocaleFormat("%H:%M:%S", aDate),
+ u: aDate => String(aDate.getDay() || 7),
+ U: aDate => String(weekNumber(aDate, 0)),
+ V: aDate => String(weekNumberISO(aDate)),
+ w: aDate => String(aDate.getDay()),
+ W: aDate => String(weekNumber(aDate, 1)),
+ x: aDate => lazy.dateFormatter.format(aDate),
+ X: aDate => lazy.timeFormatter.format(aDate),
+ y: aDate => String(aDate.getFullYear() % 100),
+ Y: aDate => String(aDate.getFullYear()),
+ z: aDate => timeZoneOffset(aDate),
+ Z: aDate => timeZone(aDate),
+ "%": () => "%",
+};
+const padding = {
+ C: { fill: "0", width: 2 },
+ d: { fill: "0", width: 2 },
+ e: { fill: " ", width: 2 },
+ g: { fill: "0", width: 2 },
+ H: { fill: "0", width: 2 },
+ I: { fill: "0", width: 2 },
+ j: { fill: "0", width: 3 },
+ k: { fill: " ", width: 2 },
+ l: { fill: " ", width: 2 },
+ m: { fill: "0", width: 2 },
+ M: { fill: "0", width: 2 },
+ S: { fill: "0", width: 2 },
+ U: { fill: "0", width: 2 },
+ V: { fill: "0", width: 2 },
+ W: { fill: "0", width: 2 },
+ y: { fill: "0", width: 2 },
+};
+
+export function ToLocaleFormat(aFormat, aDate) {
+ // Modified conversion specifiers E and O are ignored.
+ let specifiers = Object.keys(formatFunctions).join("");
+ let pattern = RegExp(`%#?(\\^)?([0_-]\\d*)?(?:[EO])?([${specifiers}])`, "g");
+
+ return aFormat.replace(
+ pattern,
+ (matched, upperCaseFlag, fillWidthFlags, specifier) => {
+ let result = formatFunctions[specifier](aDate);
+ if (upperCaseFlag) {
+ result = result.toLocaleUpperCase();
+ }
+ let fill = specifier in padding ? padding[specifier].fill : "";
+ let width = specifier in padding ? padding[specifier].width : 0;
+ if (fillWidthFlags) {
+ let newFill = fillWidthFlags[0];
+ let newWidth = fillWidthFlags.match(/\d+/);
+ if (newFill === "-" && newWidth === null) {
+ fill = "";
+ } else {
+ fill = newFill === "0" ? "0" : " ";
+ width = newWidth !== null ? Number(newWidth) : width;
+ }
+ }
+ return result.padStart(width, fill);
+ }
+ );
+}
diff --git a/comm/chat/modules/imContentSink.sys.mjs b/comm/chat/modules/imContentSink.sys.mjs
new file mode 100644
index 0000000000..b3ff617048
--- /dev/null
+++ b/comm/chat/modules/imContentSink.sys.mjs
@@ -0,0 +1,495 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var kAllowedURLs = aValue => /^(https?|ftp|mailto|magnet):/.test(aValue);
+var kAllowedMozClasses = aClassName =>
+ aClassName == "moz-txt-underscore" ||
+ aClassName == "moz-txt-tag" ||
+ aClassName == "ib-person";
+var kAllowedAnchorClasses = aClassName => aClassName == "ib-person";
+
+/* Tags whose content should be fully removed, and reported in the Error Console. */
+var kForbiddenTags = {
+ script: true,
+ style: true,
+};
+
+/**
+ * In strict mode, remove all formatting. Keep only links and line breaks.
+ *
+ * @type {CleanRules}
+ */
+var kStrictMode = {
+ attrs: {},
+
+ tags: {
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ br: true,
+ p: true,
+ },
+
+ styles: {},
+};
+
+/**
+ * Standard mode allows basic formattings (bold, italic, underlined).
+ *
+ * @type {CleanRules}
+ */
+var kStandardMode = {
+ attrs: {
+ style: true,
+ },
+
+ tags: {
+ div: true,
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ em: true,
+ strong: true,
+ b: true,
+ i: true,
+ u: true,
+ s: true,
+ span: {
+ class: kAllowedMozClasses,
+ },
+ br: true,
+ code: true,
+ ul: true,
+ li: true,
+ ol: {
+ start: true,
+ },
+ cite: true,
+ blockquote: true,
+ p: true,
+ del: true,
+ strike: true,
+ ins: true,
+ sub: true,
+ sup: true,
+ pre: true,
+ table: true,
+ thead: true,
+ tbody: true,
+ tr: true,
+ th: true,
+ td: true,
+ caption: true,
+ details: true,
+ summary: true,
+ },
+
+ styles: {
+ "font-style": true,
+ "font-weight": true,
+ "text-decoration-line": true,
+ },
+};
+
+/**
+ * Permissive mode allows just about anything that isn't going to mess up the chat window.
+ * In comparison to normal mode this primarily means elements that can vary font sizes and
+ * colors.
+ *
+ * @type {CleanRules}
+ */
+var kPermissiveMode = {
+ attrs: {
+ style: true,
+ },
+
+ tags: {
+ div: true,
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ font: {
+ face: true,
+ color: true,
+ size: true,
+ },
+ em: true,
+ strong: true,
+ b: true,
+ i: true,
+ u: true,
+ s: true,
+ span: {
+ class: kAllowedMozClasses,
+ },
+ br: true,
+ hr: true,
+ code: true,
+ ul: true,
+ li: true,
+ ol: {
+ start: true,
+ },
+ cite: true,
+ blockquote: true,
+ p: true,
+ del: true,
+ strike: true,
+ ins: true,
+ sub: true,
+ sup: true,
+ pre: true,
+ table: true,
+ thead: true,
+ tbody: true,
+ tr: true,
+ th: true,
+ td: true,
+ caption: true,
+ details: true,
+ summary: true,
+ h1: true,
+ h2: true,
+ h3: true,
+ h4: true,
+ h5: true,
+ h6: true,
+ },
+
+ // FIXME: should be possible to use functions to filter values
+ styles: {
+ color: true,
+ font: true,
+ "font-family": true,
+ "font-size": true,
+ "font-style": true,
+ "font-weight": true,
+ "text-decoration-color": true,
+ "text-decoration-style": true,
+ "text-decoration-line": true,
+ },
+};
+
+var kModePref = "messenger.options.filterMode";
+var kModes = [kStrictMode, kStandardMode, kPermissiveMode];
+
+var gGlobalRuleset = null;
+
+function initGlobalRuleset() {
+ gGlobalRuleset = newRuleset();
+
+ Services.prefs.addObserver(kModePref, styleObserver);
+}
+
+var styleObserver = {
+ observe(aObject, aTopic, aMsg) {
+ if (aTopic != "nsPref:changed" || aMsg != kModePref) {
+ throw new Error("bad notification");
+ }
+
+ if (!gGlobalRuleset) {
+ throw new Error("gGlobalRuleset not initialized");
+ }
+
+ setBaseRuleset(getModePref(), gGlobalRuleset);
+ },
+};
+
+function getModePref() {
+ let baseNum = Services.prefs.getIntPref(kModePref);
+ if (baseNum < 0 || baseNum > 2) {
+ baseNum = 1;
+ }
+
+ return kModes[baseNum];
+}
+
+function setBaseRuleset(aBase, aResult) {
+ for (let property in aBase) {
+ aResult[property] = Object.create(aBase[property], aResult[property]);
+ }
+}
+
+function newRuleset(aBase) {
+ let result = {
+ tags: {},
+ attrs: {},
+ styles: {},
+ };
+ setBaseRuleset(aBase || getModePref(), result);
+ return result;
+}
+
+export function createDerivedRuleset() {
+ if (!gGlobalRuleset) {
+ initGlobalRuleset();
+ }
+ return newRuleset(gGlobalRuleset);
+}
+
+export function addGlobalAllowedTag(aTag, aAttrs = true) {
+ gGlobalRuleset.tags[aTag] = aAttrs;
+}
+
+export function removeGlobalAllowedTag(aTag) {
+ delete gGlobalRuleset.tags[aTag];
+}
+
+export function addGlobalAllowedAttribute(aAttr, aRule = true) {
+ gGlobalRuleset.attrs[aAttr] = aRule;
+}
+
+export function removeGlobalAllowedAttribute(aAttr) {
+ delete gGlobalRuleset.attrs[aAttr];
+}
+
+export function addGlobalAllowedStyleRule(aStyle, aRule = true) {
+ gGlobalRuleset.styles[aStyle] = aRule;
+}
+
+export function removeGlobalAllowedStyleRule(aStyle) {
+ delete gGlobalRuleset.styles[aStyle];
+}
+
+/**
+ * A dynamic rule which decides if an attribute is allowed based on the
+ * attribute's value.
+ *
+ * @callback ValueRule
+ * @param {string} value - The attribute value.
+ * @returns {bool} - True if the attribute should be allowed.
+ *
+ * @example
+ *
+ * aValue => aValue == 'about:blank'
+ */
+
+/**
+ * An object whose properties are the allowed attributes.
+ *
+ * The value of the property should be true to unconditionally accept the
+ * attribute, or a function which accepts the value of the attribute and
+ * returns a boolean of whether the attribute should be accepted or not.
+ *
+ * @typedef Ruleset
+ * @type {Object<string, (boolean | ValueRule)>}}
+ */
+
+/**
+ * A set of rules for which tags, attributes, and styles should be allowed when
+ * rendering HTML.
+ *
+ * See kStrictMode, kStandardMode, kPermissiveMode for examples of Rulesets.
+ *
+ * @typedef CleanRules
+ * @type {object}
+ * @property {Ruleset} attrs
+ * An object whose properties are the allowed attributes for any tag.
+ * @property {Object<string, (boolean|Ruleset)>} tags
+ * An object whose properties are the allowed tags.
+ *
+ * The value can point to a {@link Ruleset} for that tag which augments the
+ * ones provided by attrs. If either of the {@link Ruleset}s from attrs or
+ * tags allows an attribute, then it is accepted.
+ * @property {Object<string, boolean>} styles
+ * An object whose properties are the allowed CSS style rules.
+ *
+ * The value of each property is unused.
+ *
+ * FIXME: make styles accept functions to filter the CSS values like Ruleset.
+ *
+ * @example
+ *
+ * {
+ * attrs: { 'style': true },
+ * tags: {
+ * a: { 'href': true },
+ * },
+ * styles: {
+ * 'font-size': true
+ * }
+ * }
+ */
+
+/**
+ * A function to modify text nodes.
+ *
+ * @callback TextModifier
+ * @param {Node} - The text node to modify.
+ * @returns {int} - The number of nodes added.
+ *
+ * -1 if the current textnode was deleted
+ * 0 if the node count is unchanged
+ * positive value if nodes were added.
+ *
+ * For instance, adding an <img> tag for a smiley adds 2 nodes:
+ * the img tag
+ * the new text node after the img tag.
+ */
+
+/**
+ * Removes nodes, attributes and styles that are not allowed according to the
+ * given rules.
+ *
+ * @param {Node} aNode
+ * A DOM node to inspect recursively against the rules.
+ * @param {CleanRules} aRules
+ * The rules for what tags, attributes, and styles are allowed.
+ * @param {TextModifier[]} aTextModifiers
+ * A list of functions to modify text content.
+ */
+function cleanupNode(aNode, aRules, aTextModifiers) {
+ // Iterate each node and apply rules for what content is allowed. This has two
+ // modes: one for element nodes and one for text nodes.
+ for (let i = 0; i < aNode.childNodes.length; ++i) {
+ let node = aNode.childNodes[i];
+ if (
+ node.nodeType == node.ELEMENT_NODE &&
+ node.namespaceURI == "http://www.w3.org/1999/xhtml"
+ ) {
+ // If the node is an element, check if the node is an allowed tag.
+ let nodeName = node.localName;
+ if (!(nodeName in aRules.tags)) {
+ // If the node is not allowed, either remove it completely (if
+ // it is forbidden) or replace it with its children.
+ if (nodeName in kForbiddenTags) {
+ console.error(
+ "removing a " + nodeName + " tag from a message before display"
+ );
+ } else {
+ while (node.hasChildNodes()) {
+ aNode.insertBefore(node.firstChild, node);
+ }
+ }
+ aNode.removeChild(node);
+ // We want to process again the node at the index i which is
+ // now the first child of the node we removed
+ --i;
+ continue;
+ }
+
+ // This node is being kept, cleanup each child node.
+ cleanupNode(node, aRules, aTextModifiers);
+
+ // Cleanup the attributes of this node.
+ let attrs = node.attributes;
+ let acceptFunction = function (aAttrRules, aAttr) {
+ // An attribute is always accepted if its rule is true, or conditionally
+ // accepted if its rule is a function that evaluates to true.
+ // If its rule does not exist, it is removed.
+ let localName = aAttr.localName;
+ let rule = localName in aAttrRules && aAttrRules[localName];
+ return (
+ rule === true || (typeof rule == "function" && rule(aAttr.value))
+ );
+ };
+ for (let j = 0; j < attrs.length; ++j) {
+ let attr = attrs[j];
+ // If either the attribute is accepted for all tags or for this specific
+ // tag then it is allowed.
+ if (
+ !(
+ acceptFunction(aRules.attrs, attr) ||
+ (typeof aRules.tags[nodeName] == "object" &&
+ acceptFunction(aRules.tags[nodeName], attr))
+ )
+ ) {
+ node.removeAttribute(attr.name);
+ --j;
+ }
+ }
+
+ // Cleanup the style attribute.
+ let style = node.style;
+ for (let j = 0; j < style.length; ++j) {
+ if (!(style[j] in aRules.styles)) {
+ style.removeProperty(style[j]);
+ --j;
+ }
+ }
+
+ // If the style attribute is now empty or if it contained unsupported or
+ // unparsable CSS it should be dropped completely.
+ if (!style.length) {
+ node.removeAttribute("style");
+ }
+
+ // Sort the style attributes for easier checking/comparing later.
+ if (node.hasAttribute("style")) {
+ let trailingSemi = false;
+ let attrs = node.getAttribute("style").trim();
+ if (attrs.endsWith(";")) {
+ attrs = attrs.slice(0, -1);
+ trailingSemi = true;
+ }
+ attrs = attrs.split(";").map(a => a.trim());
+ attrs.sort();
+ node.setAttribute(
+ "style",
+ attrs.join("; ") + (trailingSemi ? ";" : "")
+ );
+ }
+ } else {
+ // We are on a text node, we need to apply the functions
+ // provided in the aTextModifiers array.
+
+ // Each of these function should return the number of nodes added:
+ // * -1 if the current textnode was deleted
+ // * 0 if the node count is unchanged
+ // * positive value if nodes were added.
+ // For instance, adding an <img> tag for a smiley adds 2 nodes:
+ // - the img tag
+ // - the new text node after the img tag.
+
+ // This is the number of nodes we need to process. If new nodes
+ // are created, the next text modifier functions have more nodes
+ // to process.
+ let textNodeCount = 1;
+ for (let modifier of aTextModifiers) {
+ for (let n = 0; n < textNodeCount; ++n) {
+ let textNode = aNode.childNodes[i + n];
+
+ // If we are processing nodes created by one of the previous
+ // text modifier function, some of the nodes are likely not
+ // text node, skip them.
+ if (
+ textNode.nodeType != textNode.TEXT_NODE &&
+ textNode.nodeType != textNode.CDATA_SECTION_NODE
+ ) {
+ continue;
+ }
+
+ let result = modifier(textNode);
+ textNodeCount += result;
+ n += result;
+ }
+ }
+
+ // newly created nodes should not be filtered, be sure we skip them!
+ i += textNodeCount - 1;
+ }
+ }
+}
+
+export function cleanupImMarkup(aText, aRuleset, aTextModifiers = []) {
+ if (!gGlobalRuleset) {
+ initGlobalRuleset();
+ }
+
+ let parser = new DOMParser();
+ // Wrap the text to be parsed in a <span> to avoid losing leading whitespace.
+ let doc = parser.parseFromString(
+ "<!DOCTYPE html><html><body><span>" + aText + "</span></body></html>",
+ "text/html"
+ );
+ let span = doc.querySelector("span");
+ cleanupNode(span, aRuleset || gGlobalRuleset, aTextModifiers);
+ return span.innerHTML;
+}
diff --git a/comm/chat/modules/imSmileys.sys.mjs b/comm/chat/modules/imSmileys.sys.mjs
new file mode 100644
index 0000000000..1658033786
--- /dev/null
+++ b/comm/chat/modules/imSmileys.sys.mjs
@@ -0,0 +1,184 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Used to add smileys to the content of a textnode. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gTextDecoder", () => {
+ return new TextDecoder();
+});
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+var kEmoticonsThemePref = "messenger.options.emoticonsTheme";
+var kThemeFile = "theme.json";
+
+Object.defineProperty(lazy, "gTheme", {
+ configurable: true,
+ enumerable: true,
+
+ get() {
+ delete this.gTheme;
+ gPrefObserver.init();
+ return (this.gTheme = getTheme());
+ },
+});
+
+var gPrefObserver = {
+ init() {
+ Services.prefs.addObserver(kEmoticonsThemePref, gPrefObserver);
+ },
+
+ observe(aObject, aTopic, aMsg) {
+ if (aTopic != "nsPref:changed" || aMsg != kEmoticonsThemePref) {
+ throw new Error("bad notification");
+ }
+
+ lazy.gTheme = getTheme();
+ },
+};
+
+function getTheme(aName) {
+ let name = aName || Services.prefs.getCharPref(kEmoticonsThemePref);
+
+ let theme = {
+ name,
+ iconsHash: null,
+ json: null,
+ regExp: null,
+ };
+
+ if (name == "none") {
+ return theme;
+ }
+
+ if (name == "default") {
+ theme.baseUri = "chrome://instantbird-emoticons/skin/";
+ } else {
+ theme.baseUri = "chrome://" + theme.name + "/skin/";
+ }
+ try {
+ let channel = Services.io.newChannel(
+ theme.baseUri + kThemeFile,
+ null,
+ null,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_IMAGE
+ );
+ let stream = channel.open();
+ let bytes = lazy.NetUtil.readInputStream(stream, stream.available());
+ theme.json = JSON.parse(lazy.gTextDecoder.decode(bytes));
+ stream.close();
+ theme.iconsHash = {};
+ for (let smiley of theme.json.smileys) {
+ for (let textCode of smiley.textCodes) {
+ theme.iconsHash[textCode] = smiley;
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ return theme;
+}
+
+function getRegexp() {
+ if (lazy.gTheme.regExp) {
+ lazy.gTheme.regExp.lastIndex = 0;
+ return lazy.gTheme.regExp;
+ }
+
+ // return null if smileys are disabled
+ if (!lazy.gTheme.iconsHash) {
+ return null;
+ }
+
+ if ("" in lazy.gTheme.iconsHash) {
+ console.error(
+ "Emoticon " +
+ lazy.gTheme.iconsHash[""].filename +
+ " matches the empty string!"
+ );
+ delete lazy.gTheme.iconsHash[""];
+ }
+
+ let emoticonList = [];
+ for (let emoticon in lazy.gTheme.iconsHash) {
+ emoticonList.push(emoticon);
+ }
+
+ let exp = /[[\]{}()*+?.\\^$|]/g;
+ emoticonList = emoticonList
+ .sort()
+ .reverse()
+ .map(x => x.replace(exp, "\\$&"));
+
+ if (!emoticonList.length) {
+ // the theme contains no valid emoticon, make sure we will return
+ // early next time
+ lazy.gTheme.iconsHash = null;
+ return null;
+ }
+
+ lazy.gTheme.regExp = new RegExp(emoticonList.join("|"), "g");
+ return lazy.gTheme.regExp;
+}
+
+export function smileTextNode(aNode) {
+ /*
+ * Skip text nodes that contain the href in the child text node.
+ * We must check both the testNode.textContent and the aNode.data since they
+ * cover different cases:
+ * textContent: The URL is split over multiple nodes for some reason
+ * data: The URL is not the only content in the link, skip only the one node
+ * Check the class name to skip any autolinked nodes from mozTXTToHTMLConv.
+ */
+ let testNode = aNode;
+ while ((testNode = testNode.parentNode)) {
+ if (
+ testNode.nodeName.toLowerCase() == "a" &&
+ (testNode.getAttribute("href") == testNode.textContent.trim() ||
+ testNode.getAttribute("href") == aNode.data.trim() ||
+ testNode.className.includes("moz-txt-link-"))
+ ) {
+ return 0;
+ }
+ }
+
+ let result = 0;
+ let exp = getRegexp();
+ if (!exp) {
+ return result;
+ }
+
+ let match;
+ while ((match = exp.exec(aNode.data))) {
+ let smileNode = aNode.splitText(match.index);
+ aNode = smileNode.splitText(exp.lastIndex - match.index);
+ // at this point, smileNode is a text node with only the text
+ // of the smiley and aNode is a text node with the text after
+ // the smiley. The text in aNode hasn't been processed yet.
+ let smile = smileNode.data;
+ let elt = aNode.ownerDocument.createElement("span");
+ elt.appendChild(
+ aNode.ownerDocument.createTextNode(lazy.gTheme.iconsHash[smile].glyph)
+ );
+ // Add the title attribute (to show the original text in a tooltip) in case
+ // the replacement was done incorrectly.
+ elt.setAttribute("title", smile);
+ smileNode.parentNode.replaceChild(elt, smileNode);
+ result += 2;
+ exp.lastIndex = 0;
+ }
+ return result;
+}
diff --git a/comm/chat/modules/imStatusUtils.sys.mjs b/comm/chat/modules/imStatusUtils.sys.mjs
new file mode 100644
index 0000000000..58c594b117
--- /dev/null
+++ b/comm/chat/modules/imStatusUtils.sys.mjs
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/status.properties")
+);
+
+var imIStatusInfo = Ci.imIStatusInfo;
+var statusAttributes = {};
+statusAttributes[imIStatusInfo.STATUS_UNKNOWN] = "unknown";
+statusAttributes[imIStatusInfo.STATUS_OFFLINE] = "offline";
+statusAttributes[imIStatusInfo.STATUS_INVISIBLE] = "invisible";
+statusAttributes[imIStatusInfo.STATUS_MOBILE] = "mobile";
+statusAttributes[imIStatusInfo.STATUS_IDLE] = "idle";
+statusAttributes[imIStatusInfo.STATUS_AWAY] = "away";
+statusAttributes[imIStatusInfo.STATUS_UNAVAILABLE] = "unavailable";
+statusAttributes[imIStatusInfo.STATUS_AVAILABLE] = "available";
+
+export var Status = {
+ toAttribute: aStatusType =>
+ aStatusType in statusAttributes ? statusAttributes[aStatusType] : "unknown",
+
+ _labels: {},
+ toLabel(aStatusType, aStatusText) {
+ // aStatusType may be either one of the (integral) imIStatusInfo status
+ // constants, or one of the statusAttributes.
+ if (!(typeof aStatusType == "string")) {
+ aStatusType = this.toAttribute(aStatusType);
+ }
+
+ if (!(aStatusType in this._labels)) {
+ this._labels[aStatusType] = lazy._(aStatusType + "StatusType");
+ }
+
+ let label = this._labels[aStatusType];
+ if (aStatusText) {
+ label = lazy._("statusWithStatusMessage", label, aStatusText);
+ }
+
+ return label;
+ },
+
+ toFlag(aAttribute) {
+ for (let flag in statusAttributes) {
+ if (statusAttributes[flag] == aAttribute) {
+ return flag;
+ }
+ }
+ return imIStatusInfo.STATUS_UNKNOWN;
+ },
+};
diff --git a/comm/chat/modules/imTextboxUtils.sys.mjs b/comm/chat/modules/imTextboxUtils.sys.mjs
new file mode 100644
index 0000000000..979abb6f61
--- /dev/null
+++ b/comm/chat/modules/imTextboxUtils.sys.mjs
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export var TextboxSize = {
+ _textboxAutoResizePrefName: "messenger.conversations.textbox.autoResize",
+ get autoResize() {
+ delete this.autoResize;
+ Services.prefs.addObserver(this._textboxAutoResizePrefName, this);
+ return (this.autoResize = Services.prefs.getBoolPref(
+ this._textboxAutoResizePrefName
+ ));
+ },
+ observe(aSubject, aTopic, aMsg) {
+ if (aTopic == "nsPref:changed" && aMsg == this._textboxAutoResizePrefName) {
+ this.autoResize = Services.prefs.getBoolPref(aMsg);
+ }
+ },
+};
diff --git a/comm/chat/modules/imThemes.sys.mjs b/comm/chat/modules/imThemes.sys.mjs
new file mode 100644
index 0000000000..5b7f0ee824
--- /dev/null
+++ b/comm/chat/modules/imThemes.sys.mjs
@@ -0,0 +1,1333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+);
+
+var kMessagesStylePrefBranch = "messenger.options.messagesStyle.";
+var kThemePref = "theme";
+var kVariantPref = "variant";
+var kCombineConsecutivePref = "combineConsecutive";
+var kCombineConsecutiveIntervalPref = "combineConsecutiveInterval";
+
+var DEFAULT_THEME = "bubbles";
+var DEFAULT_THEMES = ["bubbles", "dark", "mail", "papersheets", "simple"];
+
+var kLineBreak = "@mozilla.org/windows-registry-key;1" in Cc ? "\r\n" : "\n";
+
+XPCOMUtils.defineLazyGetter(lazy, "gPrefBranch", () =>
+ Services.prefs.getBranch(kMessagesStylePrefBranch)
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTXT => cs.scanTXT(aTXT, cs.kEntities);
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gTimeFormatter", () => {
+ return new Services.intl.DateTimeFormat(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs",
+});
+
+var gCurrentTheme = null;
+
+function getChromeFile(aURI) {
+ try {
+ let channel = Services.io.newChannel(
+ aURI,
+ null,
+ null,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let stream = channel.open();
+ let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sstream.init(stream);
+ let text = sstream.read(sstream.available());
+ sstream.close();
+ return text;
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ dump("Getting " + aURI + ": " + e + "\n");
+ }
+ return null;
+ }
+}
+
+function HTMLTheme(aBaseURI) {
+ let files = {
+ footer: "Footer.html",
+ header: "Header.html",
+ status: "Status.html",
+ statusNext: "NextStatus.html",
+ incomingContent: "Incoming/Content.html",
+ incomingContext: "Incoming/Context.html",
+ incomingNextContent: "Incoming/NextContent.html",
+ incomingNextContext: "Incoming/NextContext.html",
+ outgoingContent: "Outgoing/Content.html",
+ outgoingContext: "Outgoing/Context.html",
+ outgoingNextContent: "Outgoing/NextContent.html",
+ outgoingNextContext: "Outgoing/NextContext.html",
+ };
+
+ for (let id in files) {
+ let html = getChromeFile(aBaseURI + files[id]);
+ if (html) {
+ Object.defineProperty(this, id, { value: html });
+ }
+ }
+
+ if (!("incomingContent" in files)) {
+ throw new Error("Invalid theme: Incoming/Content.html is missing!");
+ }
+}
+
+HTMLTheme.prototype = {
+ get footer() {
+ return "";
+ },
+ get header() {
+ return "";
+ },
+ get status() {
+ return this.incomingContent;
+ },
+ get statusNext() {
+ return this.status;
+ },
+ get incomingContent() {
+ throw new Error("Incoming/Content.html is a required file");
+ },
+ get incomingNextContent() {
+ return this.incomingContent;
+ },
+ get outgoingContent() {
+ return this.incomingContent;
+ },
+ get outgoingNextContent() {
+ return this.incomingNextContent;
+ },
+ get incomingContext() {
+ return this.incomingContent;
+ },
+ get incomingNextContext() {
+ return this.incomingNextContent;
+ },
+ get outgoingContext() {
+ return this.hasOwnProperty("outgoingContent")
+ ? this.outgoingContent
+ : this.incomingContext;
+ },
+ get outgoingNextContext() {
+ return this.hasOwnProperty("outgoingNextContent")
+ ? this.outgoingNextContent
+ : this.incomingNextContext;
+ },
+};
+
+function plistToJSON(aElt) {
+ switch (aElt.localName) {
+ case "true":
+ return true;
+ case "false":
+ return false;
+ case "string":
+ case "data":
+ return aElt.textContent;
+ case "real":
+ return parseFloat(aElt.textContent);
+ case "integer":
+ return parseInt(aElt.textContent, 10);
+
+ case "dict":
+ let res = {};
+ let nodes = aElt.children;
+ for (let i = 0; i < nodes.length; ++i) {
+ if (nodes[i].nodeName == "key") {
+ let key = nodes[i].textContent;
+ ++i;
+ while (!Element.isInstance(nodes[i])) {
+ ++i;
+ }
+ res[key] = plistToJSON(nodes[i]);
+ }
+ }
+ return res;
+
+ case "array":
+ let array = [];
+ nodes = aElt.children;
+ for (let i = 0; i < nodes.length; ++i) {
+ if (Element.isInstance(nodes[i])) {
+ array.push(plistToJSON(nodes[i]));
+ }
+ }
+ return array;
+
+ default:
+ throw new Error("Unknown tag in plist file");
+ }
+}
+
+function getInfoPlistContent(aBaseURI) {
+ try {
+ let channel = Services.io.newChannel(
+ aBaseURI + "Info.plist",
+ null,
+ null,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let stream = channel.open();
+ let parser = new DOMParser();
+ let doc = parser.parseFromStream(
+ stream,
+ null,
+ stream.available(),
+ "text/xml"
+ );
+ if (doc.documentElement.localName != "plist") {
+ throw new Error("Invalid Info.plist file");
+ }
+ let node = doc.documentElement.firstElementChild;
+ while (node && !Element.isInstance(node)) {
+ node = node.nextElementSibling;
+ }
+ if (!node || node.localName != "dict") {
+ throw new Error("Empty or invalid Info.plist file");
+ }
+ return plistToJSON(node);
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+}
+
+function getChromeBaseURI(aThemeName) {
+ if (DEFAULT_THEMES.includes(aThemeName)) {
+ return "chrome://messenger-messagestyles/skin/" + aThemeName + "/";
+ }
+ return "chrome://" + aThemeName + "/skin/";
+}
+
+export function getThemeByName(aName) {
+ let baseURI = getChromeBaseURI(aName);
+ let metadata = getInfoPlistContent(baseURI);
+ if (!metadata) {
+ throw new Error("Cannot load theme " + aName);
+ }
+
+ return {
+ name: aName,
+ variant: "default",
+ baseURI,
+ metadata,
+ html: new HTMLTheme(baseURI),
+ combineConsecutive: lazy.gPrefBranch.getBoolPref(kCombineConsecutivePref),
+ combineConsecutiveInterval: lazy.gPrefBranch.getIntPref(
+ kCombineConsecutiveIntervalPref
+ ),
+ };
+}
+
+export function getCurrentTheme() {
+ let name = lazy.gPrefBranch.getCharPref(kThemePref);
+ let variant = lazy.gPrefBranch.getCharPref(kVariantPref);
+ if (
+ gCurrentTheme &&
+ gCurrentTheme.name == name &&
+ gCurrentTheme.variant == variant
+ ) {
+ return gCurrentTheme;
+ }
+
+ try {
+ gCurrentTheme = getThemeByName(name);
+ gCurrentTheme.variant = variant;
+ } catch (e) {
+ console.error(e);
+ gCurrentTheme = getThemeByName(DEFAULT_THEME);
+ gCurrentTheme.variant = "default";
+ }
+
+ return gCurrentTheme;
+}
+
+function getDirectoryEntries(aDir) {
+ let ios = Services.io;
+ let uri = ios.newURI(aDir);
+ let cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIXULChromeRegistry
+ );
+ while (uri.scheme == "chrome") {
+ uri = cr.convertChromeURL(uri);
+ }
+
+ // remove any trailing file name added by convertChromeURL
+ let spec = uri.spec.replace(/[^\/]+$/, "");
+ uri = ios.newURI(spec);
+
+ let results = [];
+ if (uri.scheme == "jar") {
+ uri.QueryInterface(Ci.nsIJARURI);
+ let strEntry = uri.JAREntry;
+ if (!strEntry) {
+ return [];
+ }
+
+ let zr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(
+ Ci.nsIZipReader
+ );
+ let jarFile = uri.JARFile;
+ if (jarFile instanceof Ci.nsIJARURI) {
+ let innerZr = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(
+ Ci.nsIZipReader
+ );
+ innerZr.open(jarFile.JARFile.QueryInterface(Ci.nsIFileURL).file);
+ zr.openInner(innerZr, jarFile.JAREntry);
+ } else {
+ zr.open(jarFile.QueryInterface(Ci.nsIFileURL).file);
+ }
+
+ if (!zr.hasEntry(strEntry) || !zr.getEntry(strEntry).isDirectory) {
+ zr.close();
+ return [];
+ }
+
+ let escapedEntry = strEntry.replace(/([*?$[\]^~()\\])/g, "\\$1");
+ let filter = escapedEntry + "?*~" + escapedEntry + "?*/?*";
+ let entries = zr.findEntries(filter);
+
+ let parentLength = strEntry.length;
+ for (let entry of entries) {
+ results.push(entry.substring(parentLength));
+ }
+ zr.close();
+ } else if (uri.scheme == "file") {
+ uri.QueryInterface(Ci.nsIFileURL);
+ let dir = uri.file;
+
+ if (!dir.exists() || !dir.isDirectory()) {
+ return [];
+ }
+
+ for (let file of dir.directoryEntries) {
+ results.push(file.leafName);
+ }
+ }
+
+ return results;
+}
+
+export function getThemeVariants(aTheme) {
+ let variants = getDirectoryEntries(aTheme.baseURI + "Variants/");
+ return variants
+ .filter(v => v.endsWith(".css"))
+ .map(v => v.substring(0, v.length - 4));
+}
+
+/* helper function for replacements in messages */
+function getBuddyFromMessage(aMsg) {
+ if (aMsg.incoming) {
+ let conv = aMsg.conversation;
+ if (!conv.isChat) {
+ return conv.buddy;
+ }
+ }
+
+ return null;
+}
+
+function getStatusIconFromBuddy(aBuddy) {
+ let status = "unknown";
+ if (aBuddy) {
+ if (!aBuddy.online) {
+ status = "offline";
+ } else if (aBuddy.idle) {
+ status = "idle";
+ } else if (!aBuddy.available) {
+ status = "away";
+ } else {
+ status = "available";
+ }
+ }
+
+ return "chrome://chat/skin/" + status + "-16.png";
+}
+
+var footerReplacements = {
+ chatName: aConv => lazy.TXTToHTML(aConv.title),
+ sourceName: aConv =>
+ lazy.TXTToHTML(aConv.account.alias || aConv.account.name),
+ destinationName: aConv => lazy.TXTToHTML(aConv.name),
+ destinationDisplayName: aConv => lazy.TXTToHTML(aConv.title),
+ incomingIconPath(aConv) {
+ let buddy;
+ return (
+ (!aConv.isChat && (buddy = aConv.buddy) && buddy.buddyIconFilename) ||
+ "incoming_icon.png"
+ );
+ },
+ outgoingIconPath: aConv => "outgoing_icon.png",
+ timeOpened(aConv, aFormat) {
+ let date = new Date(aConv.startDate / 1000);
+ if (aFormat) {
+ return lazy.ToLocaleFormat(aFormat, date);
+ }
+ return lazy.gTimeFormatter.format(date);
+ },
+};
+
+function formatAutoResponce(aTxt) {
+ return Services.strings
+ .createBundle("chrome://chat/locale/conversations.properties")
+ .formatStringFromName("autoReply", [aTxt]);
+}
+
+var statusMessageReplacements = {
+ message: aMsg =>
+ '<span class="ib-msg-txt">' +
+ (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) +
+ "</span>",
+ time(aMsg, aFormat) {
+ let date = new Date(aMsg.time * 1000);
+ if (aFormat) {
+ return lazy.ToLocaleFormat(aFormat, date);
+ }
+ return lazy.gTimeFormatter.format(date);
+ },
+ timestamp: aMsg => aMsg.time,
+ shortTime(aMsg) {
+ return lazy.gTimeFormatter.format(new Date(aMsg.time * 1000));
+ },
+ messageClasses(aMsg) {
+ let msgClass = [];
+
+ if (aMsg.system) {
+ msgClass.push("event");
+ } else {
+ msgClass.push("message");
+
+ if (aMsg.incoming) {
+ msgClass.push("incoming");
+ } else if (aMsg.outgoing) {
+ msgClass.push("outgoing");
+ }
+
+ if (aMsg.action) {
+ msgClass.push("action");
+ }
+
+ if (aMsg.autoResponse) {
+ msgClass.push("autoreply");
+ }
+ }
+
+ if (aMsg.containsNick) {
+ msgClass.push("nick");
+ }
+ if (aMsg.error) {
+ msgClass.push("error");
+ }
+ if (aMsg.delayed) {
+ msgClass.push("delayed");
+ }
+ if (aMsg.notification) {
+ msgClass.push("notification");
+ }
+ if (aMsg.noFormat) {
+ msgClass.push("monospaced");
+ }
+ if (aMsg.noCollapse) {
+ msgClass.push("no-collapse");
+ }
+
+ return msgClass.join(" ");
+ },
+};
+
+function formatSender(aName, isEncrypted = false) {
+ let otr = isEncrypted ? " message-encrypted" : "";
+ return `<span class="ib-sender${otr}">${lazy.TXTToHTML(aName)}</span>`;
+}
+var messageReplacements = {
+ userIconPath(aMsg) {
+ // If the protocol plugin provides an icon for the message, use it.
+ let iconURL = aMsg.iconURL;
+ if (iconURL) {
+ return iconURL;
+ }
+
+ // For outgoing messages, use the current user icon.
+ if (aMsg.outgoing) {
+ iconURL = aMsg.conversation.account.statusInfo.getUserIcon();
+ if (iconURL) {
+ return iconURL.spec;
+ }
+ }
+
+ // Fallback to the theme's default icons.
+ return (aMsg.incoming ? "Incoming" : "Outgoing") + "/buddy_icon.svg";
+ },
+ senderScreenName: aMsg => formatSender(aMsg.who, aMsg.isEncrypted),
+ sender: aMsg => formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted),
+ senderColor: aMsg => aMsg.color,
+ senderStatusIcon: aMsg => getStatusIconFromBuddy(getBuddyFromMessage(aMsg)),
+ messageDirection: aMsg => "ltr",
+ // no theme actually use this, don't bother making sure this is the real
+ // serverside alias
+ senderDisplayName: aMsg =>
+ formatSender(aMsg.alias || aMsg.who, aMsg.isEncrypted),
+ service: aMsg => aMsg.conversation.account.protocol.name,
+ textbackgroundcolor: (aMsg, aFormat) => "transparent", // FIXME?
+ __proto__: statusMessageReplacements,
+};
+
+var statusReplacements = {
+ status: aMsg => "", // FIXME
+ statusIcon(aMsg) {
+ let conv = aMsg.conversation;
+ let buddy = null;
+ if (!conv.isChat) {
+ buddy = conv.buddy;
+ }
+ return getStatusIconFromBuddy(buddy);
+ },
+ __proto__: statusMessageReplacements,
+};
+
+var kReplacementRegExp = /%([a-zA-Z]*)(\{([^\}]*)\})?%/g;
+
+function replaceKeywordsInHTML(aHTML, aReplacements, aReplacementArg) {
+ kReplacementRegExp.lastIndex = 0;
+ let previousIndex = 0;
+ let result = "";
+ let match;
+ while ((match = kReplacementRegExp.exec(aHTML))) {
+ let content = "";
+ if (match[1] in aReplacements) {
+ content = aReplacements[match[1]](aReplacementArg, match[3]);
+ } else {
+ console.error(
+ "Unknown replacement string %" + match[1] + "% in message styles."
+ );
+ }
+ result += aHTML.substring(previousIndex, match.index) + content;
+ previousIndex = kReplacementRegExp.lastIndex;
+ }
+
+ return result + aHTML.slice(previousIndex);
+}
+
+/**
+ * Determine if a message should be grouped with a previous message.
+ *
+ * @param {object} aTheme - The theme the messages will be displayed in.
+ * @param {imIMessage} aMsg - The message that is about to be appended.
+ * @param {imIMessage} aPreviousMsg - The last message that was displayed.
+ * @returns {boolean} If the message should be grouped with the previous one.
+ */
+export function isNextMessage(aTheme, aMsg, aPreviousMsg) {
+ if (
+ !aTheme.combineConsecutive ||
+ (hasMetadataKey(aTheme, "DisableCombineConsecutive") &&
+ getMetadata(aTheme, "DisableCombineConsecutive"))
+ ) {
+ return false;
+ }
+
+ if (!aPreviousMsg) {
+ return false;
+ }
+
+ if (aMsg.system && aPreviousMsg.system) {
+ return true;
+ }
+
+ if (
+ aMsg.who != aPreviousMsg.who ||
+ aMsg.outgoing != aPreviousMsg.outgoing ||
+ aMsg.incoming != aPreviousMsg.incoming ||
+ aMsg.system != aPreviousMsg.system
+ ) {
+ return false;
+ }
+
+ let timeDifference = aMsg.time - aPreviousMsg.time;
+ return (
+ timeDifference >= 0 && timeDifference <= aTheme.combineConsecutiveInterval
+ );
+}
+
+/**
+ * Determine whether the message was a next message when it was initially
+ * inserted.
+ *
+ * @param {imIMessage} msg
+ * @param {DOMDocument} doc
+ * @returns {boolean} If the message is a next message. Returns false if the
+ * message doesn't already exist in the conversation.
+ */
+export function wasNextMessage(msg, doc) {
+ return Boolean(
+ doc.querySelector(`#Chat [data-remote-id="${CSS.escape(msg.remoteId)}"]`)
+ ?.dataset.isNext
+ );
+}
+
+/**
+ * Create an HTML string to insert the message into the conversation.
+ *
+ * @param {imIMessage} aMsg
+ * @param {object} aTheme
+ * @param {boolean} aIsNext - If this message is immediately following a
+ * message of the same origin. Used for visual grouping.
+ * @param {boolean} aIsContext - If this message was already read by the user
+ * previously and just provided for context.
+ * @returns {string} Raw HTML for the message.
+ */
+export function getHTMLForMessage(aMsg, aTheme, aIsNext, aIsContext) {
+ let html, replacements;
+ if (aMsg.system) {
+ html = aIsNext ? aTheme.html.statusNext : aTheme.html.status;
+ replacements = statusReplacements;
+ } else {
+ html = aMsg.incoming ? "incoming" : "outgoing";
+ if (aIsNext) {
+ html += "Next";
+ }
+ html += aIsContext ? "Context" : "Content";
+ html = aTheme.html[html];
+ replacements = messageReplacements;
+ if (aMsg.action) {
+ let actionMessageTemplate = "* %message% *";
+ if (hasMetadataKey(aTheme, "ActionMessageTemplate")) {
+ actionMessageTemplate = getMetadata(aTheme, "ActionMessageTemplate");
+ }
+ html = html.replace(/%message%/g, actionMessageTemplate);
+ }
+ }
+
+ return replaceKeywordsInHTML(html, replacements, aMsg);
+}
+
+/**
+ *
+ * @param {imIMessage} aMsg
+ * @param {string} aHTML
+ * @param {DOMDocument} aDoc
+ * @param {boolean} aIsNext
+ * @returns {Element}
+ */
+export function insertHTMLForMessage(aMsg, aHTML, aDoc, aIsNext) {
+ let insert = aDoc.getElementById("insert");
+ if (insert && !aIsNext) {
+ insert.remove();
+ insert = null;
+ }
+
+ let parent = insert ? insert.parentNode : aDoc.getElementById("Chat");
+ let documentFragment = getDocumentFragmentFromHTML(aDoc, aHTML);
+
+ // If the parent already has a remote ID, we remove it, since it now contains
+ // multiple different messages.
+ if (parent.dataset.remoteId) {
+ for (let child of parent.children) {
+ child.dataset.remoteId = parent.dataset.remoteId;
+ child.dataset.isNext = true;
+ }
+ delete parent.dataset.remoteId;
+ }
+
+ let result = documentFragment.firstElementChild;
+ // store the prplIMessage object in each of the "root" node that
+ // will be inserted into the document, so that selection code can
+ // retrieve the message by just looking at the parent node until it
+ // finds something.
+ for (let root = result; root; root = root.nextElementSibling) {
+ // Skip the insert placeholder.
+ if (root.id === "insert") {
+ continue;
+ }
+ root._originalMsg = aMsg;
+ // Store remote ID of the message in the DOM for fast retrieval
+ root.dataset.remoteId = aMsg.remoteId;
+ if (aIsNext) {
+ root.dataset.isNext = aIsNext;
+ }
+ }
+
+ // make sure the result is an HTMLElement and not some text (whitespace)...
+ while (
+ result &&
+ !(
+ result.nodeType == result.ELEMENT_NODE &&
+ result.namespaceURI == "http://www.w3.org/1999/xhtml"
+ )
+ ) {
+ result = result.nextElementSibling;
+ }
+ if (insert) {
+ parent.replaceChild(documentFragment, insert);
+ } else {
+ parent.appendChild(documentFragment);
+ }
+ return result;
+}
+
+/**
+ * Replace the HTML of an already displayed message based on the matching
+ * remote ID.
+ *
+ * @param {imIMessage} msg - Message to insert the updated contents of.
+ * @param {string} html - The HTML contents to insert.
+ * @param {Document} doc - The HTML document the message should be replaced
+ * in.
+ * @param {boolean} isNext - If this message is immediately following a
+ * message of the same origin. Used for visual grouping.
+ */
+export function replaceHTMLForMessage(msg, html, doc, isNext) {
+ // If the updated message has no remote ID, do nothing.
+ if (!msg.remoteId) {
+ return;
+ }
+ let message = getExistingMessage(msg.remoteId, doc);
+
+ // If we couldn't find a matching message, do nothing.
+ if (!message.length) {
+ return;
+ }
+
+ let documentFragment = getDocumentFragmentFromHTML(doc, html);
+ // We don't want to add an insert point when replacing a message.
+ documentFragment.querySelector("#insert")?.remove();
+ // store the prplIMessage object in each of the "root" nodes that
+ // will be inserted into the document, so that the selection code can
+ // retrieve the message by just looking at the parent node until it
+ // finds something.
+ for (
+ let root = documentFragment.firstElementChild;
+ root;
+ root = root.nextElementSibling
+ ) {
+ root._originalMsg = msg;
+ root.dataset.remoteId = msg.remoteId;
+ if (isNext) {
+ root.dataset.isNext = isNext;
+ }
+ }
+
+ // Remove all but the first element of the original message
+ if (message.length > 1) {
+ let range = doc.createRange();
+ range.setStartBefore(message[1]);
+ range.setEndAfter(message[message.length - 1]);
+ range.deleteContents();
+ }
+ // Insert the new message into the DOM
+ message[0].replaceWith(documentFragment);
+}
+
+/**
+ * Remove all elements belonging to a message from the document, based on the
+ * remote ID of the message.
+ *
+ * @param {string} remoteId
+ * @param {Document} doc
+ */
+export function removeMessage(remoteId, doc) {
+ let message = getExistingMessage(remoteId, doc);
+
+ // If we couldn't find a matching message, do nothing.
+ if (!message.length) {
+ return;
+ }
+
+ // Remove all elements of the original message
+ let range = doc.createRange();
+ range.setStartBefore(message[0]);
+ range.setEndAfter(message[message.length - 1]);
+ range.deleteContents();
+}
+
+function hasMetadataKey(aTheme, aKey) {
+ return (
+ aKey in aTheme.metadata ||
+ (aTheme.variant != "default" &&
+ aKey + ":" + aTheme.variant in aTheme.metadata) ||
+ ("DefaultVariant" in aTheme.metadata &&
+ aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata)
+ );
+}
+
+function getMetadata(aTheme, aKey) {
+ if (
+ aTheme.variant != "default" &&
+ aKey + ":" + aTheme.variant in aTheme.metadata
+ ) {
+ return aTheme.metadata[aKey + ":" + aTheme.variant];
+ }
+
+ if (
+ "DefaultVariant" in aTheme.metadata &&
+ aKey + ":" + aTheme.metadata.DefaultVariant in aTheme.metadata
+ ) {
+ return aTheme.metadata[aKey + ":" + aTheme.metadata.DefaultVariant];
+ }
+
+ return aTheme.metadata[aKey];
+}
+
+export function initHTMLDocument(aConv, aTheme, aDoc) {
+ let base = aDoc.createElement("base");
+ base.href = aTheme.baseURI;
+ aDoc.head.appendChild(base);
+
+ // Screen readers may read the title of the document, so provide one
+ // to avoid an ugly fallback to the URL (see bug 1165).
+ aDoc.title = aConv.title;
+
+ function addCSS(aHref) {
+ let link = aDoc.createElement("link");
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", aHref);
+ link.setAttribute("type", "text/css");
+ aDoc.head.appendChild(link);
+ }
+ addCSS("chrome://chat/skin/conv.css");
+ addCSS("chrome://messenger/skin/icons.css");
+
+ // add css to handle DefaultFontFamily and DefaultFontSize
+ let cssText = "";
+ if (hasMetadataKey(aTheme, "DefaultFontFamily")) {
+ cssText += "font-family: " + getMetadata(aTheme, "DefaultFontFamily") + ";";
+ }
+ if (hasMetadataKey(aTheme, "DefaultFontSize")) {
+ cssText += "font-size: " + getMetadata(aTheme, "DefaultFontSize") + ";";
+ }
+ if (cssText) {
+ addCSS("data:text/css,*{ " + cssText + " }");
+ }
+
+ // add the main CSS file of the theme
+ if (aTheme.metadata.MessageViewVersion >= 3 || aTheme.variant == "default") {
+ addCSS("main.css");
+ }
+
+ // add the CSS file of the variant
+ if (aTheme.variant != "default") {
+ addCSS("Variants/" + aTheme.variant + ".css");
+ } else if ("DefaultVariant" in aTheme.metadata) {
+ addCSS("Variants/" + aTheme.metadata.DefaultVariant + ".css");
+ }
+ aDoc.body.id = "ibcontent";
+
+ // We insert the whole content of body: chat div, footer
+ let html = '<div id="Chat" aria-live="polite"></div>';
+ html += replaceKeywordsInHTML(aTheme.html.footer, footerReplacements, aConv);
+
+ let frag = getDocumentFragmentFromHTML(aDoc, html);
+ aDoc.body.appendChild(frag);
+ if (!aTheme.metadata.NoScript) {
+ const scriptTag = aDoc.createElement("script");
+ scriptTag.src = "inline.js";
+ aDoc.body.appendChild(scriptTag);
+ }
+ aDoc.defaultView.convertTimeUnits = lazy.DownloadUtils.convertTimeUnits;
+}
+
+/* Selection stuff */
+function getEllipsis() {
+ let ellipsis = "[\u2026]";
+
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "messenger.conversations.selections.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ return ellipsis;
+}
+
+function _serializeDOMObject(aDocument, aInitFunction) {
+ // This shouldn't really be a constant, as we want to support
+ // text/html too in the future.
+ const type = "text/plain";
+
+ let encoder = Cu.createDocumentEncoder(type);
+ encoder.init(aDocument, type, Ci.nsIDocumentEncoder.OutputPreformatted);
+ aInitFunction(encoder);
+ let result = encoder.encodeToString();
+ return result;
+}
+
+function serializeRange(aRange) {
+ return _serializeDOMObject(
+ aRange.startContainer.ownerDocument,
+ function (aEncoder) {
+ aEncoder.setRange(aRange);
+ }
+ );
+}
+
+function serializeNode(aNode) {
+ return _serializeDOMObject(aNode.ownerDocument, function (aEncoder) {
+ aEncoder.setNode(aNode);
+ });
+}
+
+/* This function is used to pretty print a selection inside a conversation area */
+export function serializeSelection(aSelection) {
+ // We have two kinds of selection serialization:
+ // - The short version, used when only a part of message is
+ // selected, or if nothing interesting is selected
+ let shortSelection = "";
+
+ // - The long version, which is used:
+ // * when both some of the message text and some of the context
+ // (sender, time, ...) is selected;
+ // * when several messages are selected at once
+ // This version uses an array, with each message formatted
+ // through the theme system.
+ let longSelection = [];
+
+ // We first assume that we are going to use the short version, but
+ // while working on creating the short version, we prepare
+ // everything to be able to switch to the long version if we later
+ // discover that it is in fact needed.
+ let shortVersionPossible = true;
+
+ // Sometimes we need to know if a selection range is inside the same
+ // message as the previous selection range, so we keep track of the
+ // last message we have processed.
+ let lastMessage = null;
+
+ for (let i = 0; i < aSelection.rangeCount; ++i) {
+ let range = aSelection.getRangeAt(i);
+ let messages = getMessagesForRange(range);
+
+ // If at least one selected message has some of its text selected,
+ // remove from the selection all the messages that have no text
+ // selected
+ let testFunction = msg => msg.isTextSelected();
+ if (messages.some(testFunction)) {
+ messages = messages.filter(testFunction);
+ }
+
+ if (!messages.length) {
+ // Do it only if it wouldn't override a better already found selection
+ if (!shortSelection) {
+ shortSelection = serializeRange(range);
+ }
+ continue;
+ }
+
+ if (
+ shortVersionPossible &&
+ messages.length == 1 &&
+ (!messages[0].isTextSelected() || messages[0].onlyTextSelected()) &&
+ (!lastMessage ||
+ lastMessage.msg == messages[0].msg ||
+ lastMessage.msg.who == messages[0].msg.who)
+ ) {
+ if (shortSelection) {
+ if (lastMessage.msg != messages[0].msg) {
+ // Add the ellipsis only if the previous message was cut
+ if (lastMessage.cutEnd) {
+ shortSelection += " " + getEllipsis();
+ }
+ shortSelection += kLineBreak;
+ } else {
+ shortSelection += " " + getEllipsis() + " ";
+ }
+ }
+ shortSelection += serializeRange(range);
+ longSelection.push(messages[0].getFormattedMessage());
+ } else {
+ shortVersionPossible = false;
+ for (let m = 0; m < messages.length; ++m) {
+ let message = messages[m];
+ if (m == 0 && lastMessage && lastMessage.msg == message.msg) {
+ let text = message.getSelectedText();
+ if (message.cutEnd) {
+ text += " " + getEllipsis();
+ }
+ longSelection[longSelection.length - 1] += " " + text;
+ } else {
+ longSelection.push(message.getFormattedMessage());
+ }
+ }
+ }
+ lastMessage = messages[messages.length - 1];
+ }
+
+ if (shortVersionPossible) {
+ return shortSelection || aSelection.toString();
+ }
+ return longSelection.join(kLineBreak);
+}
+
+function SelectedMessage(aRootNode, aRange) {
+ this._rootNodes = [aRootNode];
+ this._range = aRange;
+}
+
+SelectedMessage.prototype = {
+ get msg() {
+ return this._rootNodes[0]._originalMsg;
+ },
+ addRoot(aRootNode) {
+ this._rootNodes.push(aRootNode);
+ },
+
+ // Helper function that returns the first span node of class
+ // ib-msg-text under the rootNodes of the selected message.
+ _getSpanNode() {
+ // first use the cached value if any
+ if (this._spanNode) {
+ return this._spanNode;
+ }
+
+ let spanNode = null;
+ // If we could use NodeFilter.webidl, we wouldn't have to make up our own
+ // object. FILTER_REJECT is not used here, but included for completeness.
+ const NodeFilter = {
+ SHOW_ELEMENT: 0x1,
+ FILTER_ACCEPT: 1,
+ FILTER_REJECT: 2,
+ FILTER_SKIP: 3,
+ };
+ // helper filter function for the tree walker
+ let filter = function (node) {
+ return node.className == "ib-msg-txt"
+ ? NodeFilter.FILTER_ACCEPT
+ : NodeFilter.FILTER_SKIP;
+ };
+ // walk the DOM subtrees of each root, keep the first correct span node
+ for (let i = 0; !spanNode && i < this._rootNodes.length; ++i) {
+ let rootNode = this._rootNodes[i];
+ // the TreeWalker doesn't test the root node, special case it first
+ if (filter(rootNode) == NodeFilter.FILTER_ACCEPT) {
+ spanNode = rootNode;
+ break;
+ }
+ let treeWalker = rootNode.ownerDocument.createTreeWalker(
+ rootNode,
+ NodeFilter.SHOW_ELEMENT,
+ { acceptNode: filter },
+ false
+ );
+ spanNode = treeWalker.nextNode();
+ }
+
+ return (this._spanNode = spanNode);
+ },
+
+ // Initialize _textSelected and _otherSelected; if _textSelected is true,
+ // also initialize _selectedText and _cutBegin/End.
+ _initSelectedText() {
+ if ("_textSelected" in this) {
+ // Already initialized.
+ return;
+ }
+
+ let spanNode = this._getSpanNode();
+ if (!spanNode) {
+ // can happen if the message text is under a separate root node
+ // that isn't selected at all
+ this._textSelected = false;
+ this._otherSelected = true;
+ return;
+ }
+ let startPoint = this._range.comparePoint(spanNode, 0);
+ // Note that we are working on the HTML DOM, including text nodes,
+ // so we need to use childNodes here and below.
+ let endPoint = this._range.comparePoint(
+ spanNode,
+ spanNode.childNodes.length
+ );
+ if (startPoint <= 0 && endPoint >= 0) {
+ let range = this._range.cloneRange();
+ if (startPoint >= 0) {
+ range.setStart(spanNode, 0);
+ }
+ if (endPoint <= 0) {
+ range.setEnd(spanNode, spanNode.childNodes.length);
+ }
+ this._selectedText = serializeRange(range);
+
+ // if the selected text is empty, set _selectedText to false
+ // this happens if the carret is at the offset 0 in the span node
+ this._textSelected = this._selectedText != "";
+ } else {
+ this._textSelected = false;
+ }
+ if (this._textSelected) {
+ // to check if the start or end is cut, the result of
+ // comparePoint is not enough because the selection range may
+ // start or end in a text node instead of the span node
+
+ if (startPoint == -1) {
+ let range = spanNode.ownerDocument.createRange();
+ range.setStart(spanNode, 0);
+ range.setEnd(this._range.startContainer, this._range.startOffset);
+ this._cutBegin = serializeRange(range) != "";
+ } else {
+ this._cutBegin = false;
+ }
+
+ if (endPoint == 1) {
+ let range = spanNode.ownerDocument.createRange();
+ range.setStart(this._range.endContainer, this._range.endOffset);
+ range.setEnd(spanNode, spanNode.childNodes.length);
+ this._cutEnd = !/^(\r?\n)?$/.test(serializeRange(range));
+ } else {
+ this._cutEnd = false;
+ }
+ }
+ this._otherSelected =
+ (startPoint >= 0 || endPoint <= 0) && // eliminate most negative cases
+ (!this._textSelected ||
+ serializeRange(this._range).length > this._selectedText.length);
+ },
+ get cutBegin() {
+ this._initSelectedText();
+ return this._textSelected && this._cutBegin;
+ },
+ get cutEnd() {
+ this._initSelectedText();
+ return this._textSelected && this._cutEnd;
+ },
+ isTextSelected() {
+ this._initSelectedText();
+ return this._textSelected;
+ },
+ onlyTextSelected() {
+ this._initSelectedText();
+ return !this._otherSelected;
+ },
+ getSelectedText() {
+ this._initSelectedText();
+ return this._textSelected ? this._selectedText : "";
+ },
+ getFormattedMessage() {
+ // First, get the selected text
+ this._initSelectedText();
+ let msg = this.msg;
+ let text;
+ if (this._textSelected) {
+ // Add ellipsis is needed
+ text =
+ (this._cutBegin ? getEllipsis() + " " : "") +
+ this._selectedText +
+ (this._cutEnd ? " " + getEllipsis() : "");
+ } else {
+ let div = this._rootNodes[0].ownerDocument.createElement("div");
+ let divChildren = getDocumentFragmentFromHTML(
+ div.ownerDocument,
+ msg.autoResponse ? formatAutoResponce(msg.message) : msg.message
+ );
+ div.appendChild(divChildren);
+ text = serializeNode(div);
+ }
+
+ // then get the suitable replacements and templates for this message
+ let getLocalizedPrefWithDefault = function (aName, aDefault) {
+ try {
+ let prefBranch = Services.prefs.getBranch(
+ "messenger.conversations.selections."
+ );
+ return prefBranch.getComplexValue(aName, Ci.nsIPrefLocalizedString)
+ .data;
+ } catch (e) {
+ return aDefault;
+ }
+ };
+ let html, replacements;
+ if (msg.system) {
+ replacements = statusReplacements;
+ html = getLocalizedPrefWithDefault(
+ "systemMessagesTemplate",
+ "%time% - %message%"
+ );
+ } else {
+ replacements = messageReplacements;
+ if (msg.action) {
+ html = getLocalizedPrefWithDefault(
+ "actionMessagesTemplate",
+ "%time% * %sender% %message%"
+ );
+ } else {
+ html = getLocalizedPrefWithDefault(
+ "contentMessagesTemplate",
+ "%time% - %sender%: %message%"
+ );
+ }
+ }
+
+ // Overrides default replacements so that they don't add a span node.
+ // Also, this uses directly the text variable so that we don't
+ // have to change the content of msg.message and revert it
+ // afterwards.
+ replacements = {
+ message: aMsg => text,
+ sender: aMsg => aMsg.alias || aMsg.who,
+ __proto__: replacements,
+ };
+
+ // Finally, let the theme system do the magic!
+ return replaceKeywordsInHTML(html, replacements, msg);
+ },
+};
+
+export function getMessagesForRange(aRange) {
+ let result = []; // will hold the final result
+ let messages = {}; // used to prevent duplicate messages in the result array
+
+ // cache the range boundaries, they will be used a lot
+ let endNode = aRange.endContainer;
+ let startNode = aRange.startContainer;
+
+ // Helper function to recursively look for _originalMsg JS
+ // properties on DOM nodes, and stop when endNode is reached.
+ // Found nodes are pushed into the rootNodes array.
+ let processSubtree = function (aNode) {
+ if (aNode._originalMsg) {
+ // store the result
+ if (!(aNode._originalMsg.id in messages)) {
+ // we've found a new message!
+ let newMessage = new SelectedMessage(aNode, aRange);
+ messages[aNode._originalMsg.id] = newMessage;
+ result.push(newMessage);
+ } else {
+ // we've found another root of an already known message
+ messages[aNode._originalMsg.id].addRoot(aNode);
+ }
+ }
+
+ // check if we have reached the end node
+ if (aNode == endNode) {
+ return true;
+ }
+
+ // recurse through children
+ if (
+ aNode.nodeType == aNode.ELEMENT_NODE &&
+ aNode.namespaceURI == "http://www.w3.org/1999/xhtml"
+ ) {
+ for (let i = 0; i < aNode.children.length; ++i) {
+ if (processSubtree(aNode.children[i])) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ };
+
+ let currentNode = aRange.commonAncestorContainer;
+ if (
+ currentNode.nodeType == currentNode.ELEMENT_NODE &&
+ currentNode.namespaceURI == "http://www.w3.org/1999/xhtml"
+ ) {
+ // Determine the index of the first and last children of currentNode
+ // that we should process.
+ let found = false;
+ let start = 0;
+ if (currentNode == startNode) {
+ // we want to process all children
+ found = true;
+ start = aRange.startOffset;
+ } else {
+ // startNode needs to be a direct child of currentNode
+ while (startNode.parentNode != currentNode) {
+ startNode = startNode.parentNode;
+ }
+ }
+ let end;
+ if (currentNode == endNode) {
+ end = aRange.endOffset;
+ } else {
+ end = currentNode.children.length;
+ }
+
+ for (let i = start; i < end; ++i) {
+ let node = currentNode.children[i];
+
+ // don't do anything until we find the startNode
+ found = found || node == startNode;
+ if (!found) {
+ continue;
+ }
+
+ if (processSubtree(node)) {
+ break;
+ }
+ }
+ }
+
+ // The selection may not include any root node of the first touched
+ // message, in this case, the DOM traversal of the DOM range
+ // couldn't give us the first message. Make sure we actually have
+ // the message in which the range starts.
+ let firstRoot = aRange.startContainer;
+ while (firstRoot && !firstRoot._originalMsg) {
+ firstRoot = firstRoot.parentNode;
+ }
+ if (firstRoot && !(firstRoot._originalMsg.id in messages)) {
+ result.unshift(new SelectedMessage(firstRoot, aRange));
+ }
+
+ return result;
+}
+
+/**
+ * Turns a raw HTML string into a DocumentFragment usable in the provided
+ * document.
+ *
+ * @param {Document} doc - The Document the fragment will belong to.
+ * @param {string} html - The target HTML to be parsed.
+ *
+ * @returns {DocumentFragment}
+ */
+export function getDocumentFragmentFromHTML(doc, html) {
+ let uri = Services.io.newURI(doc.baseURI);
+ let flags = Ci.nsIParserUtils.SanitizerAllowStyle;
+ let context = doc.createElement("div");
+ return ParserUtils.parseFragment(html, flags, false, uri, context);
+}
+
+/**
+ * Get all nodes that make up the given message if any.
+ *
+ * @param {string} remoteId - Remote ID of the message to get
+ * @param {Document} doc - Document the message is in.
+ * @returns {NodeList} Node list of all the parts of the message, or an empty
+ * list if the message is not found.
+ */
+function getExistingMessage(remoteId, doc) {
+ let parent = doc.getElementById("Chat");
+ return parent.querySelectorAll(`[data-remote-id="${CSS.escape(remoteId)}"]`);
+}
diff --git a/comm/chat/modules/imXPCOMUtils.sys.mjs b/comm/chat/modules/imXPCOMUtils.sys.mjs
new file mode 100644
index 0000000000..4a48f2116d
--- /dev/null
+++ b/comm/chat/modules/imXPCOMUtils.sys.mjs
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+var kLogLevelPref = "purple.debug.loglevel";
+
+/**
+ * Creates an nsIScriptError instance and logs it.
+ *
+ * @param aModule
+ * string identifying the module within which the error occurred.
+ * @param aLevel
+ * the error level as defined in imIDebugMessage.
+ * @param aMessage
+ * the error message string.
+ * @param aOriginalError
+ * (optional) JS Error object containing the location where the
+ * actual error occurred. Its error message is appended to aMessage.
+ */
+export function scriptError(aModule, aLevel, aMessage, aOriginalError) {
+ // Figure out the log level, based on the module and the prefs set.
+ // The module name is split on periods, and if no pref is set the pref with
+ // the last section removed is attempted (until no sections are left, using
+ // the global default log level).
+ let logLevel = -1;
+ let logKeys = ["level"].concat(aModule.split("."));
+ for (; logKeys.length > 0; logKeys.pop()) {
+ let logKey = logKeys.join(".");
+ if (logKey in lazy.gLogLevels) {
+ logLevel = lazy.gLogLevels[logKey];
+ break;
+ }
+ }
+
+ // Only continue if we will log this message.
+ if (logLevel > aLevel && !("imAccount" in this)) {
+ return;
+ }
+
+ let flag = Ci.nsIScriptError.warningFlag;
+ if (aLevel >= Ci.imIDebugMessage.LEVEL_ERROR) {
+ flag = Ci.nsIScriptError.errorFlag;
+ }
+
+ let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ let caller = Components.stack.caller;
+ let sourceLine = aModule || caller.sourceLine;
+ if (caller.name) {
+ if (sourceLine) {
+ sourceLine += ": ";
+ }
+ sourceLine += caller.name;
+ }
+ let fileName = caller.filename;
+ let lineNumber = caller.lineNumber;
+ if (aOriginalError) {
+ aMessage += "\n" + (aOriginalError.message || aOriginalError);
+ if (aOriginalError.fileName) {
+ fileName = aOriginalError.fileName;
+ }
+ if (aOriginalError.lineNumber) {
+ lineNumber = aOriginalError.lineNumber;
+ }
+ }
+ scriptError.init(
+ aMessage,
+ fileName,
+ sourceLine,
+ lineNumber,
+ null,
+ flag,
+ "component javascript"
+ );
+
+ if (logLevel <= aLevel) {
+ dump(aModule + ": " + aMessage + "\n");
+ if (aLevel == Ci.imIDebugMessage.LEVEL_LOG && logLevel == aLevel) {
+ Services.console.logStringMessage(aMessage);
+ } else {
+ Services.console.logMessage(scriptError);
+ }
+ }
+ if ("imAccount" in this) {
+ this.imAccount.logDebugMessage(scriptError, aLevel);
+ }
+}
+
+export function initLogModule(aModule, aObj = {}) {
+ aObj.DEBUG = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_DEBUG);
+ aObj.LOG = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_LOG);
+ aObj.WARN = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_WARNING);
+ aObj.ERROR = scriptError.bind(aObj, aModule, Ci.imIDebugMessage.LEVEL_ERROR);
+ return aObj;
+}
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "gLogLevels", function () {
+ // This object functions both as an obsever as well as a dict keeping the
+ // log levels with prefs; the log levels all start with "level" (i.e. "level"
+ // for the global level, "level.irc" for the IRC module). The dual-purpose
+ // is necessary to make sure the observe is left alive while being a weak ref
+ // to avoid cycles with the pref service.
+ let logLevels = {
+ observe(aSubject, aTopic, aData) {
+ let module = "level" + aData.substr(kLogLevelPref.length);
+ if (Services.prefs.getPrefType(aData) == Services.prefs.PREF_INT) {
+ lazy.gLogLevels[module] = Services.prefs.getIntPref(aData);
+ } else {
+ delete lazy.gLogLevels[module];
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ // Add weak pref observer to see log level pref changes.
+ Services.prefs.addObserver(kLogLevelPref, logLevels, true /* weak */);
+
+ // Initialize with existing log level prefs.
+ for (let pref of Services.prefs.getChildList(kLogLevelPref)) {
+ if (Services.prefs.getPrefType(pref) == Services.prefs.PREF_INT) {
+ logLevels["level" + pref.substr(kLogLevelPref.length)] =
+ Services.prefs.getIntPref(pref);
+ }
+ }
+
+ // Let environment variables override prefs.
+ Services.env
+ .get("PRPL_LOG")
+ .split(/[;,]/)
+ .filter(n => n != "")
+ .forEach(function (env) {
+ let [, module, level] = env.match(/(?:(.*?)[:=])?(\d+)/);
+ logLevels["level" + (module ? "." + module : "")] = parseInt(level, 10);
+ });
+
+ return logLevels;
+});
+
+export function executeSoon(aFunction) {
+ Services.tm.mainThread.dispatch(aFunction, Ci.nsIEventTarget.DISPATCH_NORMAL);
+}
+
+/* Common nsIClassInfo and QueryInterface implementation
+ * shared by all generic objects implemented in this file. */
+export function ClassInfo(aInterfaces, aDescription = "JS Proto Object") {
+ if (!(this instanceof ClassInfo)) {
+ return new ClassInfo(aInterfaces, aDescription);
+ }
+
+ if (!Array.isArray(aInterfaces)) {
+ aInterfaces = [aInterfaces];
+ }
+
+ for (let i of aInterfaces) {
+ if (typeof i == "string" && !(i in Ci)) {
+ Services.console.logStringMessage("ClassInfo: unknown interface " + i);
+ }
+ }
+
+ this._interfaces = aInterfaces.map(i => (typeof i == "string" ? Ci[i] : i));
+
+ this.classDescription = aDescription;
+}
+
+ClassInfo.prototype = {
+ // eslint-disable-next-line mozilla/use-chromeutils-generateqi
+ QueryInterface(iid) {
+ if (
+ iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIClassInfo) ||
+ this._interfaces.some(i => i.equals(iid))
+ ) {
+ return this;
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ get interfaces() {
+ return [Ci.nsIClassInfo, Ci.nsISupports].concat(this._interfaces);
+ },
+ getScriptableHelper: () => null,
+ contractID: null,
+ classID: null,
+ flags: 0,
+};
+
+export function l10nHelper(aChromeURL) {
+ let bundle = Services.strings.createBundle(aChromeURL);
+ return function (aStringId) {
+ try {
+ if (arguments.length == 1) {
+ return bundle.GetStringFromName(aStringId);
+ }
+ return bundle.formatStringFromName(
+ aStringId,
+ Array.prototype.slice.call(arguments, 1)
+ );
+ } catch (e) {
+ console.error(e);
+ dump("Failed to get " + aStringId + "\n");
+ return aStringId;
+ }
+ };
+}
+
+/**
+ * Constructs an nsISimpleEnumerator for the given array of items.
+ * Copied from netwerk/test/httpserver/httpd.js
+ *
+ * @param items : Array
+ * the items, which must all implement nsISupports
+ */
+export function nsSimpleEnumerator(items) {
+ this._items = items;
+ this._nextIndex = 0;
+}
+
+nsSimpleEnumerator.prototype = {
+ hasMoreElements() {
+ return this._nextIndex < this._items.length;
+ },
+ getNext() {
+ if (!this.hasMoreElements()) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+
+ return this._items[this._nextIndex++];
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]),
+ [Symbol.iterator]() {
+ return this._items.values();
+ },
+};
+
+export var EmptyEnumerator = {
+ hasMoreElements: () => false,
+ getNext() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]),
+ *[Symbol.iterator]() {},
+};
diff --git a/comm/chat/modules/jsProtoHelper.sys.mjs b/comm/chat/modules/jsProtoHelper.sys.mjs
new file mode 100644
index 0000000000..b792a02ffe
--- /dev/null
+++ b/comm/chat/modules/jsProtoHelper.sys.mjs
@@ -0,0 +1,1796 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ initLogModule,
+ nsSimpleEnumerator,
+ l10nHelper,
+ ClassInfo,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/conversations.properties")
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTXT => cs.scanTXT(aTXT, cs.kEntities);
+});
+
+function OutgoingMessage(aMsg, aConversation) {
+ this.message = aMsg;
+ this.conversation = aConversation;
+}
+OutgoingMessage.prototype = {
+ __proto__: ClassInfo("imIOutgoingMessage", "Outgoing Message"),
+ cancelled: false,
+ action: false,
+ notification: false,
+};
+
+export var GenericAccountPrototype = {
+ __proto__: ClassInfo("prplIAccount", "generic account object"),
+ get wrappedJSObject() {
+ return this;
+ },
+ _init(aProtocol, aImAccount) {
+ this.protocol = aProtocol;
+ this.imAccount = aImAccount;
+ initLogModule(aProtocol.id, this);
+ },
+ observe(aSubject, aTopic, aData) {},
+ remove() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ unInit() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ connect() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ disconnect() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ createConversation(aName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ joinChat(aComponents) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ setBool(aName, aVal) {},
+ setInt(aName, aVal) {},
+ setString(aName, aVal) {},
+
+ get name() {
+ return this.imAccount.name;
+ },
+ get connected() {
+ return this.imAccount.connected;
+ },
+ get connecting() {
+ return this.imAccount.connecting;
+ },
+ get disconnected() {
+ return this.imAccount.disconnected;
+ },
+ get disconnecting() {
+ return this.imAccount.disconnecting;
+ },
+ _connectionErrorReason: Ci.prplIAccount.NO_ERROR,
+ get connectionErrorReason() {
+ return this._connectionErrorReason;
+ },
+
+ /**
+ * Convert a socket's nsITransportSecurityInfo into a prplIAccount connection error. Store
+ * the nsITransportSecurityInfo and the connection location on the account so the
+ * certificate exception dialog can access the information.
+ *
+ * @param {Socket} aSocket - Socket where the connection error occurred.
+ * @returns {number} The prplIAccount error constant describing the problem.
+ */
+ handleConnectionSecurityError(aSocket) {
+ // Stash away the connectionTarget and securityInfo.
+ this._connectionTarget = aSocket.host + ":" + aSocket.port;
+ let securityInfo = (this._securityInfo = aSocket.securityInfo);
+
+ if (!securityInfo) {
+ return Ci.prplIAccount.ERROR_CERT_NOT_PROVIDED;
+ }
+
+ if (securityInfo.isUntrusted) {
+ if (securityInfo.serverCert && securityInfo.serverCert.isSelfSigned) {
+ return Ci.prplIAccount.ERROR_CERT_SELF_SIGNED;
+ }
+ return Ci.prplIAccount.ERROR_CERT_UNTRUSTED;
+ }
+
+ if (securityInfo.isNotValidAtThisTime) {
+ if (
+ securityInfo.serverCert &&
+ securityInfo.serverCert.validity.notBefore < Date.now() * 1000
+ ) {
+ return Ci.prplIAccount.ERROR_CERT_NOT_ACTIVATED;
+ }
+ return Ci.prplIAccount.ERROR_CERT_EXPIRED;
+ }
+
+ if (securityInfo.isDomainMismatch) {
+ return Ci.prplIAccount.ERROR_CERT_HOSTNAME_MISMATCH;
+ }
+
+ // XXX ERROR_CERT_FINGERPRINT_MISMATCH
+
+ return Ci.prplIAccount.ERROR_CERT_OTHER_ERROR;
+ },
+ _connectionTarget: "",
+ get connectionTarget() {
+ return this._connectionTarget;
+ },
+ _securityInfo: null,
+ get securityInfo() {
+ return this._securityInfo;
+ },
+
+ reportConnected() {
+ this.imAccount.observe(this, "account-connected", null);
+ },
+ reportConnecting(aConnectionStateMsg) {
+ // Delete any leftover errors from the previous connection.
+ delete this._connectionTarget;
+ delete this._securityInfo;
+
+ if (!this.connecting) {
+ this.imAccount.observe(this, "account-connecting", null);
+ }
+ if (aConnectionStateMsg) {
+ this.imAccount.observe(
+ this,
+ "account-connect-progress",
+ aConnectionStateMsg
+ );
+ }
+ },
+ reportDisconnected() {
+ this.imAccount.observe(this, "account-disconnected", null);
+ },
+ reportDisconnecting(aConnectionErrorReason, aConnectionErrorMessage) {
+ this._connectionErrorReason = aConnectionErrorReason;
+ this.imAccount.observe(
+ this,
+ "account-disconnecting",
+ aConnectionErrorMessage
+ );
+ this.cancelPendingBuddyRequests();
+ this.cancelPendingChatRequests();
+ this.cancelPendingVerificationRequests();
+ },
+
+ // Called when the user adds a new buddy from the UI.
+ addBuddy(aTag, aName) {
+ IMServices.contacts.accountBuddyAdded(
+ new AccountBuddy(this, null, aTag, aName)
+ );
+ },
+ // Called during startup for each of the buddies in the local buddy list.
+ loadBuddy(aBuddy, aTag) {
+ try {
+ return new AccountBuddy(this, aBuddy, aTag);
+ } catch (x) {
+ dump(x + "\n");
+ return null;
+ }
+ },
+
+ _pendingBuddyRequests: null,
+ addBuddyRequest(aUserName, aGrantCallback, aDenyCallback) {
+ if (!this._pendingBuddyRequests) {
+ this._pendingBuddyRequests = [];
+ }
+ let buddyRequest = {
+ get account() {
+ return this._account.imAccount;
+ },
+ get userName() {
+ return aUserName;
+ },
+ _account: this,
+ // Grant and deny callbacks both receive the auth request object as an
+ // argument for further use.
+ grant() {
+ aGrantCallback(this);
+ this._remove();
+ },
+ deny() {
+ aDenyCallback(this);
+ this._remove();
+ },
+ cancel() {
+ Services.obs.notifyObservers(
+ this,
+ "buddy-authorization-request-canceled"
+ );
+ this._remove();
+ },
+ _remove() {
+ this._account.removeBuddyRequest(this);
+ },
+ QueryInterface: ChromeUtils.generateQI(["prplIBuddyRequest"]),
+ };
+ this._pendingBuddyRequests.push(buddyRequest);
+ Services.obs.notifyObservers(buddyRequest, "buddy-authorization-request");
+ },
+ removeBuddyRequest(aRequest) {
+ if (!this._pendingBuddyRequests) {
+ return;
+ }
+
+ this._pendingBuddyRequests = this._pendingBuddyRequests.filter(
+ r => r !== aRequest
+ );
+ },
+ /**
+ * Cancel a pending buddy request.
+ *
+ * @param {string} aUserName - The username the request is for.
+ */
+ cancelBuddyRequest(aUserName) {
+ if (!this._pendingBuddyRequests) {
+ return;
+ }
+
+ for (let request of this._pendingBuddyRequests) {
+ if (request.userName == aUserName) {
+ request.cancel();
+ break;
+ }
+ }
+ },
+ cancelPendingBuddyRequests() {
+ if (!this._pendingBuddyRequests) {
+ return;
+ }
+
+ for (let request of this._pendingBuddyRequests) {
+ request.cancel();
+ }
+ delete this._pendingBuddyRequests;
+ },
+
+ _pendingChatRequests: null,
+ /**
+ * Inform the user about a new conversation invitation.
+ *
+ * @param {string} conversationName - Name of the conversation the user is
+ * invited to.
+ * @param {(prplIChatRequest) => void} grantCallback - Function to be called
+ * when the invite is accepted.
+ * @param {(prplIChatRequest?, boolean) => void} [denyCallback] - Function to
+ * be called when the invite is rejected. If omitted, |canDeny| will be
+ * |false|. Callback is passed a boolean indicating whether the rejection should be
+ * sent to the other party. It being false is equivalent to ignoring the invite, in
+ * which case the callback should try to apply the ignore on the protocol level.
+ */
+ addChatRequest(conversationName, grantCallback, denyCallback) {
+ if (!this._pendingChatRequests) {
+ this._pendingChatRequests = new Set();
+ }
+ let inviteHandling = Services.prefs.getIntPref(
+ "messenger.conversations.autoAcceptChatInvitations"
+ );
+ // Only auto-reject invites that can be denied.
+ if (inviteHandling <= 0 && denyCallback) {
+ const shouldReject = inviteHandling == -1;
+ denyCallback(null, shouldReject);
+ return;
+ }
+ let resolvePromise;
+ let rejectPromise;
+ let completePromise = new Promise((resolve, reject) => {
+ resolvePromise = resolve;
+ rejectPromise = reject;
+ });
+ /** @implements {prplIChatRequest} */
+ let chatRequest = {
+ get account() {
+ return this._account.imAccount;
+ },
+ get conversationName() {
+ return conversationName;
+ },
+ get canDeny() {
+ return Boolean(denyCallback);
+ },
+ _account: this,
+ // Grant and deny callbacks both receive the auth request object as an
+ // argument for further use.
+ grant() {
+ resolvePromise(true);
+ grantCallback(this);
+ this._remove();
+ },
+ deny() {
+ if (!denyCallback) {
+ throw new Error("Can not deny this invitation.");
+ }
+ resolvePromise(false);
+ denyCallback(this, true);
+ this._remove();
+ },
+ cancel() {
+ rejectPromise(new Error("Cancelled"));
+ this._remove();
+ },
+ completePromise,
+ _remove() {
+ this._account.removeChatRequest(this);
+ },
+ QueryInterface: ChromeUtils.generateQI(["prplIChatRequest"]),
+ };
+ this._pendingChatRequests.add(chatRequest);
+ Services.obs.notifyObservers(chatRequest, "conv-authorization-request");
+ },
+ removeChatRequest(aRequest) {
+ if (!this._pendingChatRequests) {
+ return;
+ }
+
+ this._pendingChatRequests.delete(aRequest);
+ },
+ /**
+ * Cancel a pending chat request.
+ *
+ * @param {string} conversationName - The conversation the request is for.
+ */
+ cancelChatRequest(conversationName) {
+ if (!this._pendingChatRequests) {
+ return;
+ }
+
+ for (let request of this._pendingChatRequests) {
+ if (request.conversationName == conversationName) {
+ request.cancel();
+ break;
+ }
+ }
+ },
+ cancelPendingChatRequests() {
+ if (!this._pendingChatRequests) {
+ return;
+ }
+
+ for (let request of this._pendingChatRequests) {
+ request.cancel();
+ }
+ this._pendingChatRequests = null;
+ },
+
+ requestBuddyInfo(aBuddyName) {},
+
+ get canJoinChat() {
+ return false;
+ },
+ getChatRoomFields() {
+ if (!this.chatRoomFields) {
+ return [];
+ }
+ let fieldNames = Object.keys(this.chatRoomFields);
+ return fieldNames.map(
+ fieldName => new ChatRoomField(fieldName, this.chatRoomFields[fieldName])
+ );
+ },
+ getChatRoomDefaultFieldValues(aDefaultChatName) {
+ if (!this.chatRoomFields) {
+ return new ChatRoomFieldValues({});
+ }
+
+ let defaultFieldValues = {};
+ for (let fieldName in this.chatRoomFields) {
+ defaultFieldValues[fieldName] = this.chatRoomFields[fieldName].default;
+ }
+
+ if (aDefaultChatName && "parseDefaultChatName" in this) {
+ let parsedDefaultChatName = this.parseDefaultChatName(aDefaultChatName);
+ for (let field in parsedDefaultChatName) {
+ defaultFieldValues[field] = parsedDefaultChatName[field];
+ }
+ }
+
+ return new ChatRoomFieldValues(defaultFieldValues);
+ },
+ requestRoomInfo(aCallback) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ getRoomInfo(aName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ get isRoomInfoStale() {
+ return false;
+ },
+
+ getPref(aName, aType) {
+ return this.prefs.prefHasUserValue(aName)
+ ? this.prefs["get" + aType + "Pref"](aName)
+ : this.protocol._getOptionDefault(aName);
+ },
+ getInt(aName) {
+ return this.getPref(aName, "Int");
+ },
+ getBool(aName) {
+ return this.getPref(aName, "Bool");
+ },
+ getString(aName) {
+ return this.prefs.prefHasUserValue(aName)
+ ? this.prefs.getStringPref(aName)
+ : this.protocol._getOptionDefault(aName);
+ },
+
+ get prefs() {
+ return (
+ this._prefs ||
+ (this._prefs = Services.prefs.getBranch(
+ "messenger.account." + this.imAccount.id + ".options."
+ ))
+ );
+ },
+
+ get normalizedName() {
+ return this.normalize(this.name);
+ },
+ normalize(aName) {
+ return aName.toLowerCase();
+ },
+
+ getSessions() {
+ return [];
+ },
+ reportSessionsChanged() {
+ Services.obs.notifyObservers(this.imAccount, "account-sessions-changed");
+ },
+
+ _pendingVerificationRequests: null,
+ /**
+ *
+ * @param {string} aDisplayName - Display name the request is from.
+ * @param {() => Promise<{challenge: string, challengeDescription: string?}>} aGetChallenge - Accept request and generate
+ * the challenge.
+ * @param {AbortSignal} [aAbortSignal] - Abort signal to indicate the request
+ * was cancelled.
+ * @returns {Promise<boolean>} Completion promise for the verification.
+ * Boolean indicates the result of the verification, rejection is a cancel.
+ */
+ addVerificationRequest(aDisplayName, aGetChallenge, aAbortSignal) {
+ if (!this._pendingVerificationRequests) {
+ this._pendingVerificationRequests = [];
+ }
+ let verificationRequest = {
+ _account: this,
+ get account() {
+ return this._account.imAccount;
+ },
+ get subject() {
+ return aDisplayName;
+ },
+ get challengeType() {
+ return Ci.imISessionVerification.CHALLENGE_TEXT;
+ },
+ get challenge() {
+ return this._challenge;
+ },
+ get challengeDescription() {
+ return this._challengeDescription;
+ },
+ _challenge: "",
+ _challengeDescription: "",
+ _canceled: false,
+ completePromise: null,
+ async verify() {
+ const { challenge, challengeDescription = "" } = await aGetChallenge();
+ this._challenge = challenge;
+ this._challengeDescription = challengeDescription;
+ },
+ submitResponse(challengeMatches) {
+ this._accept(challengeMatches);
+ this._remove();
+ },
+ cancel() {
+ if (this._canceled) {
+ return;
+ }
+ this._canceled = true;
+ Services.obs.notifyObservers(
+ this,
+ "buddy-verification-request-canceled"
+ );
+ this._deny();
+ this._remove();
+ },
+ _remove() {
+ this._account.removeVerificationRequest(this);
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "imIIncomingSessionVerification",
+ ]),
+ };
+ verificationRequest.completePromise = new Promise((resolve, reject) => {
+ verificationRequest._accept = resolve;
+ verificationRequest._deny = reject;
+ });
+ this._pendingVerificationRequests.push(verificationRequest);
+ Services.obs.notifyObservers(
+ verificationRequest,
+ "buddy-verification-request"
+ );
+ if (aAbortSignal) {
+ aAbortSignal.addEventListener(
+ "abort",
+ () => {
+ verificationRequest.cancel();
+ },
+ { once: true }
+ );
+ if (aAbortSignal.aborted) {
+ verificationRequest.cancel();
+ }
+ }
+ return verificationRequest.completePromise;
+ },
+ /**
+ * Remove a verification request for this account.
+ *
+ * @param {imIIncomingSessionVerification} aRequest
+ */
+ removeVerificationRequest(aRequest) {
+ if (!this._pendingVerificationRequests) {
+ return;
+ }
+ this._pendingVerificationRequests =
+ this._pendingVerificationRequests.filter(r => r !== aRequest);
+ },
+ cancelPendingVerificationRequests() {
+ if (!this._pendingVerificationRequests) {
+ return;
+ }
+ for (let request of this._pendingVerificationRequests) {
+ request.cancel();
+ }
+ this._pendingVerificationRequests = null;
+ },
+
+ _encryptionStatus: [],
+ get encryptionStatus() {
+ return this._encryptionStatus;
+ },
+ set encryptionStatus(newStatus) {
+ this._encryptionStatus = newStatus;
+ Services.obs.notifyObservers(
+ this.imAccount,
+ "account-encryption-status-changed",
+ newStatus
+ );
+ },
+};
+
+export var GenericAccountBuddyPrototype = {
+ __proto__: ClassInfo("prplIAccountBuddy", "generic account buddy object"),
+ get DEBUG() {
+ return this._account.DEBUG;
+ },
+ get LOG() {
+ return this._account.LOG;
+ },
+ get WARN() {
+ return this._account.WARN;
+ },
+ get ERROR() {
+ return this._account.ERROR;
+ },
+
+ _init(aAccount, aBuddy, aTag, aUserName) {
+ if (!aBuddy && !aUserName) {
+ throw new Error("aUserName is required when aBuddy is null");
+ }
+
+ this._tag = aTag;
+ this._account = aAccount;
+ this._buddy = aBuddy;
+ if (aBuddy) {
+ let displayName = aBuddy.displayName;
+ if (displayName != aUserName) {
+ this._serverAlias = displayName;
+ }
+ }
+ this._userName = aUserName;
+ },
+ unInit() {
+ delete this._tag;
+ delete this._account;
+ delete this._buddy;
+ },
+
+ get account() {
+ return this._account.imAccount;
+ },
+ set buddy(aBuddy) {
+ if (this._buddy) {
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+ this._buddy = aBuddy;
+ },
+ get buddy() {
+ return this._buddy;
+ },
+ get tag() {
+ return this._tag;
+ },
+ set tag(aNewTag) {
+ let oldTag = this._tag;
+ this._tag = aNewTag;
+ IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag);
+ },
+
+ _notifyObservers(aTopic, aData) {
+ try {
+ this._buddy.observe(this, "account-buddy-" + aTopic, aData);
+ } catch (e) {
+ this.ERROR(e);
+ }
+ },
+
+ _userName: "",
+ get userName() {
+ return this._userName || this._buddy.userName;
+ },
+ get normalizedName() {
+ return this._account.normalize(this.userName);
+ },
+ _serverAlias: "",
+ get serverAlias() {
+ return this._serverAlias;
+ },
+ set serverAlias(aNewAlias) {
+ let old = this.displayName;
+ this._serverAlias = aNewAlias;
+ if (old != this.displayName) {
+ this._notifyObservers("display-name-changed", old);
+ }
+ },
+
+ /**
+ * Method called to start verification of the buddy. Same signature as
+ * _startVerification of GenericSessionPrototype. If the property is not a
+ * function, |canVerifyIdentity| is false.
+ *
+ * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?}
+ */
+ _startVerification: null,
+ get canVerifyIdentity() {
+ return typeof this._startVerification === "function";
+ },
+ _identityVerified: false,
+ get identityVerified() {
+ return this.canVerifyIdentity && this._identityVerified;
+ },
+ verifyIdentity() {
+ if (!this.canVerifyIdentity) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ if (this.identityVerified) {
+ return Promise.resolve();
+ }
+ return this._startVerification().then(
+ ({
+ challenge,
+ challengeDescription,
+ handleResult,
+ cancel,
+ cancelPromise,
+ }) => {
+ const verifier = new SessionVerification(
+ challenge,
+ this.userName,
+ challengeDescription
+ );
+ verifier.completePromise.then(
+ result => handleResult(result),
+ () => cancel()
+ );
+ cancelPromise.then(() => verifier.cancel());
+ return verifier;
+ }
+ );
+ },
+
+ remove() {
+ IMServices.contacts.accountBuddyRemoved(this);
+ },
+
+ // imIStatusInfo implementation
+ get displayName() {
+ return this.serverAlias || this.userName;
+ },
+ _buddyIconFilename: "",
+ get buddyIconFilename() {
+ return this._buddyIconFilename;
+ },
+ set buddyIconFilename(aNewFileName) {
+ this._buddyIconFilename = aNewFileName;
+ this._notifyObservers("icon-changed");
+ },
+ _statusType: 0,
+ get statusType() {
+ return this._statusType;
+ },
+ get online() {
+ return this._statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
+ },
+ get available() {
+ return this._statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
+ },
+ get idle() {
+ return this._statusType == Ci.imIStatusInfo.STATUS_IDLE;
+ },
+ get mobile() {
+ return this._statusType == Ci.imIStatusInfo.STATUS_MOBILE;
+ },
+ _statusText: "",
+ get statusText() {
+ return this._statusText;
+ },
+
+ // This is for use by the protocol plugin, it's not exposed in the
+ // imIStatusInfo interface.
+ // All parameters are optional and will be ignored if they are null
+ // or undefined.
+ setStatus(aStatusType, aStatusText, aAvailabilityDetails) {
+ // Ignore omitted parameters.
+ if (aStatusType === undefined || aStatusType === null) {
+ aStatusType = this._statusType;
+ }
+ if (aStatusText === undefined || aStatusText === null) {
+ aStatusText = this._statusText;
+ }
+ if (aAvailabilityDetails === undefined || aAvailabilityDetails === null) {
+ aAvailabilityDetails = this._availabilityDetails;
+ }
+
+ // Decide which notifications should be fired.
+ let notifications = [];
+ if (
+ this._statusType != aStatusType ||
+ this._availabilityDetails != aAvailabilityDetails
+ ) {
+ notifications.push("availability-changed");
+ }
+ if (this._statusType != aStatusType || this._statusText != aStatusText) {
+ notifications.push("status-changed");
+ if (this.online && aStatusType <= Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-off");
+ }
+ if (!this.online && aStatusType > Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-on");
+ }
+ }
+
+ // Actually change the stored status.
+ [this._statusType, this._statusText, this._availabilityDetails] = [
+ aStatusType,
+ aStatusText,
+ aAvailabilityDetails,
+ ];
+
+ // Fire the notifications.
+ notifications.forEach(function (aTopic) {
+ this._notifyObservers(aTopic);
+ }, this);
+ },
+
+ _availabilityDetails: 0,
+ get availabilityDetails() {
+ return this._availabilityDetails;
+ },
+
+ get canSendMessage() {
+ return this.online;
+ },
+
+ getTooltipInfo: () => [],
+ createConversation() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+};
+
+// aUserName is required only if aBuddy is null, i.e., we are adding a buddy.
+function AccountBuddy(aAccount, aBuddy, aTag, aUserName) {
+ this._init(aAccount, aBuddy, aTag, aUserName);
+}
+AccountBuddy.prototype = GenericAccountBuddyPrototype;
+
+export var GenericMessagePrototype = {
+ __proto__: ClassInfo("prplIMessage", "generic message object"),
+
+ _lastId: 0,
+ _init(aWho, aMessage, aObject, aConversation) {
+ this.id = ++GenericMessagePrototype._lastId;
+ this.time = Math.floor(new Date() / 1000);
+ this.who = aWho;
+ this.message = aMessage;
+ this.originalMessage = aMessage;
+ this.conversation = aConversation;
+
+ if (aObject) {
+ for (let i in aObject) {
+ this[i] = aObject[i];
+ }
+ }
+ },
+ _alias: "",
+ get alias() {
+ return this._alias || this.who;
+ },
+ _iconURL: "",
+ get iconURL() {
+ // If the protocol plugin has explicitly set an icon for the message, use it.
+ if (this._iconURL) {
+ return this._iconURL;
+ }
+
+ // Otherwise, attempt to find a buddy for incoming messages, and forward the call.
+ if (this.incoming && this.conversation && !this.conversation.isChat) {
+ let buddy = this.conversation.buddy;
+ if (buddy) {
+ return buddy.buddyIconFilename;
+ }
+ }
+ return "";
+ },
+ conversation: null,
+ remoteId: "",
+
+ outgoing: false,
+ incoming: false,
+ system: false,
+ autoResponse: false,
+ containsNick: false,
+ noLog: false,
+ error: false,
+ delayed: false,
+ noFormat: false,
+ containsImages: false,
+ notification: false,
+ noLinkification: false,
+ noCollapse: false,
+ isEncrypted: false,
+ action: false,
+ deleted: false,
+
+ getActions() {
+ return [];
+ },
+
+ whenDisplayed() {},
+ whenRead() {},
+};
+
+export function Message(aWho, aMessage, aObject, aConversation) {
+ this._init(aWho, aMessage, aObject, aConversation);
+}
+
+Message.prototype = GenericMessagePrototype;
+
+export var GenericConversationPrototype = {
+ __proto__: ClassInfo("prplIConversation", "generic conversation object"),
+ get wrappedJSObject() {
+ return this;
+ },
+
+ get DEBUG() {
+ return this._account.DEBUG;
+ },
+ get LOG() {
+ return this._account.LOG;
+ },
+ get WARN() {
+ return this._account.WARN;
+ },
+ get ERROR() {
+ return this._account.ERROR;
+ },
+
+ _init(aAccount, aName) {
+ this._account = aAccount;
+ this._name = aName;
+ this._observers = [];
+ this._date = new Date() * 1000;
+ IMServices.conversations.addConversation(this);
+ },
+
+ _id: 0,
+ get id() {
+ return this._id;
+ },
+ set id(aId) {
+ if (this._id) {
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+ this._id = aId;
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ try {
+ observer.observe(aSubject, aTopic, aData);
+ } catch (e) {
+ this.ERROR(e);
+ }
+ }
+ },
+
+ prepareForSending: aOutgoingMessage => [aOutgoingMessage.message],
+ prepareForDisplaying(aImMessage) {
+ if (aImMessage.displayMessage !== aImMessage.message) {
+ this.DEBUG(
+ "Preparing:\n" +
+ aImMessage.message +
+ "\nDisplaying:\n" +
+ aImMessage.displayMessage
+ );
+ }
+ },
+ sendMsg(aMsg, aAction = false, aNotification = false) {
+ // Add-ons (eg. pastebin) have an opportunity to cancel the message at this
+ // point, or change the text content of the message.
+ // If an add-on wants to split a message, it should truncate the first
+ // message, and insert new messages using the conversation's sendMsg method.
+ let om = new OutgoingMessage(aMsg, this);
+ om.action = aAction;
+ om.notification = aNotification;
+ this.notifyObservers(om, "preparing-message");
+ if (om.cancelled) {
+ return;
+ }
+
+ // Protocols have an opportunity here to preprocess messages before they are
+ // sent (eg. split long messages). If a message is split here, the split
+ // will be visible in the UI.
+ let messages = this.prepareForSending(om);
+ let isAction = om.action;
+ let isNotification = om.notification;
+
+ for (let msg of messages) {
+ // Add-ons (eg. OTR) have an opportunity to tweak or cancel the message
+ // at this point.
+ om = new OutgoingMessage(msg, this);
+ om.action = isAction;
+ om.notification = isNotification;
+ this.notifyObservers(om, "sending-message");
+ if (om.cancelled) {
+ continue;
+ }
+ this.dispatchMessage(om.message, om.action, om.notification);
+ }
+ },
+ dispatchMessage(message, action, notification) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ sendTyping: aString => Ci.prplIConversation.NO_TYPING_LIMIT,
+
+ close() {
+ Services.obs.notifyObservers(this, "closing-conversation");
+ IMServices.conversations.removeConversation(this);
+ },
+ unInit() {
+ delete this._account;
+ delete this._observers;
+ },
+
+ /**
+ * Create a prplIMessage instance from params.
+ *
+ * @param {string} who - Nick of the participant who sent the message.
+ * @param {string} text - Raw message contents.
+ * @param {object} properties - Additional properties of the message.
+ * @returns {prplIMessage}
+ */
+ createMessage(who, text, properties) {
+ return new Message(who, text, properties, this);
+ },
+
+ writeMessage(aWho, aText, aProperties) {
+ const message = this.createMessage(aWho, aText, aProperties);
+ this.notifyObservers(message, "new-text");
+ },
+
+ /**
+ * Update the contents of a message.
+ *
+ * @param {string} who - Nick of the participant who sent the message.
+ * @param {string} text - Raw contents of the message.
+ * @param {object} properties - Additional properties of the message. Should
+ * specify a |remoteId| to find the previous version of this message.
+ */
+ updateMessage(who, text, properties) {
+ const message = this.createMessage(who, text, properties);
+ this.notifyObservers(message, "update-text");
+ },
+
+ /**
+ * Remove a message from the conversation. Does not affect logs, use
+ * updateMessage with a deleted property to remove from logs.
+ *
+ * @param {string} remoteId - Remote ID of the event to remove.
+ */
+ removeMessage(remoteId) {
+ this.notifyObservers(null, "remove-text", remoteId);
+ },
+
+ get account() {
+ return this._account.imAccount;
+ },
+ get name() {
+ return this._name;
+ },
+ get normalizedName() {
+ return this._account.normalize(this.name);
+ },
+ get title() {
+ return this.name;
+ },
+ get startDate() {
+ return this._date;
+ },
+ _convIconFilename: "",
+ get convIconFilename() {
+ return this._convIconFilename;
+ },
+ set convIconFilename(aNewFilename) {
+ this._convIconFilename = aNewFilename;
+ this.notifyObservers(this, "update-conv-icon");
+ },
+
+ get encryptionState() {
+ return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED;
+ },
+ initializeEncryption() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+};
+
+export var GenericConvIMPrototype = {
+ __proto__: GenericConversationPrototype,
+ _interfaces: [Ci.prplIConversation, Ci.prplIConvIM],
+ classDescription: "generic ConvIM object",
+
+ updateTyping(aState, aName) {
+ if (aState == this.typingState) {
+ return;
+ }
+
+ if (aState == Ci.prplIConvIM.NOT_TYPING) {
+ delete this.typingState;
+ } else {
+ this.typingState = aState;
+ }
+ this.notifyObservers(null, "update-typing", aName);
+ },
+
+ get isChat() {
+ return false;
+ },
+ buddy: null,
+ typingState: Ci.prplIConvIM.NOT_TYPING,
+ get convIconFilename() {
+ // By default, pass through information from the buddy for IM conversations
+ // that don't have their own icon.
+ const convIconFilename = this._convIconFilename;
+ if (convIconFilename) {
+ return convIconFilename;
+ }
+ return this.buddy?.buddyIconFilename;
+ },
+};
+
+export var GenericConvChatPrototype = {
+ __proto__: GenericConversationPrototype,
+ _interfaces: [Ci.prplIConversation, Ci.prplIConvChat],
+ classDescription: "generic ConvChat object",
+
+ _init(aAccount, aName, aNick) {
+ // _participants holds prplIConvChatBuddy objects.
+ this._participants = new Map();
+ this.nick = aNick;
+ GenericConversationPrototype._init.call(this, aAccount, aName);
+ },
+
+ get isChat() {
+ return true;
+ },
+
+ // Stores the prplIChatRoomFieldValues required to join this channel
+ // to enable later reconnections. If null, the MUC will not be reconnected
+ // automatically after disconnections.
+ chatRoomFields: null,
+
+ _topic: "",
+ _topicSetter: null,
+ get topic() {
+ return this._topic;
+ },
+ get topicSettable() {
+ return false;
+ },
+ get topicSetter() {
+ return this._topicSetter;
+ },
+ /**
+ * Set the topic of a conversation.
+ *
+ * @param {string} aTopic - The new topic. If an update message is sent to
+ * the conversation, this will be HTML escaped before being sent.
+ * @param {string} aTopicSetter - The user who last modified the topic.
+ * @param {string} aQuiet - If false, a message notifying about the topic
+ * change will be sent to the conversation.
+ */
+ setTopic(aTopic, aTopicSetter, aQuiet) {
+ // Only change the topic if the topic and/or topic setter has changed.
+ if (
+ this._topic == aTopic &&
+ (!this._topicSetter || this._topicSetter == aTopicSetter)
+ ) {
+ return;
+ }
+
+ this._topic = aTopic;
+ this._topicSetter = aTopicSetter;
+
+ this.notifyObservers(null, "chat-update-topic");
+
+ if (aQuiet) {
+ return;
+ }
+
+ // Send the topic as a message.
+ let message;
+ if (aTopicSetter) {
+ if (aTopic) {
+ message = lazy._("topicChanged", aTopicSetter, lazy.TXTToHTML(aTopic));
+ } else {
+ message = lazy._("topicCleared", aTopicSetter);
+ }
+ } else {
+ aTopicSetter = null;
+ if (aTopic) {
+ message = lazy._("topicSet", this.name, lazy.TXTToHTML(aTopic));
+ } else {
+ message = lazy._("topicNotSet", this.name);
+ }
+ }
+ this.writeMessage(aTopicSetter, message, { system: true });
+ },
+
+ get nick() {
+ return this._nick;
+ },
+ set nick(aNick) {
+ this._nick = aNick;
+ let escapedNick = this._nick.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&");
+ this._pingRegexp = new RegExp("(?:^|\\W)" + escapedNick + "(?:\\W|$)", "i");
+ },
+
+ _left: false,
+ get left() {
+ return this._left;
+ },
+ set left(aLeft) {
+ if (aLeft == this._left) {
+ return;
+ }
+ this._left = aLeft;
+ this.notifyObservers(null, "update-conv-chatleft");
+ },
+
+ _joining: false,
+ get joining() {
+ return this._joining;
+ },
+ set joining(aJoining) {
+ if (aJoining == this._joining) {
+ return;
+ }
+ this._joining = aJoining;
+ this.notifyObservers(null, "update-conv-chatjoining");
+ },
+
+ getParticipant(aName) {
+ return this._participants.has(aName) ? this._participants.get(aName) : null;
+ },
+ getParticipants() {
+ // Convert the values of the Map into an array.
+ return Array.from(this._participants.values());
+ },
+ getNormalizedChatBuddyName: aChatBuddyName => aChatBuddyName,
+
+ // Updates the nick of a participant in conversation to a new one.
+ updateNick(aOldNick, aNewNick, isOwnNick) {
+ let message;
+ let isParticipant = this._participants.has(aOldNick);
+ if (isOwnNick) {
+ // If this is the user's nick, change it.
+ this.nick = aNewNick;
+ message = lazy._("nickSet.you", aNewNick);
+
+ // If the account was disconnected, it's OK the user is not a participant.
+ if (!isParticipant) {
+ return;
+ }
+ } else if (!isParticipant) {
+ this.ERROR(
+ "Trying to rename nick that doesn't exist! " +
+ aOldNick +
+ " to " +
+ aNewNick
+ );
+ return;
+ } else {
+ message = lazy._("nickSet", aOldNick, aNewNick);
+ }
+
+ // Get the original participant and then remove it.
+ let participant = this._participants.get(aOldNick);
+ this._participants.delete(aOldNick);
+
+ // Update the nickname and add it under the new nick.
+ participant.name = aNewNick;
+ this._participants.set(aNewNick, participant);
+
+ this.notifyObservers(participant, "chat-buddy-update", aOldNick);
+ this.writeMessage(aOldNick, message, { system: true });
+ },
+
+ // Removes a participant from conversation.
+ removeParticipant(aNick) {
+ if (!this._participants.has(aNick)) {
+ return;
+ }
+
+ let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ stringNickname.data = aNick;
+ this.notifyObservers(
+ new nsSimpleEnumerator([stringNickname]),
+ "chat-buddy-remove"
+ );
+ this._participants.delete(aNick);
+ },
+
+ // Removes all participant in conversation.
+ removeAllParticipants() {
+ let stringNicknames = [];
+ this._participants.forEach(function (aParticipant) {
+ let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ stringNickname.data = aParticipant.name;
+ stringNicknames.push(stringNickname);
+ });
+ this.notifyObservers(
+ new nsSimpleEnumerator(stringNicknames),
+ "chat-buddy-remove"
+ );
+ this._participants.clear();
+ },
+
+ createMessage(who, text, properties) {
+ properties.containsNick =
+ "incoming" in properties && this._pingRegexp.test(text);
+ return GenericConversationPrototype.createMessage.apply(this, arguments);
+ },
+};
+
+export var GenericConvChatBuddyPrototype = {
+ __proto__: ClassInfo("prplIConvChatBuddy", "generic ConvChatBuddy object"),
+
+ _name: "",
+ get name() {
+ return this._name;
+ },
+ set name(aName) {
+ this._name = aName;
+ },
+ alias: "",
+ buddy: false,
+ buddyIconFilename: "",
+
+ voiced: false,
+ moderator: false,
+ admin: false,
+ founder: false,
+ typing: false,
+
+ /**
+ * Method called to start verification of the buddy. Same signature as
+ * _startVerification of GenericSessionPrototype. If the property is not a
+ * function, |canVerifyIdentity| is false.
+ *
+ * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?}
+ */
+ _startVerification: null,
+ get canVerifyIdentity() {
+ return typeof this._startVerification === "function";
+ },
+ _identityVerified: false,
+ get identityVerified() {
+ return this.canVerifyIdentity && this._identityVerified;
+ },
+ verifyIdentity() {
+ if (!this.canVerifyIdentity) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ if (this.identityVerified) {
+ return Promise.resolve();
+ }
+ return this._startVerification().then(
+ ({
+ challenge,
+ challengeDescription,
+ handleResult,
+ cancel,
+ cancelPromise,
+ }) => {
+ const verifier = new SessionVerification(
+ challenge,
+ this.name,
+ challengeDescription
+ );
+ verifier.completePromise.then(
+ result => handleResult(result),
+ () => cancel()
+ );
+ cancelPromise.then(() => verifier.cancel());
+ return verifier;
+ }
+ );
+ },
+};
+
+export function TooltipInfo(aLabel, aValue, aType = Ci.prplITooltipInfo.pair) {
+ this.type = aType;
+ if (aType == Ci.prplITooltipInfo.status) {
+ this.label = aLabel.toString();
+ this.value = aValue || "";
+ } else if (aType == Ci.prplITooltipInfo.icon) {
+ this.value = aValue;
+ } else if (
+ aLabel === undefined ||
+ aType == Ci.prplITooltipInfo.sectionBreak
+ ) {
+ this.type = Ci.prplITooltipInfo.sectionBreak;
+ } else {
+ this.label = aLabel;
+ if (aValue === undefined) {
+ this.type = Ci.prplITooltipInfo.sectionHeader;
+ } else {
+ this.value = aValue;
+ }
+ }
+}
+
+TooltipInfo.prototype = ClassInfo("prplITooltipInfo", "generic tooltip info");
+
+/* aOption is an object containing:
+ * - label: localized text to display (recommended: use a getter with _)
+ * - default: the default value for this option. The type of the
+ * option will be determined based on the type of the default value.
+ * If the default value is a string, the option will be of type
+ * list if listValues has been provided. In that case the default
+ * value should be one of the listed values.
+ * - [optional] listValues: only if this option can only take a list of
+ * predefined values. This is an object of the form:
+ * {value1: localizedLabel, value2: ...}.
+ * - [optional] masked: boolean, if true the UI shouldn't display the value.
+ * This could typically be used for password field.
+ * Warning: The UI currently doesn't support this.
+ */
+function purplePref(aName, aOption) {
+ this.name = aName; // Preference name
+ this.label = aOption.label; // Text to display
+
+ if (aOption.default === undefined || aOption.default === null) {
+ throw new Error(
+ "A default value for the option is required to determine its type."
+ );
+ }
+ this._defaultValue = aOption.default;
+
+ const kTypes = { boolean: "Bool", string: "String", number: "Int" };
+ let type = kTypes[typeof aOption.default];
+ if (!type) {
+ throw new Error("Invalid option type");
+ }
+
+ if (type == "String" && "listValues" in aOption) {
+ type = "List";
+ this._listValues = aOption.listValues;
+ }
+ this.type = Ci.prplIPref["type" + type];
+
+ if ("masked" in aOption && aOption.masked) {
+ this.masked = true;
+ }
+}
+purplePref.prototype = {
+ __proto__: ClassInfo("prplIPref", "generic account option preference"),
+
+ masked: false,
+
+ // Default value
+ getBool() {
+ return this._defaultValue;
+ },
+ getInt() {
+ return this._defaultValue;
+ },
+ getString() {
+ return this._defaultValue;
+ },
+ getList() {
+ // Convert a JavaScript object map {"value 1": "label 1", ...}
+ let keys = Object.keys(this._listValues);
+ return keys.map(key => new purpleKeyValuePair(this._listValues[key], key));
+ },
+ getListDefault() {
+ return this._defaultValue;
+ },
+};
+
+function purpleKeyValuePair(aName, aValue) {
+ this.name = aName;
+ this.value = aValue;
+}
+purpleKeyValuePair.prototype = ClassInfo(
+ "prplIKeyValuePair",
+ "generic Key Value Pair"
+);
+
+function UsernameSplit(aValues) {
+ this._values = aValues;
+}
+UsernameSplit.prototype = {
+ __proto__: ClassInfo("prplIUsernameSplit", "username split object"),
+
+ get label() {
+ return this._values.label;
+ },
+ get separator() {
+ return this._values.separator;
+ },
+ get defaultValue() {
+ return this._values.defaultValue;
+ },
+};
+
+function ChatRoomField(aIdentifier, aField) {
+ this.identifier = aIdentifier;
+ this.label = aField.label;
+ this.required = !!aField.required;
+
+ let type = "TEXT";
+ if (typeof aField.default == "number") {
+ type = "INT";
+ this.min = aField.min;
+ this.max = aField.max;
+ } else if (aField.isPassword) {
+ type = "PASSWORD";
+ }
+ this.type = Ci.prplIChatRoomField["TYPE_" + type];
+}
+ChatRoomField.prototype = ClassInfo(
+ "prplIChatRoomField",
+ "ChatRoomField object"
+);
+
+function ChatRoomFieldValues(aMap) {
+ this.values = aMap;
+}
+ChatRoomFieldValues.prototype = {
+ __proto__: ClassInfo("prplIChatRoomFieldValues", "ChatRoomFieldValues"),
+
+ getValue(aIdentifier) {
+ return this.values.hasOwnProperty(aIdentifier)
+ ? this.values[aIdentifier]
+ : null;
+ },
+ setValue(aIdentifier, aValue) {
+ this.values[aIdentifier] = aValue;
+ },
+};
+
+// the name getter and the getAccount method need to be implemented by
+// protocol plugins.
+export var GenericProtocolPrototype = {
+ __proto__: ClassInfo("prplIProtocol", "Generic protocol object"),
+
+ init(aId) {
+ if (aId != this.id) {
+ throw new Error(
+ "Creating an instance of " +
+ aId +
+ " but this object implements " +
+ this.id
+ );
+ }
+ },
+ get id() {
+ return "prpl-" + this.normalizedName;
+ },
+ get iconBaseURI() {
+ return "chrome://chat/skin/prpl-generic/";
+ },
+
+ getAccount(aImAccount) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ _getOptionDefault(aName) {
+ if (this.options && this.options.hasOwnProperty(aName)) {
+ return this.options[aName].default;
+ }
+ throw new Error(aName + " has no default value in " + this.id + ".");
+ },
+ getOptions() {
+ if (!this.options) {
+ return [];
+ }
+
+ let purplePrefs = [];
+ for (let [name, option] of Object.entries(this.options)) {
+ purplePrefs.push(new purplePref(name, option));
+ }
+ return purplePrefs;
+ },
+ usernamePrefix: "",
+ getUsernameSplit() {
+ if (!this.usernameSplits || !this.usernameSplits.length) {
+ return [];
+ }
+ return this.usernameSplits.map(split => new UsernameSplit(split));
+ },
+
+ /**
+ * Protocol agnostic implementation that splits the username by the pattern
+ * defined with |usernamePrefix| and |usernameSplits| on the protocol.
+ * Prefers the first occurrence of a separator.
+ *
+ * @param {string} aName - Username to split.
+ * @returns {string[]} Parts of the username or empty array if the username
+ * doesn't match the splitting format.
+ */
+ splitUsername(aName) {
+ let remainingName = aName;
+ if (this.usernamePrefix) {
+ if (!remainingName.startsWith(this.usernamePrefix)) {
+ return [];
+ }
+ remainingName = remainingName.slice(this.usernamePrefix.length);
+ }
+ if (!this.usernameSplits || !this.usernameSplits.length) {
+ return [remainingName];
+ }
+ const parts = [];
+ for (const split of this.usernameSplits) {
+ if (!remainingName.includes(split.separator)) {
+ return [];
+ }
+ const separatorIndex = remainingName.indexOf(split.separator);
+ parts.push(remainingName.slice(0, separatorIndex));
+ remainingName = remainingName.slice(
+ separatorIndex + split.separator.length
+ );
+ }
+ parts.push(remainingName);
+ return parts;
+ },
+
+ registerCommands() {
+ if (!this.commands) {
+ return;
+ }
+
+ this.commands.forEach(function (command) {
+ if (!command.hasOwnProperty("name") || !command.hasOwnProperty("run")) {
+ throw new Error("Every command must have a name and a run function.");
+ }
+ if (!("QueryInterface" in command)) {
+ command.QueryInterface = ChromeUtils.generateQI(["imICommand"]);
+ }
+ if (!command.hasOwnProperty("usageContext")) {
+ command.usageContext = Ci.imICommand.CMD_CONTEXT_ALL;
+ }
+ if (!command.hasOwnProperty("priority")) {
+ command.priority = Ci.imICommand.CMD_PRIORITY_PRPL;
+ }
+ IMServices.cmd.registerCommand(command, this.id);
+ }, this);
+ },
+
+ // NS_ERROR_XPC_JSOBJECT_HAS_NO_FUNCTION_NAMED errors are too noisy
+ get usernameEmptyText() {
+ return "";
+ },
+ accountExists: () => false, // FIXME
+
+ get chatHasTopic() {
+ return false;
+ },
+ get noPassword() {
+ return false;
+ },
+ get passwordOptional() {
+ return false;
+ },
+ get slashCommandsNative() {
+ return false;
+ },
+ get canEncrypt() {
+ return false;
+ },
+
+ get classDescription() {
+ return this.name + " Protocol";
+ },
+ get contractID() {
+ return "@mozilla.org/chat/" + this.normalizedName + ";1";
+ },
+};
+
+/**
+ * Text challenge session verification flow. Starts the UI flow.
+ *
+ * @param {string} challenge - String the challenge should display.
+ * @param {string} subject - Human readable identifier of the other side of the
+ * challenge.
+ * @param {string} [challengeDescription] - Description of the challenge
+ * contents.
+ */
+function SessionVerification(challenge, subject, challengeDescription) {
+ this._challenge = challenge;
+ this._subject = subject;
+ if (challengeDescription) {
+ this._description = challengeDescription;
+ }
+ this._responsePromise = new Promise((resolve, reject) => {
+ this._submit = resolve;
+ this._cancel = reject;
+ });
+}
+SessionVerification.prototype = {
+ __proto__: ClassInfo(
+ "imISessionVerification",
+ "generic session verification object"
+ ),
+ _challengeType: Ci.imISessionVerification.CHALLENGE_TEXT,
+ _challenge: "",
+ _description: "",
+ _responsePromise: null,
+ _submit: null,
+ _cancel: null,
+ _cancelled: false,
+ get challengeType() {
+ return this._challengeType;
+ },
+ get challenge() {
+ return this._challenge;
+ },
+ get challengeDescription() {
+ return this._description;
+ },
+ get subject() {
+ return this._subject;
+ },
+ get completePromise() {
+ return this._responsePromise;
+ },
+ submitResponse(challengeMatches) {
+ this._submit(challengeMatches);
+ },
+ cancel() {
+ if (this._cancelled) {
+ return;
+ }
+ this._cancelled = true;
+ this._cancel();
+ },
+};
+
+export var GenericSessionPrototype = {
+ __proto__: ClassInfo("prplISession", "generic session object"),
+ /**
+ * Initialize the session.
+ *
+ * @param {prplIAccount} account - Account the session is related to.
+ * @param {string} id - ID of the session.
+ * @param {boolean} [trusted=false] - If the session is trusted.
+ * @param {boolean} [currentSession=false] - If the session represents the.
+ * session we're connected as.
+ */
+ _init(account, id, trusted = false, currentSession = false) {
+ this._account = account;
+ this._id = id;
+ this._trusted = trusted;
+ this._currentSession = currentSession;
+ },
+ _account: null,
+ _id: "",
+ _trusted: false,
+ _currentSession: false,
+ get id() {
+ return this._id;
+ },
+ get trusted() {
+ return this._trusted;
+ },
+ set trusted(newTrust) {
+ this._trusted = newTrust;
+ this._account.reportSessionsChanged();
+ },
+ get currentSession() {
+ return this._currentSession;
+ },
+ /**
+ * Handle the start of the session verification process. The protocol is
+ * expected to update the trusted property on the session if it becomes
+ * trusted after verification.
+ *
+ * @returns {Promise<{challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise<void>}>}
+ * Promise resolves to an object holding the challenge string, as well as a
+ * callback that handles the result of the verification flow. The cancel
+ * callback is called when the verification is cancelled and the cancelPromise
+ * is used for the protocol to report when the other side cancels.
+ * The cancel callback will be called when the cancel promise resolves.
+ */
+ _startVerification() {
+ return Promise.reject(
+ Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED)
+ );
+ },
+ verify() {
+ if (this.trusted) {
+ return Promise.resolve();
+ }
+ return this._startVerification().then(
+ ({
+ challenge,
+ challengeDescription,
+ handleResult,
+ cancel,
+ cancelPromise,
+ }) => {
+ const verifier = new SessionVerification(
+ challenge,
+ this.id,
+ challengeDescription
+ );
+ verifier.completePromise.then(
+ result => handleResult(result),
+ () => cancel()
+ );
+ cancelPromise.then(() => verifier.cancel());
+ return verifier;
+ }
+ );
+ },
+};
diff --git a/comm/chat/modules/moz.build b/comm/chat/modules/moz.build
new file mode 100644
index 0000000000..b3ae019739
--- /dev/null
+++ b/comm/chat/modules/moz.build
@@ -0,0 +1,25 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "CLib.sys.mjs",
+ "imContentSink.sys.mjs",
+ "IMServices.sys.mjs",
+ "imSmileys.sys.mjs",
+ "imStatusUtils.sys.mjs",
+ "imTextboxUtils.sys.mjs",
+ "imThemes.sys.mjs",
+ "imXPCOMUtils.sys.mjs",
+ "InteractiveBrowser.sys.mjs",
+ "jsProtoHelper.sys.mjs",
+ "NormalizedMap.sys.mjs",
+ "OTR.sys.mjs",
+ "OTRLib.sys.mjs",
+ "OTRUI.sys.mjs",
+ "socket.sys.mjs",
+ "ToLocaleFormat.sys.mjs",
+]
diff --git a/comm/chat/modules/socket.sys.mjs b/comm/chat/modules/socket.sys.mjs
new file mode 100644
index 0000000000..9253e0e96b
--- /dev/null
+++ b/comm/chat/modules/socket.sys.mjs
@@ -0,0 +1,644 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Combines a lot of the Mozilla networking interfaces into a sane interface for
+ * simple(r) handling of a low-level socket which sends text content.
+ *
+ * This implements nsIStreamListener, nsIRequestObserver, nsITransportEventSink
+ * and nsIProtocolProxyCallback.
+ *
+ * This uses nsIRoutedSocketTransportService, nsIServerSocket, nsIThreadManager,
+ * nsIScriptableInputStream, nsIInputStreamPump, nsIProxyService, nsIProxyInfo.
+ *
+ * High-level methods:
+ * connect(<originHost>, <originPort>[, ("starttls" | "ssl" | "udp")
+ * [, <proxy>[, <host>, <port>]]])
+ * disconnect()
+ * sendData(String <data>[, <logged data>])
+ * sendString(String <data>[, <encoding>[, <logged data>]])
+ * startTLS()
+ * resetPingTimer()
+ * cancelDisconnectTimer()
+ *
+ * High-level properties:
+ * delimiter
+ * inputSegmentSize
+ * outputSegmentSize
+ * proxyFlags
+ * connectTimeout (default is no timeout)
+ * readWriteTimeout (default is no timeout)
+ * disconnected
+ * securityInfo
+ *
+ * Users should "subclass" this object, i.e. set their .__proto__ to be it. And
+ * then implement:
+ * onConnection()
+ * onConnectionHeard()
+ * onConnectionTimedOut()
+ * onConnectionReset()
+ * onConnectionSecurityError(unsigned long aTLSError, optional AString aNSSErrorMessage)
+ * onConnectionClosed()
+ * onDataReceived(String <data>)
+ * onTransportStatus(nsISocketTransport <transport>, nsresult <status>,
+ * unsigned long <progress>, unsigned long <progress max>)
+ * sendPing()
+ * LOG(<message>)
+ * DEBUG(<message>)
+ *
+ * Optional features:
+ * The ping functionality: Included in the socket object is a higher level
+ * "ping" messaging system, which is commonly used in instant messaging
+ * protocols. The ping functionality works by calling a user defined method,
+ * sendPing(), if resetPingTimer() is not called after two minutes. If no
+ * ping response is received after 30 seconds, the socket will disconnect.
+ * Thus, a socket using this functionality should:
+ * 1. Implement sendPing() to send an appropriate ping message for the
+ * protocol.
+ * 2. Call resetPingTimer() to start the ping messages.
+ * 3. Call resetPingTimer() each time a message is received (i.e. the
+ * socket is known to still be alive).
+ * 4. Call cancelDisconnectTimer() when a ping response is received.
+ */
+
+/*
+ * To Do:
+ * Add a message queue to keep from flooding a server (just an array, just
+ * keep shifting the first element off and calling as setTimeout for the
+ * desired flood time?).
+ */
+
+import { executeSoon } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ clearTimeout,
+ requestIdleCallback,
+ setTimeout,
+} from "resource://gre/modules/Timer.sys.mjs";
+
+// Network errors see: xpcom/base/nsError.h
+var NS_ERROR_MODULE_NETWORK = 2152398848;
+var NS_ERROR_NET_TIMEOUT = NS_ERROR_MODULE_NETWORK + 14;
+var NS_ERROR_NET_RESET = NS_ERROR_MODULE_NETWORK + 20;
+var NS_ERROR_UNKNOWN_HOST = NS_ERROR_MODULE_NETWORK + 30;
+
+var ScriptableInputStream = Components.Constructor(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+);
+var InputStreamPump = Components.Constructor(
+ "@mozilla.org/network/input-stream-pump;1",
+ "nsIInputStreamPump",
+ "init"
+);
+var ScriptableUnicodeConverter = Components.Constructor(
+ "@mozilla.org/intl/scriptableunicodeconverter",
+ "nsIScriptableUnicodeConverter"
+);
+
+/**
+ * @implements {nsIStreamListener}
+ * @implements {nsIRequestObserver}
+ * @implements {nsITransportEventSink}
+ * @implements {nsIProtocolProxyCallback}
+ */
+export var Socket = {
+ // Set this for non-binary mode to automatically parse the stream into chunks
+ // separated by delimiter.
+ delimiter: "",
+
+ // Set this for the segment size of outgoing binary streams.
+ outputSegmentSize: 0,
+
+ // Flags used by nsIProxyService when resolving a proxy.
+ proxyFlags: Ci.nsIProtocolProxyService.RESOLVE_PREFER_SOCKS_PROXY,
+
+ // Time (in seconds) for nsISocketTransport to continue trying before
+ // reporting a failure, 0 is forever.
+ connectTimeout: 0,
+ readWriteTimeout: 0,
+
+ // A nsITransportSecurityInfo instance giving details about the certificate error.
+ securityInfo: null,
+
+ /*
+ *****************************************************************************
+ ******************************* Public methods ******************************
+ *****************************************************************************
+ */
+ // Synchronously open a connection.
+ // It connects to aHost and aPort, but uses aOriginHost and aOriginPort for
+ // checking the certificate for them (see nsIRoutedSocketTransportService
+ // in nsISocketTransportService.idl).
+ connect(
+ aOriginHost,
+ aOriginPort,
+ aSecurity,
+ aProxy,
+ aHost = aOriginHost,
+ aPort = aOriginPort
+ ) {
+ if (Services.io.offline) {
+ throw Components.Exception("Offline, can't connect", Cr.NS_ERROR_FAILURE);
+ }
+
+ // This won't work for Linux due to bug 758848.
+ Services.obs.addObserver(this, "wake_notification");
+
+ this.LOG("Connecting to: " + aHost + ":" + aPort);
+ this.originHost = aOriginHost;
+ this.originPort = aOriginPort;
+ this.host = aHost;
+ this.port = aPort;
+ this.disconnected = false;
+
+ this._pendingData = [];
+ delete this._stopRequestStatus;
+
+ // Array of security options
+ this.security = aSecurity || [];
+
+ // Choose a proxy, use the given one, otherwise get one from the proxy
+ // service
+ if (aProxy) {
+ this._createTransport(aProxy);
+ } else {
+ try {
+ // Attempt to get a default proxy from the proxy service.
+ let proxyService = Cc[
+ "@mozilla.org/network/protocol-proxy-service;1"
+ ].getService(Ci.nsIProtocolProxyService);
+
+ // Add a URI scheme since, by default, some protocols (i.e. IRC) don't
+ // have a URI scheme before the host.
+ let uri = Services.io.newURI("http://" + this.host);
+ // This will return null when the result is known immediately and
+ // the callback will just be dispatched to the current thread.
+ this._proxyCancel = proxyService.asyncResolve(
+ uri,
+ this.proxyFlags,
+ this
+ );
+ } catch (e) {
+ console.error(e);
+ // We had some error getting the proxy service, just don't use one.
+ this._createTransport(null);
+ }
+ }
+ },
+
+ // Disconnect all open streams.
+ disconnect() {
+ this.LOG("Disconnect");
+
+ // Don't handle any remaining unhandled data.
+ this._pendingData = [];
+
+ // Close all input and output streams.
+ if ("_inputStream" in this) {
+ this._inputStream.close();
+ delete this._inputStream;
+ }
+ if ("_outputStream" in this) {
+ this._outputStream.close();
+ delete this._outputStream;
+ }
+ if ("transport" in this) {
+ this.transport.close(Cr.NS_OK);
+ delete this.transport;
+ }
+
+ if ("_proxyCancel" in this) {
+ if (this._proxyCancel) {
+ // Has to give a failure code.
+ this._proxyCancel.cancel(Cr.NS_ERROR_ABORT);
+ }
+ delete this._proxyCancel;
+ }
+
+ if (this._pingTimer) {
+ clearTimeout(this._pingTimer);
+ delete this._pingTimer;
+ delete this._resetPingTimerPending;
+ }
+ this.cancelDisconnectTimer();
+
+ delete this._lastAliveTime;
+ Services.obs.removeObserver(this, "wake_notification");
+
+ this.disconnected = true;
+ },
+
+ // Send data on the output stream. Provide aLoggedData to log something
+ // different than what is actually sent.
+ sendData(/* string */ aData, aLoggedData = aData) {
+ this.LOG("Sending:\n" + aLoggedData);
+
+ try {
+ this._outputStream.write(aData, aData.length);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ // Send a string to the output stream after converting the encoding. Provide
+ // aLoggedData to log something different than what is actually sent.
+ sendString(aString, aEncoding = "UTF-8", aLoggedData = aString) {
+ this.LOG("Sending:\n" + aLoggedData);
+
+ let converter = new ScriptableUnicodeConverter();
+ converter.charset = aEncoding;
+ try {
+ let stream = converter.convertToInputStream(aString);
+ this._outputStream.writeFrom(stream, stream.available());
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ disconnected: true,
+
+ startTLS() {
+ this.transport.tlsSocketControl
+ .QueryInterface(Ci.nsITLSSocketControl)
+ .StartTLS();
+ },
+
+ // If using the ping functionality, this should be called whenever a message is
+ // received (e.g. when it is known the socket is still open). Calling this for
+ // the first time enables the ping functionality.
+ resetPingTimer() {
+ // Clearing and setting timeouts is expensive, so we do it at most
+ // once per eventloop spin cycle.
+ if (this._resetPingTimerPending) {
+ return;
+ }
+ this._resetPingTimerPending = true;
+ executeSoon(this._delayedResetPingTimer.bind(this));
+ },
+ kTimeBeforePing: 120000, // 2 min
+ kTimeAfterPingBeforeDisconnect: 30000, // 30 s
+ _delayedResetPingTimer() {
+ if (!this._resetPingTimerPending) {
+ return;
+ }
+ delete this._resetPingTimerPending;
+ if (this._pingTimer) {
+ clearTimeout(this._pingTimer);
+ }
+ // Send a ping every 2 minutes if there's no traffic on the socket.
+ this._pingTimer = setTimeout(
+ this._sendPing.bind(this),
+ this.kTimeBeforePing
+ );
+ },
+
+ // If using the ping functionality, this should be called when a ping receives
+ // a response.
+ cancelDisconnectTimer() {
+ if (!this._disconnectTimer) {
+ return;
+ }
+ clearTimeout(this._disconnectTimer);
+ delete this._disconnectTimer;
+ },
+
+ // Plenty of time may have elapsed if the computer wakes from sleep, so check
+ // if we should reconnect immediately.
+ _lastAliveTime: null,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "wake_notification") {
+ return;
+ }
+ let elapsedTime = Date.now() - this._lastAliveTime;
+ // If there never was any activity before we went to sleep,
+ // or if we've been waiting for a ping response for over 30s,
+ // or if the last activity on the socket is longer ago than we usually
+ // allow before we timeout,
+ // declare the connection timed out immediately.
+ if (
+ !this._lastAliveTime ||
+ (this._disconnectTimer &&
+ elapsedTime > this.kTimeAfterPingBeforeDisconnect) ||
+ elapsedTime > this.kTimeBeforePing + this.kTimeAfterPingBeforeDisconnect
+ ) {
+ this.onConnectionTimedOut();
+ } else if (this._pingTimer) {
+ // If there was a ping timer running when the computer went to sleep,
+ // ping immediately to discover if we are still connected.
+ clearTimeout(this._pingTimer);
+ this._sendPing();
+ }
+ },
+
+ /*
+ *****************************************************************************
+ ***************************** Interface methods *****************************
+ *****************************************************************************
+ */
+ /*
+ * nsIProtocolProxyCallback methods
+ */
+ onProxyAvailable(aRequest, aURI, aProxyInfo, aStatus) {
+ if (!("_proxyCancel" in this)) {
+ this.LOG("onProxyAvailable called, but disconnect() was called before.");
+ return;
+ }
+
+ if (aProxyInfo) {
+ if (aProxyInfo.type == "http") {
+ this.LOG("ignoring http proxy");
+ aProxyInfo = null;
+ } else {
+ this.LOG(
+ "using " +
+ aProxyInfo.type +
+ " proxy: " +
+ aProxyInfo.host +
+ ":" +
+ aProxyInfo.port
+ );
+ }
+ }
+ this._createTransport(aProxyInfo);
+ delete this._proxyCancel;
+ },
+
+ /*
+ * nsIStreamListener methods
+ */
+ // onDataAvailable, called by Mozilla's networking code.
+ // Buffers the data, and parses it into discrete messages.
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (this.disconnected) {
+ return;
+ }
+ this._lastAliveTime = Date.now();
+
+ if (this.delimiter) {
+ // Load the data from the stream.
+ this._incomingDataBuffer += this._scriptableInputStream.read(aCount);
+ let data = this._incomingDataBuffer.split(this.delimiter);
+
+ // Store the (possibly) incomplete part.
+ this._incomingDataBuffer = data.pop();
+ if (!data.length) {
+ return;
+ }
+
+ // Add the strings to the queue.
+ this._pendingData = this._pendingData.concat(data);
+ } else {
+ // Add the whole string to the queue.
+ this._pendingData.push(this._scriptableInputStream.read(aCount));
+ }
+ this._activateQueue();
+ },
+
+ _pendingData: [],
+ _handlingQueue: false,
+ _activateQueue() {
+ if (this._handlingQueue) {
+ return;
+ }
+ this._handlingQueue = requestIdleCallback(this._handleQueue.bind(this));
+ },
+ // Asynchronously send each string to the handle data function.
+ async _handleQueue(timing) {
+ while (this._pendingData.length) {
+ this.onDataReceived(this._pendingData.shift());
+ // One pendingData entry generally takes less than 1ms to handle.
+ if (timing.timeRemaining() < 1) {
+ break;
+ }
+ }
+ if (this._pendingData.length) {
+ this._handlingQueue = requestIdleCallback(this._handleQueue.bind(this));
+ return;
+ }
+ delete this._handlingQueue;
+ // If there was a stop request, handle it.
+ if ("_stopRequestStatus" in this) {
+ await this._handleStopRequest(this._stopRequestStatus);
+ }
+ },
+
+ /*
+ * nsIRequestObserver methods
+ */
+ // Signifies the beginning of an async request
+ onStartRequest(aRequest) {
+ if (this.disconnected) {
+ // Ignore this if we're already disconnected.
+ return;
+ }
+ this.DEBUG("onStartRequest");
+ },
+ // Called to signify the end of an asynchronous request.
+ onStopRequest(aRequest, aStatus) {
+ if (this.disconnected) {
+ // We're already disconnected, so nothing left to do here.
+ return;
+ }
+
+ this.DEBUG("onStopRequest (" + aStatus + ")");
+ this._stopRequestStatus = aStatus;
+ // The stop request will be handled when the queue is next empty.
+ this._activateQueue();
+ },
+ // Close the connection after receiving a stop request.
+ async _handleStopRequest(aStatus) {
+ if (this.disconnected) {
+ return;
+ }
+ this.disconnected = true;
+ // If the host cannot be resolved, reset the connection to attempt to
+ // reconnect.
+ if (aStatus == NS_ERROR_NET_RESET || aStatus == NS_ERROR_UNKNOWN_HOST) {
+ this.onConnectionReset();
+ } else if (aStatus == NS_ERROR_NET_TIMEOUT) {
+ this.onConnectionTimedOut();
+ } else if (!Components.isSuccessCode(aStatus)) {
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ this.securityInfo =
+ await this.transport.tlsSocketControl?.asyncGetSecurityInfo();
+ this.onConnectionSecurityError(
+ aStatus,
+ nssErrorsService.getErrorMessage(aStatus)
+ );
+ }
+ this.onConnectionClosed();
+ },
+
+ /*
+ * nsITransportEventSink methods
+ */
+ onTransportStatus(aTransport, aStatus, aProgress, aProgressmax) {
+ // Don't send status change notifications after the socket has been closed.
+ // The event sink can't be removed after opening the transport, so we can't
+ // do better than adding a null check here.
+ if (!this.transport) {
+ return;
+ }
+
+ const nsITransportEventSinkStatus = {
+ 0x4b0003: "STATUS_RESOLVING",
+ 0x4b000b: "STATUS_RESOLVED",
+ 0x4b0007: "STATUS_CONNECTING_TO",
+ 0x4b0004: "STATUS_CONNECTED_TO",
+ 0x4b0005: "STATUS_SENDING_TO",
+ 0x4b000a: "STATUS_WAITING_FOR",
+ 0x4b0006: "STATUS_RECEIVING_FROM",
+ };
+ let status = nsITransportEventSinkStatus[aStatus];
+ this.DEBUG(
+ "onTransportStatus(" + (status || "0x" + aStatus.toString(16)) + ")"
+ );
+
+ if (status == "STATUS_CONNECTED_TO") {
+ // Notify that the connection has been established.
+ this.onConnection();
+ }
+ },
+
+ /*
+ *****************************************************************************
+ ****************************** Private methods ******************************
+ *****************************************************************************
+ */
+ _resetBuffers() {
+ this._incomingDataBuffer = "";
+ this._outgoingDataBuffer = [];
+ },
+
+ _createTransport(aProxy) {
+ this.proxy = aProxy;
+
+ // Empty incoming and outgoing data storage buffers
+ this._resetBuffers();
+
+ // Create a routed socket transport
+ // We connect to host and port, but the origin host and origin port are
+ // given to PSM (e.g. check the certificate).
+ let socketTS = Cc[
+ "@mozilla.org/network/socket-transport-service;1"
+ ].getService(Ci.nsIRoutedSocketTransportService);
+ this.transport = socketTS.createRoutedTransport(
+ this.security,
+ this.originHost,
+ this.originPort,
+ this.host,
+ this.port,
+ this.proxy,
+ null
+ );
+
+ this._openStreams();
+ },
+
+ // Open the incoming and outgoing streams, and init the nsISocketTransport.
+ _openStreams() {
+ // TODO: is this still required after bug 1547096?
+ this.transport.securityCallbacks = this;
+
+ // Set the timeouts for the nsISocketTransport for both a connect event and
+ // a read/write. Only set them if the user has provided them.
+ if (this.connectTimeout) {
+ this.transport.setTimeout(
+ Ci.nsISocketTransport.TIMEOUT_CONNECT,
+ this.connectTimeout
+ );
+ }
+ if (this.readWriteTimeout) {
+ this.transport.setTimeout(
+ Ci.nsISocketTransport.TIMEOUT_READ_WRITE,
+ this.readWriteTimeout
+ );
+ }
+
+ this.transport.setEventSink(this, Services.tm.currentThread);
+
+ // No limit on the output stream buffer
+ this._outputStream = this.transport.openOutputStream(
+ 0,
+ this.outputSegmentSize,
+ -1
+ );
+ if (!this._outputStream) {
+ throw new Error("Error getting output stream.");
+ }
+
+ this._inputStream = this.transport.openInputStream(
+ 0, // flags
+ 0, // Use default segment size
+ 0
+ ); // Use default segment count
+ if (!this._inputStream) {
+ throw new Error("Error getting input stream.");
+ }
+
+ // Handle character mode
+ this._scriptableInputStream = new ScriptableInputStream(this._inputStream);
+
+ this.pump = new InputStreamPump(
+ this._inputStream, // Data to read
+ 0, // Use default segment size
+ 0, // Use default segment length
+ false
+ ); // Do not close when done
+ this.pump.asyncRead(this);
+ },
+
+ _pingTimer: null,
+ _disconnectTimer: null,
+ _sendPing() {
+ delete this._pingTimer;
+ this.sendPing();
+ this._disconnectTimer = setTimeout(
+ this.onConnectionTimedOut.bind(this),
+ this.kTimeAfterPingBeforeDisconnect
+ );
+ },
+
+ /*
+ *****************************************************************************
+ ********************* Methods for subtypes to override **********************
+ *****************************************************************************
+ */
+ LOG(aString) {},
+ DEBUG(aString) {},
+ // Called when a connection is established.
+ onConnection() {},
+ // Called when a socket is accepted after listening.
+ onConnectionHeard() {},
+ // Called when a connection times out.
+ onConnectionTimedOut() {},
+ // Called when a socket request's network is reset.
+ onConnectionReset() {},
+ // Called when the certificate provided by the server didn't satisfy NSS.
+ onConnectionSecurityError(aTLSError, aNSSErrorMessage) {},
+ // Called when the other end has closed the connection.
+ onConnectionClosed() {},
+
+ // Called when ASCII data is available.
+ onDataReceived(/* string */ aData) {},
+
+ // If using the ping functionality, this is called when a new ping message
+ // should be sent on the socket.
+ sendPing() {},
+
+ /* QueryInterface and nsIInterfaceRequestor implementations */
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ "nsITransportEventSink",
+ "nsIProtocolProxyCallback",
+ ]),
+
+ getInterface(iid) {
+ return this.QueryInterface(iid);
+ },
+};
diff --git a/comm/chat/modules/test/test_InteractiveBrowser.js b/comm/chat/modules/test/test_InteractiveBrowser.js
new file mode 100644
index 0000000000..eb39d7048b
--- /dev/null
+++ b/comm/chat/modules/test/test_InteractiveBrowser.js
@@ -0,0 +1,280 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { InteractiveBrowser, CancelledError } = ChromeUtils.importESModule(
+ "resource:///modules/InteractiveBrowser.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+add_task(async function test_waitForRedirectOnLocationChange() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ let resolved = false;
+ const request = InteractiveBrowser.waitForRedirect(url, promptText).then(
+ redirectUrl => {
+ resolved = true;
+ return redirectUrl;
+ }
+ );
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ await TestUtils.waitForTick();
+ ok(webProgress.listener, "Progress listener added");
+ equal(window.document.title, promptText, "Window title set");
+
+ const intermediate = "https://intermediate.example.com/";
+ webProgress.listener.onLocationChange(
+ webProgress,
+ {
+ name: intermediate + 1,
+ },
+ {
+ spec: intermediate + 1,
+ }
+ );
+ ok(
+ webProgress.listener,
+ "Progress listener still there after intermediary redirect"
+ );
+ ok(!resolved, "Still waiting for redirect");
+ webProgress.listener.onStateChange(
+ webProgress,
+ {
+ name: intermediate + 2,
+ },
+ Ci.nsIWebProgressListener.STATE_START,
+ null
+ );
+ ok(webProgress.listener, "Listener still there after second redirect");
+ ok(!resolved, "Still waiting for redirect 2");
+
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf";
+ webProgress.listener.onLocationChange(
+ webProgress,
+ {
+ name: completionUrl,
+ },
+ {
+ spec: completionUrl,
+ }
+ );
+
+ const redirectedUrl = await request;
+ ok(resolved, "Redirect complete");
+ equal(redirectedUrl, completionUrl);
+
+ ok(!webProgress.listener);
+ ok(window.closed);
+});
+
+add_task(async function test_waitForRedirectOnStateChangeStart() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ let resolved = false;
+ const request = InteractiveBrowser.waitForRedirect(url, promptText).then(
+ redirectUrl => {
+ resolved = true;
+ return redirectUrl;
+ }
+ );
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ await TestUtils.waitForTick();
+ ok(webProgress.listener, "Progress listener added");
+ equal(window.document.title, promptText, "Window title set");
+
+ const intermediate = "https://intermediate.example.com/";
+ webProgress.listener.onStateChange(
+ webProgress,
+ {
+ name: intermediate,
+ },
+ Ci.nsIWebProgressListener.STATE_START,
+ null
+ );
+ ok(webProgress.listener);
+ ok(!resolved);
+
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf";
+ webProgress.listener.onStateChange(
+ webProgress,
+ {
+ name: completionUrl,
+ },
+ Ci.nsIWebProgressListener.STATE_START
+ );
+
+ const redirectedUrl = await request;
+ ok(resolved, "Redirect complete");
+ equal(redirectedUrl, completionUrl);
+
+ ok(!webProgress.listener);
+ ok(window.closed);
+});
+
+add_task(async function test_waitForRedirectOnStateChangeStart() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ let resolved = false;
+ const request = InteractiveBrowser.waitForRedirect(url, promptText).then(
+ redirectUrl => {
+ resolved = true;
+ return redirectUrl;
+ }
+ );
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ await TestUtils.waitForTick();
+ ok(webProgress.listener, "Progress listener added");
+ equal(window.document.title, promptText, "Window title set");
+
+ const intermediate = "https://intermediate.example.com/";
+ webProgress.listener.onStateChange(
+ webProgress,
+ {
+ name: intermediate,
+ },
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK,
+ null
+ );
+ ok(webProgress.listener);
+ ok(!resolved);
+
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf";
+ webProgress.listener.onStateChange(
+ webProgress,
+ {
+ name: completionUrl,
+ },
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ );
+
+ const redirectedUrl = await request;
+ ok(resolved, "Redirect complete");
+ equal(redirectedUrl, completionUrl);
+
+ ok(!webProgress.listener);
+ ok(window.closed);
+});
+
+add_task(async function test_waitForRedirectCancelled() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ const request = InteractiveBrowser.waitForRedirect(url, promptText);
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.cancelled();
+
+ await rejects(request, CancelledError);
+});
+
+add_task(async function test_waitForRedirectImmediatelyAborted() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ const request = InteractiveBrowser.waitForRedirect(url, promptText);
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ subject.wrappedJSObject.cancelled();
+ await TestUtils.waitForTick();
+ ok(!webProgress.listener);
+
+ await rejects(request, CancelledError);
+});
+
+add_task(async function test_waitForRedirectAbortEvent() {
+ const url = "https://example.com";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ const request = InteractiveBrowser.waitForRedirect(url, promptText);
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ await TestUtils.waitForTick();
+ ok(webProgress.listener);
+ equal(window.document.title, promptText);
+
+ subject.wrappedJSObject.cancelled();
+ await rejects(request, CancelledError);
+ ok(!webProgress.listener);
+ ok(window.closed);
+});
+
+add_task(async function test_waitForRedirectAlreadyArrived() {
+ const url = "https://example.com";
+ const completionUrl = InteractiveBrowser.COMPLETION_URL + "/test?code=asdf";
+ const promptText = "lorem ipsum";
+ const { window, webProgress } = getRequestStubs();
+ window.initialURI = completionUrl;
+
+ const observeTopic = TestUtils.topicObserved("browser-request");
+ let resolved = false;
+ const request = InteractiveBrowser.waitForRedirect(url, promptText).then(
+ redirectUrl => {
+ resolved = true;
+ return redirectUrl;
+ }
+ );
+ const [subject] = await observeTopic;
+
+ subject.wrappedJSObject.loaded(window, webProgress);
+ const redirectedUrl = await request;
+
+ equal(window.document.title, promptText, "Window title set");
+ ok(resolved, "Redirect complete");
+ equal(redirectedUrl, completionUrl);
+
+ ok(!webProgress.listener);
+ ok(window.closed);
+});
+
+function getRequestStubs() {
+ const mocks = {
+ window: {
+ close() {
+ this.closed = true;
+ },
+ document: {
+ getElementById() {
+ return {
+ currentURI: {
+ spec: mocks.window.initialURI,
+ },
+ };
+ },
+ },
+ initialURI: "",
+ },
+ webProgress: {
+ addProgressListener(listener) {
+ this.listener = listener;
+ },
+ removeProgressListener(listener) {
+ if (this.listener === listener) {
+ delete this.listener;
+ }
+ },
+ },
+ };
+ return mocks;
+}
diff --git a/comm/chat/modules/test/test_NormalizedMap.js b/comm/chat/modules/test/test_NormalizedMap.js
new file mode 100644
index 0000000000..cad5bcd4d8
--- /dev/null
+++ b/comm/chat/modules/test/test_NormalizedMap.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { NormalizedMap } = ChromeUtils.importESModule(
+ "resource:///modules/NormalizedMap.sys.mjs"
+);
+
+function test_setter_getter() {
+ let m = new NormalizedMap(aStr => aStr.toLowerCase());
+ m.set("foo", "bar");
+ m.set("BaZ", "blah");
+ Assert.equal(m.has("FOO"), true);
+ Assert.equal(m.has("BaZ"), true);
+ Assert.equal(m.get("FOO"), "bar");
+
+ let keys = Array.from(m.keys());
+ Assert.equal(keys[0], "foo");
+ Assert.equal(keys[1], "baz");
+
+ let values = Array.from(m.values());
+ Assert.equal(values[0], "bar");
+ Assert.equal(values[1], "blah");
+
+ Assert.equal(m.size, 2);
+
+ run_next_test();
+}
+
+function test_constructor() {
+ let k = new NormalizedMap(
+ aStr => aStr.toLowerCase(),
+ [
+ ["A", 2],
+ ["b", 3],
+ ]
+ );
+ Assert.equal(k.get("b"), 3);
+ Assert.equal(k.get("a"), 2);
+ Assert.equal(k.get("B"), 3);
+ Assert.equal(k.get("A"), 2);
+
+ run_next_test();
+}
+
+function test_iterator() {
+ let k = new NormalizedMap(aStr => aStr.toLowerCase());
+ k.set("FoO", "bar");
+
+ for (let [key, value] of k) {
+ Assert.equal(key, "foo");
+ Assert.equal(value, "bar");
+ }
+
+ run_next_test();
+}
+
+function test_delete() {
+ let m = new NormalizedMap(aStr => aStr.toLowerCase());
+ m.set("foo", "bar");
+ m.set("BaZ", "blah");
+
+ Assert.equal(m.delete("blah"), false);
+
+ Assert.equal(m.delete("FOO"), true);
+ Assert.equal(m.size, 1);
+
+ Assert.equal(m.delete("baz"), true);
+ Assert.equal(m.size, 0);
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(test_setter_getter);
+ add_test(test_constructor);
+ add_test(test_iterator);
+ add_test(test_delete);
+
+ run_next_test();
+}
diff --git a/comm/chat/modules/test/test_filtering.js b/comm/chat/modules/test/test_filtering.js
new file mode 100644
index 0000000000..33c8fcf262
--- /dev/null
+++ b/comm/chat/modules/test/test_filtering.js
@@ -0,0 +1,479 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// These tests run into issues if there isn't a profile directory, see bug 1542397.
+do_get_profile();
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var {
+ cleanupImMarkup,
+ createDerivedRuleset,
+ addGlobalAllowedTag,
+ removeGlobalAllowedTag,
+ addGlobalAllowedAttribute,
+ removeGlobalAllowedAttribute,
+ addGlobalAllowedStyleRule,
+ removeGlobalAllowedStyleRule,
+} = ChromeUtils.importESModule("resource:///modules/imContentSink.sys.mjs");
+
+var kModePref = "messenger.options.filterMode";
+var kStrictMode = 0,
+ kStandardMode = 1,
+ kPermissiveMode = 2;
+
+function run_test() {
+ let defaultMode = Services.prefs.getIntPref(kModePref);
+
+ add_test(test_strictMode);
+ add_test(test_standardMode);
+ add_test(test_permissiveMode);
+ add_test(test_addGlobalAllowedTag);
+ add_test(test_addGlobalAllowedAttribute);
+ add_test(test_addGlobalAllowedStyleRule);
+ add_test(test_createDerivedRuleset);
+
+ Services.prefs.setIntPref(kModePref, defaultMode);
+ run_next_test();
+}
+
+// Sanity check: a string without HTML markup shouldn't be modified.
+function test_plainText() {
+ const strings = [
+ "foo",
+ "foo ", // preserve trailing whitespace
+ " foo", // preserve leading indent
+ "&lt;html&gt;&amp;", // keep escaped characters
+ ];
+ for (let string of strings) {
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+}
+
+function test_paragraphs() {
+ const strings = ["<p>foo</p><p>bar</p>", "<p>foo<br>bar</p>", "foo<br>bar"];
+ for (let string of strings) {
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+}
+
+function test_stripScripts() {
+ const strings = [
+ ["<script>alert('hey')</script>", ""],
+ ["foo <script>alert('hey')</script>", "foo "],
+ ["<p onclick=\"alert('hey')\">foo</p>", "<p>foo</p>"],
+ ["<p onmouseover=\"alert('hey')\">foo</p>", "<p>foo</p>"],
+ ];
+ for (let [input, expectedOutput] of strings) {
+ Assert.equal(expectedOutput, cleanupImMarkup(input));
+ }
+}
+
+function test_links() {
+ // http, https, ftp and mailto links should be preserved.
+ const ok = [
+ "http://example.com/",
+ "https://example.com/",
+ "ftp://example.com/",
+ "mailto:foo@example.com",
+ ];
+ for (let string of ok) {
+ string = '<a href="' + string + '">foo</a>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // other links should be removed
+ const bad = [
+ "chrome://global/content/",
+ "about:",
+ "about:blank",
+ "foo://bar/",
+ "",
+ ];
+ for (let string of bad) {
+ Assert.equal(
+ "<a>foo</a>",
+ cleanupImMarkup('<a href="' + string + '">foo</a>')
+ );
+ }
+
+ // keep link titles
+ let string = '<a title="foo bar">foo</a>';
+ Assert.equal(string, cleanupImMarkup(string));
+}
+
+function test_table() {
+ const table =
+ "<table>" +
+ "<caption>test table</caption>" +
+ "<thead>" +
+ "<tr>" +
+ "<th>key</th>" +
+ "<th>data</th>" +
+ "</tr>" +
+ "</thead>" +
+ "<tbody>" +
+ "<tr>" +
+ "<td>lorem</td>" +
+ "<td>ipsum</td>" +
+ "</tr>" +
+ "</tbody>" +
+ "</table>";
+ Assert.equal(table, cleanupImMarkup(table));
+}
+
+function test_allModes() {
+ test_plainText();
+ test_paragraphs();
+ test_stripScripts();
+ test_links();
+ // Remove random classes.
+ Assert.equal("<p>foo</p>", cleanupImMarkup('<p class="foobar">foo</p>'));
+ // Test unparsable style.
+ Assert.equal("<p>foo</p>", cleanupImMarkup('<p style="not-valid">foo</p>'));
+}
+
+function test_strictMode() {
+ Services.prefs.setIntPref(kModePref, kStrictMode);
+ test_allModes();
+
+ // check that basic formatting is stripped in strict mode.
+ for (let tag of [
+ "div",
+ "em",
+ "strong",
+ "b",
+ "i",
+ "u",
+ "s",
+ "span",
+ "code",
+ "ul",
+ "li",
+ "ol",
+ "cite",
+ "blockquote",
+ "del",
+ "strike",
+ "ins",
+ "sub",
+ "sup",
+ "pre",
+ "td",
+ "details",
+ "h1",
+ ]) {
+ Assert.equal("foo", cleanupImMarkup("<" + tag + ">foo</" + tag + ">"));
+ }
+
+ // check that font settings are removed.
+ Assert.equal(
+ "foo",
+ cleanupImMarkup('<font face="Times" color="pink">foo</font>')
+ );
+ Assert.equal(
+ "<p>foo</p>",
+ cleanupImMarkup('<p style="font-weight: bold;">foo</p>')
+ );
+
+ // Discard hr
+ Assert.equal("foobar", cleanupImMarkup("foo<hr>bar"));
+
+ run_next_test();
+}
+
+function test_standardMode() {
+ Services.prefs.setIntPref(kModePref, kStandardMode);
+ test_allModes();
+ test_table();
+
+ // check that basic formatting is kept in standard mode.
+ for (let tag of [
+ "div",
+ "em",
+ "strong",
+ "b",
+ "i",
+ "u",
+ "s",
+ "span",
+ "code",
+ "ul",
+ "li",
+ "ol",
+ "cite",
+ "blockquote",
+ "del",
+ "sub",
+ "sup",
+ "pre",
+ "strike",
+ "ins",
+ "details",
+ ]) {
+ let string = "<" + tag + ">foo</" + tag + ">";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep special allowed classes.
+ for (let className of ["moz-txt-underscore", "moz-txt-tag"]) {
+ let string = '<span class="' + className + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Remove font settings
+ let font_string = '<font face="Times" color="pink" size="3">foo</font>';
+ Assert.equal("foo", cleanupImMarkup(font_string));
+
+ // Discard hr
+ Assert.equal("foobar", cleanupImMarkup("foo<hr>bar"));
+
+ const okCSS = ["font-style: italic", "font-weight: bold"];
+ for (let css of okCSS) {
+ let string = '<span style="' + css + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+ // text-decoration is a shorthand for several text-decoration properties, but
+ // standard mode only allows text-decoration-line.
+ Assert.equal(
+ '<span style="text-decoration-line: underline;">foo</span>',
+ cleanupImMarkup('<span style="text-decoration: underline">foo</span>')
+ );
+
+ const badCSS = [
+ "color: pink;",
+ "font-family: Times",
+ "font-size: larger",
+ "display: none",
+ "visibility: hidden",
+ "unsupported-by-gecko: blah",
+ ];
+ for (let css of badCSS) {
+ Assert.equal(
+ "<span>foo</span>",
+ cleanupImMarkup('<span style="' + css + '">foo</span>')
+ );
+ }
+ // The shorthand 'font' is decomposed to non-shorthand properties,
+ // and not recomposed as some non-shorthand properties are filtered out.
+ Assert.equal(
+ '<span style="font-style: normal; font-weight: normal;">foo</span>',
+ cleanupImMarkup('<span style="font: 15px normal">foo</span>')
+ );
+
+ // Discard headings
+ const heading1 = "test heading";
+ Assert.equal(heading1, cleanupImMarkup(`<h1>${heading1}</h1>`));
+
+ // Setting the start number of an <ol> is allowed
+ const olWithOffset = '<ol start="2"><li>two</li><li>three</li></ol>';
+ Assert.equal(olWithOffset, cleanupImMarkup(olWithOffset));
+
+ run_next_test();
+}
+
+function test_permissiveMode() {
+ Services.prefs.setIntPref(kModePref, kPermissiveMode);
+ test_allModes();
+ test_table();
+
+ // Check that all formatting is kept in permissive mode.
+ for (let tag of [
+ "div",
+ "em",
+ "strong",
+ "b",
+ "i",
+ "u",
+ "span",
+ "code",
+ "ul",
+ "li",
+ "ol",
+ "cite",
+ "blockquote",
+ "del",
+ "sub",
+ "sup",
+ "pre",
+ "strike",
+ "ins",
+ "details",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ ]) {
+ let string = "<" + tag + ">foo</" + tag + ">";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep special allowed classes.
+ for (let className of ["moz-txt-underscore", "moz-txt-tag"]) {
+ let string = '<span class="' + className + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Keep font settings
+ const fontAttributes = ['face="Times"', 'color="pink"', 'size="3"'];
+ for (let fontAttribute of fontAttributes) {
+ let string = "<font " + fontAttribute + ">foo</font>";
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+
+ // Allow hr
+ let hr_string = "foo<hr>bar";
+ Assert.equal(hr_string, cleanupImMarkup(hr_string));
+
+ // Allow most CSS rules changing the text appearance.
+ const okCSS = [
+ "font-style: italic",
+ "font-weight: bold",
+ "color: pink;",
+ "font-family: Times",
+ "font-size: larger",
+ ];
+ for (let css of okCSS) {
+ let string = '<span style="' + css + '">foo</span>';
+ Assert.equal(string, cleanupImMarkup(string));
+ }
+ // text-decoration is a shorthand for several text-decoration properties, but
+ // permissive mode only allows text-decoration-color, text-decoration-line,
+ // and text-decoration-style.
+ Assert.equal(
+ '<span style="text-decoration-color: currentcolor; text-decoration-line: underline; text-decoration-style: solid;">foo</span>',
+ cleanupImMarkup('<span style="text-decoration: underline;">foo</span>')
+ );
+
+ // The shorthand 'font' is decomposed to non-shorthand properties,
+ // and not recomposed as some non-shorthand properties are filtered out.
+ Assert.equal(
+ '<span style="font-family: normal; font-size: 15px; ' +
+ 'font-style: normal; font-weight: normal;">foo</span>',
+ cleanupImMarkup('<span style="font: 15px normal">foo</span>')
+ );
+
+ // But still filter out dangerous CSS rules.
+ const badCSS = [
+ "display: none",
+ "visibility: hidden",
+ "unsupported-by-gecko: blah",
+ ];
+ for (let css of badCSS) {
+ Assert.equal(
+ "<span>foo</span>",
+ cleanupImMarkup('<span style="' + css + '">foo</span>')
+ );
+ }
+
+ run_next_test();
+}
+
+function test_addGlobalAllowedTag() {
+ Services.prefs.setIntPref(kModePref, kStrictMode);
+
+ // Check that <hr> isn't allowed by default in strict mode.
+ // Note: we use <hr> instead of <img> to avoid mailnews' content policy
+ // messing things up.
+ Assert.equal("", cleanupImMarkup("<hr>"));
+
+ // Allow <hr> without attributes.
+ addGlobalAllowedTag("hr");
+ Assert.equal("<hr>", cleanupImMarkup("<hr>"));
+ Assert.equal("<hr>", cleanupImMarkup('<hr src="http://example.com/">'));
+ removeGlobalAllowedTag("hr");
+
+ // Allow <hr> with an unfiltered src attribute.
+ addGlobalAllowedTag("hr", { src: true });
+ Assert.equal("<hr>", cleanupImMarkup('<hr alt="foo">'));
+ Assert.equal(
+ '<hr src="http://example.com/">',
+ cleanupImMarkup('<hr src="http://example.com/">')
+ );
+ Assert.equal(
+ '<hr src="chrome://global/skin/img.png">',
+ cleanupImMarkup('<hr src="chrome://global/skin/img.png">')
+ );
+ removeGlobalAllowedTag("hr");
+
+ // Allow <hr> with an src attribute taking only http(s) urls.
+ addGlobalAllowedTag("hr", { src: aValue => /^https?:/.test(aValue) });
+ Assert.equal(
+ '<hr src="http://example.com/">',
+ cleanupImMarkup('<hr src="http://example.com/">')
+ );
+ Assert.equal(
+ "<hr>",
+ cleanupImMarkup('<hr src="chrome://global/skin/img.png">')
+ );
+ removeGlobalAllowedTag("hr");
+
+ run_next_test();
+}
+
+function test_addGlobalAllowedAttribute() {
+ Services.prefs.setIntPref(kModePref, kStrictMode);
+
+ // Check that id isn't allowed by default in strict mode.
+ Assert.equal("<br>", cleanupImMarkup('<br id="foo">'));
+
+ // Allow id unconditionally.
+ addGlobalAllowedAttribute("id");
+ Assert.equal('<br id="foo">', cleanupImMarkup('<br id="foo">'));
+ removeGlobalAllowedAttribute("id");
+
+ // Allow id only with numbers.
+ addGlobalAllowedAttribute("id", aId => /^\d+$/.test(aId));
+ Assert.equal('<br id="123">', cleanupImMarkup('<br id="123">'));
+ Assert.equal("<br>", cleanupImMarkup('<br id="foo">'));
+ removeGlobalAllowedAttribute("id");
+
+ run_next_test();
+}
+
+function test_addGlobalAllowedStyleRule() {
+ // We need at least the standard mode to have the style attribute allowed.
+ Services.prefs.setIntPref(kModePref, kStandardMode);
+
+ // Check that clear isn't allowed by default in strict mode.
+ Assert.equal("<br>", cleanupImMarkup('<br style="clear: both;">'));
+
+ // Allow clear.
+ addGlobalAllowedStyleRule("clear");
+ Assert.equal(
+ '<br style="clear: both;">',
+ cleanupImMarkup('<br style="clear: both;">')
+ );
+ removeGlobalAllowedStyleRule("clear");
+
+ run_next_test();
+}
+
+function test_createDerivedRuleset() {
+ Services.prefs.setIntPref(kModePref, kStandardMode);
+
+ let rules = createDerivedRuleset();
+
+ let string = "<hr>";
+ Assert.equal("", cleanupImMarkup(string));
+ Assert.equal("", cleanupImMarkup(string, rules));
+ rules.tags.hr = true;
+ Assert.equal(string, cleanupImMarkup(string, rules));
+
+ string = '<br id="123">';
+ Assert.equal("<br>", cleanupImMarkup(string));
+ Assert.equal("<br>", cleanupImMarkup(string, rules));
+ rules.attrs.id = true;
+ Assert.equal(string, cleanupImMarkup(string, rules));
+
+ string = '<br style="clear: both;">';
+ Assert.equal("<br>", cleanupImMarkup(string));
+ Assert.equal("<br>", cleanupImMarkup(string, rules));
+ rules.styles.clear = true;
+ Assert.equal(string, cleanupImMarkup(string, rules));
+
+ run_next_test();
+}
diff --git a/comm/chat/modules/test/test_imThemes.js b/comm/chat/modules/test/test_imThemes.js
new file mode 100644
index 0000000000..61171fe121
--- /dev/null
+++ b/comm/chat/modules/test/test_imThemes.js
@@ -0,0 +1,342 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const {
+ initHTMLDocument,
+ insertHTMLForMessage,
+ getHTMLForMessage,
+ replaceHTMLForMessage,
+ wasNextMessage,
+ removeMessage,
+ isNextMessage,
+} = ChromeUtils.importESModule("resource:///modules/imThemes.sys.mjs");
+const { MockDocument } = ChromeUtils.importESModule(
+ "resource://testing-common/MockDocument.sys.mjs"
+);
+
+const BASIC_CONV_DOCUMENT_HTML =
+ '<!DOCTYPE html><html><body><div id="Chat"></div></body></html>';
+
+add_task(function test_initHTMLDocument() {
+ const window = {};
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ "<!DOCTYPE html><html><head></head><body></body></html>"
+ );
+ Object.defineProperty(document, "defaultView", {
+ value: window,
+ });
+ const conversation = {
+ title: "test",
+ };
+ const theme = {
+ baseURI: "chrome://messenger-messagestyles/skin/test/",
+ variant: "default",
+ metadata: {},
+ html: {
+ footer: "",
+ script: 'console.log("hi");',
+ },
+ };
+ initHTMLDocument(conversation, theme, document);
+ equal(typeof document.defaultView.convertTimeUnits, "function");
+ equal(document.querySelector("base").href, theme.baseURI);
+ ok(
+ document.querySelector(
+ 'link[rel="stylesheet"][href="chrome://chat/skin/conv.css"]'
+ )
+ );
+ ok(document.querySelector('link[rel="stylesheet"][href="main.css"]'));
+
+ equal(document.body.id, "ibcontent");
+ ok(document.getElementById("Chat"));
+ equal(document.querySelector("script").src, theme.baseURI + "inline.js");
+});
+
+add_task(function test_insertHTMLForMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {};
+ insertHTMLForMessage(message, html, document, false);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ ok(!messageElement.dataset.isNext);
+});
+
+add_task(function test_insertHTMLForMessage_next() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {};
+ insertHTMLForMessage(message, html, document, true);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ ok(messageElement.dataset.isNext);
+});
+
+add_task(function test_getHTMLForMessage() {
+ const message = {
+ incoming: true,
+ system: false,
+ message: "foo bar",
+ who: "userId",
+ alias: "display name",
+ color: "#ffbbff",
+ };
+ const theme = {
+ html: {
+ incomingContent:
+ '<span style="color: %senderColor%;">%sender%</span>%message%',
+ },
+ };
+ const html = getHTMLForMessage(message, theme, false, false);
+ equal(
+ html,
+ '<span style="color: #ffbbff;"><span class="ib-sender">display name</span></span><span class="ib-msg-txt">foo bar</span>'
+ );
+});
+
+add_task(function test_replaceHTMLForMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, false);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ equal(messageElement.dataset.remoteId, "foo");
+ ok(!messageElement.dataset.isNext);
+ const updatedHtml =
+ '<div style="background: green;">lorem ipsum</div><div id="insert"></div>';
+ const updatedMessage = {
+ remoteId: "foo",
+ };
+ replaceHTMLForMessage(updatedMessage, updatedHtml, document, true);
+ const updatedMessageElement = document.querySelector("#Chat > div");
+ strictEqual(updatedMessageElement._originalMsg, updatedMessage);
+ equal(updatedMessageElement.style.backgroundColor, "green");
+ equal(updatedMessageElement.textContent, "lorem ipsum");
+ equal(updatedMessageElement.dataset.remoteId, "foo");
+ ok(updatedMessageElement.dataset.isNext);
+ ok(
+ !document.querySelector("#insert"),
+ "Insert anchor in template is ignored when replacing"
+ );
+});
+
+add_task(function test_replaceHTMLForMessageWithoutExistingMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const updatedHtml = '<div style="background: green;">lorem ipsum</div>';
+ const updatedMessage = {
+ remoteId: "foo",
+ };
+ replaceHTMLForMessage(updatedMessage, updatedHtml, document, false);
+ const updatedMessageElement = document.querySelector("#Chat > div");
+ ok(!updatedMessageElement);
+});
+
+add_task(function test_replaceHTMLForMessageWithoutRemoteId() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, false);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ equal(messageElement.dataset.remoteId, "foo");
+ ok(!messageElement.dataset.isNext);
+ const updatedHtml = '<div style="background: green;">lorem ipsum</div>';
+ const updatedMessage = {};
+ replaceHTMLForMessage(updatedMessage, updatedHtml, document, false);
+ const updatedMessageElement = document.querySelector("#Chat > div");
+ strictEqual(updatedMessageElement._originalMsg, message);
+ equal(updatedMessageElement.style.backgroundColor, "blue");
+ equal(updatedMessageElement.textContent, "foo bar");
+ equal(updatedMessageElement.dataset.remoteId, "foo");
+ ok(!updatedMessageElement.dataset.isNext);
+});
+
+add_task(function test_wasNextMessage_isNext() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = "<div>foo bar</div>";
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, true);
+ ok(wasNextMessage(message, document));
+});
+
+add_task(function test_wasNextMessage_isNotNext() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = "<div>foo bar</div>";
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, false);
+ ok(!wasNextMessage(message, document));
+});
+
+add_task(function test_wasNextMessage_noPreviousVersion() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const message = {
+ remoteId: "foo",
+ };
+ ok(!wasNextMessage(message, document));
+});
+
+add_task(function test_removeMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, false);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ equal(messageElement.dataset.remoteId, "foo");
+ ok(!messageElement.dataset.isNext);
+ removeMessage("foo", document);
+ const messageElements = document.querySelectorAll("#Chat > div");
+ equal(messageElements.length, 0);
+});
+
+add_task(function test_removeMessage_noMatchingMessage() {
+ const document = MockDocument.createTestDocument(
+ "chrome://chat/content/conv.html",
+ BASIC_CONV_DOCUMENT_HTML
+ );
+ const html = '<div style="background: blue;">foo bar</div>';
+ const message = {
+ remoteId: "foo",
+ };
+ insertHTMLForMessage(message, html, document, false);
+ const messageElement = document.querySelector("#Chat > div");
+ strictEqual(messageElement._originalMsg, message);
+ equal(messageElement.style.backgroundColor, "blue");
+ equal(messageElement.textContent, "foo bar");
+ equal(messageElement.dataset.remoteId, "foo");
+ ok(!messageElement.dataset.isNext);
+ removeMessage("bar", document);
+ const messageElements = document.querySelectorAll("#Chat > div");
+ notEqual(messageElements.length, 0);
+});
+
+add_task(function test_isNextMessage() {
+ const theme = {
+ combineConsecutive: true,
+ metadata: {},
+ combineConsecutiveInterval: 300,
+ };
+ const messagePairs = [
+ {
+ message: {},
+ previousMessage: null,
+ isNext: false,
+ },
+ {
+ message: {
+ system: true,
+ },
+ previousMessage: {
+ system: true,
+ },
+ isNext: true,
+ },
+ {
+ message: {
+ who: "foo",
+ },
+ previousMessage: {
+ who: "bar",
+ },
+ isNext: false,
+ },
+ {
+ message: {
+ outgoing: true,
+ },
+ isNext: false,
+ },
+ {
+ message: {
+ incoming: true,
+ },
+ isNext: false,
+ },
+ {
+ message: {
+ system: true,
+ },
+ isNext: false,
+ },
+ {
+ message: {
+ time: 100,
+ },
+ previousMessage: {
+ time: 100,
+ },
+ isNext: true,
+ },
+ {
+ message: {
+ time: 300,
+ },
+ previousMessage: {
+ time: 100,
+ },
+ isNext: true,
+ },
+ {
+ message: {
+ time: 500,
+ },
+ previousMessage: {
+ time: 100,
+ },
+ isNext: false,
+ },
+ ];
+ for (const { message, previousMessage = {}, isNext } of messagePairs) {
+ equal(isNextMessage(theme, message, previousMessage), isNext);
+ }
+});
diff --git a/comm/chat/modules/test/test_jsProtoHelper.js b/comm/chat/modules/test/test_jsProtoHelper.js
new file mode 100644
index 0000000000..b87ec27241
--- /dev/null
+++ b/comm/chat/modules/test/test_jsProtoHelper.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { GenericConvIMPrototype } = ChromeUtils.importESModule(
+ "resource:///modules/jsProtoHelper.sys.mjs"
+);
+
+var _id = 0;
+function Conversation(name) {
+ this._name = name;
+ this._observers = [];
+ this._date = Date.now() * 1000;
+ this.id = ++_id;
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ _account: {
+ imAccount: {
+ protocol: { name: "Fake Protocol" },
+ alias: "",
+ name: "Fake Account",
+ },
+ ERROR(e) {
+ throw e;
+ },
+ DEBUG() {},
+ },
+};
+
+// ROT13, used as an example transformation.
+function rot13(aString) {
+ return aString.replace(/[a-zA-Z]/g, function (c) {
+ return String.fromCharCode(
+ c.charCodeAt(0) + (c.toLowerCase() < "n" ? 1 : -1) * 13
+ );
+ });
+}
+
+// A test that cancels a message before it can be sent.
+add_task(function test_cancel_send_message() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ ok(
+ false,
+ "The message should have been halted in the conversation service."
+ );
+ };
+
+ let sending = false;
+ conv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "sending-message":
+ ok(
+ aObject.QueryInterface(Ci.imIOutgoingMessage),
+ "Wrong message type."
+ );
+ aObject.cancelled = true;
+ sending = true;
+ break;
+ case "new-text":
+ ok(
+ false,
+ "No other notification should be fired for a cancelled message."
+ );
+ break;
+ }
+ },
+ });
+ conv.sendMsg("Hi!");
+ ok(sending, "The sending-message notification was never fired.");
+});
+
+// A test that ensures protocols get a chance to prepare a message before
+// sending and displaying.
+add_task(function test_prpl_message_prep() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ this.writeMessage("user", aMsg, { outgoing: true });
+ };
+
+ conv.prepareForSending = function (aMsg) {
+ ok(aMsg.QueryInterface(Ci.imIOutgoingMessage), "Wrong message type.");
+ equal(aMsg.message, msg, "Expected the original message.");
+ prepared = true;
+ return [prefix + aMsg.message];
+ };
+
+ conv.prepareForDisplaying = function (aMsg) {
+ equal(aMsg.displayMessage, prefix + msg, "Expected the prefixed message.");
+ aMsg.displayMessage = aMsg.displayMessage.slice(prefix.length);
+ };
+
+ let msg = "Hi!";
+ let prefix = "test> ";
+
+ let prepared = false;
+ let receivedMsg = false;
+ conv.addObserver({
+ observe(aObject, aTopic) {
+ if (aTopic === "preparing-message") {
+ equal(aObject.message, msg, "Expected the original message");
+ } else if (aTopic === "sending-message") {
+ equal(aObject.message, prefix + msg, "Expected the prefixed message.");
+ } else if (aTopic === "new-text") {
+ ok(aObject.QueryInterface(Ci.prplIMessage), "Wrong message type.");
+ ok(prepared, "The message was not prepared before sending.");
+ equal(aObject.message, prefix + msg, "Expected the prefixed message.");
+ receivedMsg = true;
+ aObject.displayMessage = aObject.originalMessage;
+ conv.prepareForDisplaying(aObject);
+ equal(aObject.displayMessage, msg, "Expected the original message");
+ }
+ },
+ });
+
+ conv.sendMsg(msg);
+ ok(receivedMsg, "The new-text notification was never fired.");
+});
+
+// A test that ensures protocols can split messages before they are sent.
+add_task(function test_split_message_before_sending() {
+ let msgCount = 0;
+ let prepared = false;
+
+ let msg = "This is a looo\nooong message.\nThis one is short.";
+ let msgs = msg.split("\n");
+
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ equal(aMsg, msgs[msgCount++], "Sending an unexpected message.");
+ };
+ conv.prepareForSending = function (aMsg) {
+ ok(aMsg.QueryInterface(Ci.imIOutgoingMessage), "Wrong message type.");
+ prepared = true;
+ return aMsg.message.split("\n");
+ };
+
+ conv.sendMsg(msg);
+
+ ok(prepared, "Message wasn't prepared for sending.");
+ equal(msgCount, 3, "Not enough messages were sent.");
+});
+
+add_task(function test_removeMessage() {
+ let didRemove = false;
+ let conv = new Conversation();
+ conv.addObserver({
+ observe(subject, topic, data) {
+ if (topic === "remove-text") {
+ equal(data, "foo");
+ didRemove = true;
+ }
+ },
+ });
+
+ conv.removeMessage("foo");
+ ok(didRemove);
+});
diff --git a/comm/chat/modules/test/test_otrlib.js b/comm/chat/modules/test/test_otrlib.js
new file mode 100644
index 0000000000..4b321359f9
--- /dev/null
+++ b/comm/chat/modules/test/test_otrlib.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for libotr.
+ */
+
+"use strict";
+
+const { OTRLibLoader } = ChromeUtils.importESModule(
+ "resource:///modules/OTRLib.sys.mjs"
+);
+
+/**
+ * Initialize libotr.
+ */
+add_setup(async function () {
+ let libOTR = await OTRLibLoader.init();
+ Assert.ok(libOTR.otrl_version, "libotr did load");
+});
diff --git a/comm/chat/modules/test/xpcshell.ini b/comm/chat/modules/test/xpcshell.ini
new file mode 100644
index 0000000000..d12004fd37
--- /dev/null
+++ b/comm/chat/modules/test/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+head =
+tail =
+
+[test_filtering.js]
+[test_imThemes.js]
+[test_InteractiveBrowser.js]
+[test_jsProtoHelper.js]
+[test_NormalizedMap.js]
+[test_otrlib.js]
diff --git a/comm/chat/moz.build b/comm/chat/moz.build
new file mode 100644
index 0000000000..33688ec3de
--- /dev/null
+++ b/comm/chat/moz.build
@@ -0,0 +1,28 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "components/public",
+ "components/src",
+ "modules",
+ "content",
+ "themes",
+ "locales",
+ "protocols/facebook",
+ "protocols/gtalk",
+ "protocols/irc",
+ "protocols/matrix",
+ "protocols/odnoklassniki",
+ "protocols/twitter",
+ "protocols/xmpp",
+ "protocols/yahoo",
+]
+
+if CONFIG["MOZ_DEBUG"]:
+ DIRS += ["protocols/jsTest"]
+
+JS_PREFERENCE_PP_FILES += [
+ "chat-prefs.js",
+]
diff --git a/comm/chat/protocols/facebook/components.conf b/comm/chat/protocols/facebook/components.conf
new file mode 100644
index 0000000000..b5a023f678
--- /dev/null
+++ b/comm/chat/protocols/facebook/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{1d1d0bc5-610c-472f-b2cb-4b89857d80dc}',
+ 'contract_ids': ['@mozilla.org/chat/facebook;1'],
+ 'esModule': 'resource:///modules/facebook.sys.mjs',
+ 'constructor': 'FacebookProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-facebook'},
+ },
+]
diff --git a/comm/chat/protocols/facebook/facebook.sys.mjs b/comm/chat/protocols/facebook/facebook.sys.mjs
new file mode 100644
index 0000000000..048e81b4a9
--- /dev/null
+++ b/comm/chat/protocols/facebook/facebook.sys.mjs
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericProtocolPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/facebook.properties")
+);
+
+function FacebookAccount(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+}
+FacebookAccount.prototype = {
+ __proto__: GenericAccountPrototype,
+
+ connect() {
+ this.WARN(
+ "As Facebook deprecated its XMPP gateway, it is currently not " +
+ "possible to connect to Facebook Chat. See bug 1141674."
+ );
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("facebook.disabled")
+ );
+ this.reportDisconnected();
+ },
+
+ // Nothing to do.
+ unInit() {},
+ remove() {},
+};
+
+export function FacebookProtocol() {}
+FacebookProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "facebook";
+ },
+ get name() {
+ return lazy._("facebook.chat.name");
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-facebook/skin/";
+ },
+ getAccount(aImAccount) {
+ return new FacebookAccount(this, aImAccount);
+ },
+};
diff --git a/comm/chat/protocols/facebook/icons/prpl-facebook-32.png b/comm/chat/protocols/facebook/icons/prpl-facebook-32.png
new file mode 100644
index 0000000000..77e6d358b6
--- /dev/null
+++ b/comm/chat/protocols/facebook/icons/prpl-facebook-32.png
Binary files differ
diff --git a/comm/chat/protocols/facebook/icons/prpl-facebook-48.png b/comm/chat/protocols/facebook/icons/prpl-facebook-48.png
new file mode 100644
index 0000000000..2501acaab5
--- /dev/null
+++ b/comm/chat/protocols/facebook/icons/prpl-facebook-48.png
Binary files differ
diff --git a/comm/chat/protocols/facebook/icons/prpl-facebook.png b/comm/chat/protocols/facebook/icons/prpl-facebook.png
new file mode 100644
index 0000000000..bc42cf9b0b
--- /dev/null
+++ b/comm/chat/protocols/facebook/icons/prpl-facebook.png
Binary files differ
diff --git a/comm/chat/protocols/facebook/jar.mn b/comm/chat/protocols/facebook/jar.mn
new file mode 100644
index 0000000000..24c5e8fef6
--- /dev/null
+++ b/comm/chat/protocols/facebook/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-facebook classic/1.0 %skin/classic/prpl/facebook/
+ skin/classic/prpl/facebook/icon32.png (icons/prpl-facebook-32.png)
+ skin/classic/prpl/facebook/icon48.png (icons/prpl-facebook-48.png)
+ skin/classic/prpl/facebook/icon.png (icons/prpl-facebook.png)
diff --git a/comm/chat/protocols/facebook/moz.build b/comm/chat/protocols/facebook/moz.build
new file mode 100644
index 0000000000..d07bb3a8f9
--- /dev/null
+++ b/comm/chat/protocols/facebook/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "facebook.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/gtalk/components.conf b/comm/chat/protocols/gtalk/components.conf
new file mode 100644
index 0000000000..a736ae37a2
--- /dev/null
+++ b/comm/chat/protocols/gtalk/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{38a224c1-6748-49a9-8ab2-efc362b1000d}',
+ 'contract_ids': ['@mozilla.org/chat/gtalk;1'],
+ 'esModule': 'resource:///modules/gtalk.sys.mjs',
+ 'constructor': 'GTalkProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-gtalk'},
+ },
+]
diff --git a/comm/chat/protocols/gtalk/gtalk.sys.mjs b/comm/chat/protocols/gtalk/gtalk.sys.mjs
new file mode 100644
index 0000000000..ca5b7c33a6
--- /dev/null
+++ b/comm/chat/protocols/gtalk/gtalk.sys.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericProtocolPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+
+function GTalkAccount(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+}
+GTalkAccount.prototype = {
+ __proto__: GenericAccountPrototype,
+ connect() {
+ this.WARN(
+ "As Google deprecated its XMPP gateway, it is currently not " +
+ "possible to connect to Google Talk. See bug 1645217."
+ );
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("gtalk.disabled")
+ );
+ this.reportDisconnected();
+ },
+
+ // Nothing to do.
+ unInit() {},
+ remove() {},
+};
+
+export function GTalkProtocol() {}
+GTalkProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "gtalk";
+ },
+ get name() {
+ return lazy._("gtalk.protocolName");
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-gtalk/skin/";
+ },
+ getAccount(aImAccount) {
+ return new GTalkAccount(this, aImAccount);
+ },
+ // GTalk accounts which were configured with OAuth2 do not have a password set.
+ // Show the above error on connect instead of a "needs password" error.
+ get noPassword() {
+ return true;
+ },
+};
diff --git a/comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png b/comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png
new file mode 100644
index 0000000000..8390ff8f3e
--- /dev/null
+++ b/comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png
Binary files differ
diff --git a/comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png b/comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png
new file mode 100644
index 0000000000..e0352ac69f
--- /dev/null
+++ b/comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png
Binary files differ
diff --git a/comm/chat/protocols/gtalk/icons/prpl-gtalk.png b/comm/chat/protocols/gtalk/icons/prpl-gtalk.png
new file mode 100644
index 0000000000..396b967c65
--- /dev/null
+++ b/comm/chat/protocols/gtalk/icons/prpl-gtalk.png
Binary files differ
diff --git a/comm/chat/protocols/gtalk/jar.mn b/comm/chat/protocols/gtalk/jar.mn
new file mode 100644
index 0000000000..6f2d510e4e
--- /dev/null
+++ b/comm/chat/protocols/gtalk/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-gtalk classic/1.0 %skin/classic/prpl/gtalk/
+ skin/classic/prpl/gtalk/icon32.png (icons/prpl-gtalk-32.png)
+ skin/classic/prpl/gtalk/icon48.png (icons/prpl-gtalk-48.png)
+ skin/classic/prpl/gtalk/icon.png (icons/prpl-gtalk.png)
diff --git a/comm/chat/protocols/gtalk/moz.build b/comm/chat/protocols/gtalk/moz.build
new file mode 100644
index 0000000000..b147aeaf01
--- /dev/null
+++ b/comm/chat/protocols/gtalk/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "gtalk.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/irc/components.conf b/comm/chat/protocols/irc/components.conf
new file mode 100644
index 0000000000..08a9674884
--- /dev/null
+++ b/comm/chat/protocols/irc/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{607b2c0b-9504-483f-ad62-41de09238aec}',
+ 'contract_ids': ['@mozilla.org/chat/irc;1'],
+ 'esModule': 'resource:///modules/irc.sys.mjs',
+ 'constructor': 'ircProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-irc'},
+ },
+]
diff --git a/comm/chat/protocols/irc/icons/prpl-irc-32.png b/comm/chat/protocols/irc/icons/prpl-irc-32.png
new file mode 100644
index 0000000000..003103914c
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc-32.png
Binary files differ
diff --git a/comm/chat/protocols/irc/icons/prpl-irc-48.png b/comm/chat/protocols/irc/icons/prpl-irc-48.png
new file mode 100644
index 0000000000..606425fabb
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc-48.png
Binary files differ
diff --git a/comm/chat/protocols/irc/icons/prpl-irc.png b/comm/chat/protocols/irc/icons/prpl-irc.png
new file mode 100644
index 0000000000..19d578deda
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc.png
Binary files differ
diff --git a/comm/chat/protocols/irc/irc.sys.mjs b/comm/chat/protocols/irc/irc.sys.mjs
new file mode 100644
index 0000000000..087dbf28d8
--- /dev/null
+++ b/comm/chat/protocols/irc/irc.sys.mjs
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ ircAccount: "resource:///modules/ircAccount.sys.mjs",
+});
+
+export function ircProtocol() {
+ // ircCommands.jsm exports one variable: commands. Import this directly into
+ // the protocol object.
+ this.commands = ChromeUtils.importESModule(
+ "resource:///modules/ircCommands.sys.mjs"
+ ).commands;
+ this.registerCommands();
+}
+
+ircProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get name() {
+ return "IRC";
+ },
+ get normalizedName() {
+ return "irc";
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-irc/skin/";
+ },
+ get usernameEmptyText() {
+ return lazy._("irc.usernameHint");
+ },
+
+ usernameSplits: [
+ {
+ get label() {
+ return lazy._("options.server");
+ },
+ separator: "@",
+ defaultValue: "irc.libera.chat",
+ },
+ ],
+
+ splitUsername(aName) {
+ let splitter = aName.lastIndexOf("@");
+ if (splitter === -1) {
+ return [];
+ }
+ return [aName.slice(0, splitter), aName.slice(splitter + 1)];
+ },
+
+ options: {
+ port: {
+ get label() {
+ return lazy._("options.port");
+ },
+ default: 6697,
+ },
+ ssl: {
+ get label() {
+ return lazy._("options.ssl");
+ },
+ default: true,
+ },
+ // TODO We should attempt to auto-detect encoding instead.
+ encoding: {
+ get label() {
+ return lazy._("options.encoding");
+ },
+ default: "UTF-8",
+ },
+ quitmsg: {
+ get label() {
+ return lazy._("options.quitMessage");
+ },
+ get default() {
+ return Services.prefs.getCharPref("chat.irc.defaultQuitMessage");
+ },
+ },
+ partmsg: {
+ get label() {
+ return lazy._("options.partMessage");
+ },
+ default: "",
+ },
+ showServerTab: {
+ get label() {
+ return lazy._("options.showServerTab");
+ },
+ default: false,
+ },
+ alternateNicks: {
+ get label() {
+ return lazy._("options.alternateNicks");
+ },
+ default: "",
+ },
+ },
+
+ get chatHasTopic() {
+ return true;
+ },
+ get slashCommandsNative() {
+ return true;
+ },
+ // Passwords in IRC are optional, and are needed for certain functionality.
+ get passwordOptional() {
+ return true;
+ },
+
+ getAccount(aImAccount) {
+ return new lazy.ircAccount(this, aImAccount);
+ },
+};
diff --git a/comm/chat/protocols/irc/ircAccount.sys.mjs b/comm/chat/protocols/irc/ircAccount.sys.mjs
new file mode 100644
index 0000000000..6a127e16cb
--- /dev/null
+++ b/comm/chat/protocols/irc/ircAccount.sys.mjs
@@ -0,0 +1,2296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ ClassInfo,
+ executeSoon,
+ l10nHelper,
+ nsSimpleEnumerator,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ ctcpFormatToHTML,
+ kListRefreshInterval,
+} from "resource:///modules/ircUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericAccountBuddyPrototype,
+ GenericConvIMPrototype,
+ GenericConvChatPrototype,
+ GenericConvChatBuddyPrototype,
+ GenericConversationPrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs";
+import { Socket } from "resource:///modules/socket.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+ ircHandlers: "resource:///modules/ircHandlers.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "_conv", () =>
+ l10nHelper("chrome://chat/locale/conversations.properties")
+);
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+/*
+ * Parses a raw IRC message into an object (see section 2.3 of RFC 2812). This
+ * returns an object with the following fields:
+ * rawMessage The initial message string received without any processing.
+ * command A string that is the command or response code.
+ * params An array of strings for the parameters. The last parameter is
+ * stripped of its : prefix.
+ * origin The user's nickname or the server who sent the message. Can be
+ * a host (e.g. irc.mozilla.org) or an IPv4 address (e.g. 1.2.3.4)
+ * or an IPv6 address (e.g. 3ffe:1900:4545:3:200:f8ff:fe21:67cf).
+ * user The user's username, note that this can be undefined.
+ * host The user's hostname, note that this can be undefined.
+ * source A "nicely" formatted combination of user & host, which is
+ * <user>@<host> or <user> if host is undefined.
+ * tags A Map with tags stored as key-value-pair. The value is a decoded
+ * string or undefined if the tag has no value.
+ *
+ * There are cases (e.g. localhost) where it cannot be easily determined if a
+ * message is from a server or from a user, thus the usage of a generic "origin"
+ * instead of "nickname" or "servername".
+ *
+ * Inputs:
+ * aData The raw string to parse, it should already have the \r\n
+ * stripped from the end.
+ * aOrigin The default origin to use for unprefixed messages.
+ */
+export function ircMessage(aData, aOrigin) {
+ let message = { rawMessage: aData };
+ let temp;
+
+ // Splits the raw string into five parts. The third part, the command, is
+ // required. A raw string looks like:
+ // ["@" <tags> " "] [":" <prefix> " "] <command> [" " <parameter>]* [":" <last parameter>]
+ // <tags>: /[^ ]+/
+ // <prefix>: :(<server name> | <nickname> [["!" <user>] "@" <host>])
+ // <command>: /[^ ]+/
+ // <parameter>: /[^ ]+/
+ // <last parameter>: /.+/
+ // See http://joshualuckers.nl/2010/01/10/regular-expression-to-match-raw-irc-messages/
+ // Note that this expression is slightly more aggressive in matching than RFC
+ // 2812 would allow. It allows for empty parameters (besides the last
+ // parameter, which can always be empty), by allowing multiple spaces.
+ // (This is for compatibility with Unreal's 432 response, which returns an
+ // empty first parameter.) It also allows a trailing space after the
+ // <parameter>s when no <last parameter> is present (also occurs with Unreal).
+ if (
+ !(temp = aData.match(
+ /^(?:@([^ ]+) )?(?::([^ ]+) )?([^ ]+)((?: +[^: ][^ ]*)*)? *(?::([\s\S]*))?$/
+ ))
+ ) {
+ throw new Error("Couldn't parse message: \"" + aData + '"');
+ }
+
+ message.command = temp[3];
+ // Space separated parameters. Since we expect a space as the first thing
+ // here, we want to ignore the first value (which is empty).
+ message.params = temp[4] ? temp[4].split(" ").slice(1) : [];
+ // Last parameter can contain spaces or be an empty string.
+ if (temp[5] !== undefined) {
+ message.params.push(temp[5]);
+ }
+
+ // Handle the prefix part of the message per RFC 2812 Section 2.3.
+
+ // If no prefix is given, assume the current server is the origin.
+ if (!temp[2]) {
+ temp[2] = aOrigin;
+ }
+
+ // Split the prefix into separate nickname, username and hostname fields as:
+ // :(servername|(nickname[[!user]@host]))
+ [message.origin, message.user, message.host] = temp[2].split(/[!@]/);
+
+ // Store the tags in a Map, see IRCv3.2 Message Tags.
+ message.tags = new Map();
+
+ if (temp[1]) {
+ let tags = temp[1].split(";");
+ tags.forEach(tag => {
+ let [key, value] = tag.split("=");
+
+ if (value) {
+ // Unescape tag values according to this mapping:
+ // \\ = \
+ // \n = LF
+ // \r = CR
+ // \s = SPACE
+ // \: = ;
+ // everything else stays identical.
+ value = value.replace(/\\(.)/g, (str, type) => {
+ if (type == "\\") {
+ return "\\";
+ } else if (type == "n") {
+ return "\n";
+ } else if (type == "r") {
+ return "\r";
+ } else if (type == "s") {
+ return " ";
+ } else if (type == ":") {
+ return ";";
+ }
+ // Ignore the backslash, not specified by the spec, but as it says
+ // backslashes must be escaped this case should not occur in a valid
+ // tag value.
+ return type;
+ });
+ }
+ // The tag key can typically have the form of example.com/aaa for vendor
+ // defined tags. The spec wants any unicode characters in URLs to be
+ // in punycode (xn--). These are not unescaped to their unicode value.
+ message.tags.set(key, value);
+ });
+ }
+
+ // It is occasionally useful to have a "source" which is a combination of
+ // user@host.
+ if (message.user) {
+ message.source = message.user + "@" + message.host;
+ } else if (message.host) {
+ message.source = message.host;
+ } else {
+ message.source = "";
+ }
+
+ return message;
+}
+
+// This handles a mode change string for both channels and participants. A mode
+// change string is of the form:
+// aAddNewMode is true if modes are being added, false otherwise.
+// aNewModes is an array of mode characters.
+function _setMode(aAddNewMode, aNewModes) {
+ // Check each mode being added/removed.
+ for (let newMode of aNewModes) {
+ let hasMode = this._modes.has(newMode);
+ // If the mode is in the list of modes and we want to remove it.
+ if (hasMode && !aAddNewMode) {
+ this._modes.delete(newMode);
+ } else if (!hasMode && aAddNewMode) {
+ // If the mode is not in the list of modes and we want to add it.
+ this._modes.add(newMode);
+ }
+ }
+}
+
+function TagMessage(aMessage, aTagName) {
+ this.message = aMessage;
+ this.tagName = aTagName;
+ this.tagValue = aMessage.tags.get(aTagName);
+}
+
+// Properties / methods shared by both ircChannel and ircConversation.
+export var GenericIRCConversation = {
+ _observedNicks: [],
+ // This is set to true after a message is sent to notify the 401
+ // ERR_NOSUCHNICK handler to write an error message to the conversation.
+ _pendingMessage: false,
+ _waitingForNick: false,
+
+ normalizeNick(aNick) {
+ return this._account.normalizeNick(aNick);
+ },
+
+ // This will calculate the maximum number of bytes that are left for a message
+ // typed by the user by calculate the amount of bytes that would be used by
+ // the IRC messaging.
+ getMaxMessageLength() {
+ // Build the shortest possible message that could be sent to other users.
+ let baseMessage =
+ ":" +
+ this._account._nickname +
+ this._account.prefix +
+ " " +
+ this._account.buildMessage("PRIVMSG", this.name) +
+ " :\r\n";
+ return (
+ this._account.maxMessageLength - this._account.countBytes(baseMessage)
+ );
+ },
+ /**
+ * @param {string} aWho - Message author's username.
+ * @param {string} aMessage - Message text.
+ * @param {object} aObject - Other properties to set on the imMessage.
+ */
+ handleTags(aWho, aMessage, aObject) {
+ let messageProps = aObject;
+ if ("tags" in aObject && lazy.ircHandlers.hasTagHandlers) {
+ // Merge extra info for the handler into the props.
+ messageProps = Object.assign(
+ {
+ who: aWho,
+ message: aMessage,
+ get originalMessage() {
+ return aMessage;
+ },
+ },
+ messageProps
+ );
+ for (let tag of aObject.tags.keys()) {
+ // Unhandled tags may be common, since a tag does not have to be handled
+ // with a tag handler, it may also be handled by a message command handler.
+ lazy.ircHandlers.handleTag(
+ this._account,
+ new TagMessage(messageProps, tag)
+ );
+ }
+
+ // Remove helper prop for tag handlers. We don't want to remove the other
+ // ones, since they might have been changed and will override aWho and
+ // aMessage in the imMessage constructor.
+ delete messageProps.originalMessage;
+ }
+ // Remove the IRC tags, as those were passed in just for this step.
+ delete messageProps.tags;
+ return messageProps;
+ },
+ // Apply CTCP formatting before displaying.
+ prepareForDisplaying(aMsg) {
+ aMsg.displayMessage = ctcpFormatToHTML(aMsg.displayMessage);
+ GenericConversationPrototype.prepareForDisplaying.apply(this, arguments);
+ },
+ prepareForSending(aOutgoingMessage) {
+ // Split the message by line breaks and send each one individually.
+ let messages = aOutgoingMessage.message.split(/[\r\n]+/);
+
+ let maxLength = this.getMaxMessageLength();
+
+ // Attempt to smartly split a string into multiple lines (based on the
+ // maximum number of characters the message can contain).
+ for (let i = 0; i < messages.length; ++i) {
+ let message = messages[i];
+ let length = this._account.countBytes(message);
+ // The message is short enough.
+ if (length <= maxLength) {
+ continue;
+ }
+
+ // Find the location of a space before the maximum length.
+ let index = message.lastIndexOf(" ", maxLength);
+
+ // Remove the current message and insert the two new ones. If no space was
+ // found, cut the first message to the maximum length and start the second
+ // message one character after that. If a space was found, exclude it.
+ messages.splice(
+ i,
+ 1,
+ message.substr(0, index == -1 ? maxLength : index),
+ message.substr(index + 1 || maxLength)
+ );
+ }
+
+ return messages;
+ },
+ dispatchMessage(message, action = false, isNotice = false) {
+ if (!message.length) {
+ return;
+ }
+
+ if (action) {
+ if (!this._account.sendCTCPMessage(this.name, false, "ACTION", message)) {
+ this.writeMessage(
+ this._account._currentServerName,
+ lazy._("error.sendMessageFailed"),
+ {
+ error: true,
+ system: true,
+ }
+ );
+ return;
+ }
+ } else if (
+ !this._account.sendMessage(isNotice ? "NOTICE" : "PRIVMSG", [
+ this.name,
+ message,
+ ])
+ ) {
+ this.writeMessage(
+ this._account._currentServerName,
+ lazy._("error.sendMessageFailed"),
+ {
+ error: true,
+ system: true,
+ }
+ );
+ return;
+ }
+
+ // By default the server doesn't send the message back, but this can be
+ // enabled with the echo-message capability. If this is not enabled, just
+ // assume the message was received and immediately show it.
+ if (!this._account._activeCAPs.has("echo-message")) {
+ this.writeMessage(
+ this._account.imAccount.alias ||
+ this._account.imAccount.statusInfo.displayName ||
+ this._account._nickname,
+ message,
+ {
+ outgoing: true,
+ notification: isNotice,
+ action,
+ }
+ );
+ }
+
+ this._pendingMessage = true;
+ },
+ // IRC doesn't support typing notifications, but it does have a maximum
+ // message length.
+ sendTyping(aString) {
+ let longestLineLength = Math.max.apply(
+ null,
+ aString.split("\n").map(this._account.countBytes, this._account)
+ );
+ return this.getMaxMessageLength() - longestLineLength;
+ },
+
+ requestCurrentWhois(aNick) {
+ if (!this._observedNicks.length) {
+ Services.obs.addObserver(this, "user-info-received");
+ }
+ this._observedNicks.push(this.normalizeNick(aNick));
+ this._account.requestCurrentWhois(aNick);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "user-info-received") {
+ return;
+ }
+
+ let nick = this.normalizeNick(aData);
+ let nickIndex = this._observedNicks.indexOf(nick);
+ if (nickIndex == -1) {
+ return;
+ }
+
+ // Remove the nick from the list of nicks that are being waited to received.
+ this._observedNicks.splice(nickIndex, 1);
+
+ // If this is the last nick, remove the observer.
+ if (!this._observedNicks.length) {
+ Services.obs.removeObserver(this, "user-info-received");
+ }
+
+ // If we are waiting for the conversation name, set it.
+ let account = this._account;
+ if (this._waitingForNick && nick == this.normalizedName) {
+ if (account.whoisInformation.has(nick)) {
+ this.updateNick(account.whoisInformation.get(nick).nick);
+ }
+ delete this._waitingForNick;
+ return;
+ }
+
+ // Otherwise, print the requested whois information.
+ let type = { system: true, noLog: true };
+ // RFC 2812 errors 401 and 406 result in there being no entry for the nick.
+ if (!account.whoisInformation.has(nick)) {
+ this.writeMessage(null, lazy._("message.unknownNick", nick), type);
+ return;
+ }
+ // If the nick is offline, tell the user. In that case, it's WHOWAS info.
+ let msgType = "message.whois";
+ if ("offline" in account.whoisInformation.get(nick)) {
+ msgType = "message.whowas";
+ }
+ let msg = lazy._(msgType, account.whoisInformation.get(nick).nick);
+
+ // Iterate over each field.
+ for (let elt of aSubject.QueryInterface(Ci.nsISimpleEnumerator)) {
+ switch (elt.type) {
+ case Ci.prplITooltipInfo.pair:
+ case Ci.prplITooltipInfo.sectionHeader:
+ msg += "\n" + lazy._("message.whoisEntry", elt.label, elt.value);
+ break;
+ case Ci.prplITooltipInfo.sectionBreak:
+ break;
+ case Ci.prplITooltipInfo.status:
+ if (elt.label != Ci.imIStatusInfo.STATUS_AWAY) {
+ break;
+ }
+ // The away message has no tooltipInfo.pair entry.
+ msg +=
+ "\n" +
+ lazy._("message.whoisEntry", lazy._("tooltip.away"), elt.value);
+ break;
+ }
+ }
+ this.writeMessage(null, msg, type);
+ },
+
+ unInitIRCConversation() {
+ this._account.removeConversation(this.name);
+ if (this._observedNicks.length) {
+ Services.obs.removeObserver(this, "user-info-received");
+ }
+ },
+};
+
+export function ircChannel(aAccount, aName, aNick) {
+ this._init(aAccount, aName, aNick);
+ this._participants = new NormalizedMap(this.normalizeNick.bind(this));
+ this._modes = new Set();
+ this._observedNicks = [];
+ this.banMasks = [];
+}
+
+ircChannel.prototype = {
+ __proto__: GenericConvChatPrototype,
+ _modes: null,
+ _receivedInitialMode: false,
+ // For IRC you're not in a channel until the JOIN command is received, open
+ // all channels (initially) as left.
+ _left: true,
+ // True while we are rejoining a channel previously parted by the user.
+ _rejoined: false,
+ banMasks: [],
+
+ // Section 3.2.2 of RFC 2812.
+ part(aMessage) {
+ let params = [this.name];
+
+ // If a valid message was given, use it as the part message.
+ // Otherwise, fall back to the default part message, if it exists.
+ let msg = aMessage || this._account.getString("partmsg");
+ if (msg) {
+ params.push(msg);
+ }
+
+ this._account.sendMessage("PART", params);
+
+ // Remove reconnection information.
+ delete this.chatRoomFields;
+ },
+
+ close() {
+ // Part the room if we're connected.
+ if (this._account.connected && !this.left) {
+ this.part();
+ }
+ GenericConvChatPrototype.close.call(this);
+ },
+
+ unInit() {
+ this.unInitIRCConversation();
+ GenericConvChatPrototype.unInit.call(this);
+ },
+
+ // Use the normalized nick in order to properly notify the observers.
+ getNormalizedChatBuddyName(aNick) {
+ return this.normalizeNick(aNick);
+ },
+
+ getParticipant(aNick, aNotifyObservers) {
+ if (this._participants.has(aNick)) {
+ return this._participants.get(aNick);
+ }
+
+ let participant = new ircParticipant(aNick, this);
+ this._participants.set(aNick, participant);
+
+ // Add the participant to the whois table if it is not already there.
+ this._account.setWhois(participant._name);
+
+ if (aNotifyObservers) {
+ this.notifyObservers(
+ new nsSimpleEnumerator([participant]),
+ "chat-buddy-add"
+ );
+ }
+ return participant;
+ },
+
+ /*
+ * Add/remove modes from this channel.
+ *
+ * aNewMode is the new mode string, it MUST begin with + or -.
+ * aModeParams is a list of ordered string parameters for the mode string.
+ * aSetter is the nick of the person (or service) that set the mode.
+ */
+ setMode(aNewMode, aModeParams, aSetter) {
+ // Save this for a comparison after the new modes have been set.
+ let previousTopicSettable = this.topicSettable;
+
+ const hostMaskExp = /^.+!.+@.+$/;
+ function getNextParam() {
+ // If there's no next parameter, throw a warning.
+ if (!aModeParams.length) {
+ this.WARN("Mode parameter expected!");
+ return undefined;
+ }
+ return aModeParams.pop();
+ }
+ function peekNextParam() {
+ // Non-destructively gets the next param.
+ if (!aModeParams.length) {
+ return undefined;
+ }
+ return aModeParams.slice(-1)[0];
+ }
+
+ // Are modes being added or removed?
+ if (aNewMode[0] != "+" && aNewMode[0] != "-") {
+ this.WARN("Invalid mode string: " + aNewMode);
+ return;
+ }
+ let addNewMode = aNewMode[0] == "+";
+
+ // Check each mode being added and update the user.
+ let channelModes = [];
+ let userModes = new NormalizedMap(this.normalizeNick.bind(this));
+ let msg;
+
+ for (let i = aNewMode.length - 1; i > 0; --i) {
+ // Since some modes are conflicted between different server
+ // implementations, check if a participant with that name exists. If this
+ // is true, then update the mode of the ConvChatBuddy.
+ if (
+ this._account.memberStatuses.includes(aNewMode[i]) &&
+ aModeParams.length &&
+ this._participants.has(peekNextParam())
+ ) {
+ // Store the new modes for this nick (so each participant's mode is only
+ // updated once).
+ let nick = getNextParam();
+ if (!userModes.has(nick)) {
+ userModes.set(nick, []);
+ }
+ userModes.get(nick).push(aNewMode[i]);
+
+ // Don't use this mode as a channel mode.
+ continue;
+ } else if (aNewMode[i] == "k") {
+ // Channel key.
+ let newFields = this.name;
+ if (addNewMode) {
+ let key = getNextParam();
+ // A new channel key was set, display a message if this key is not
+ // already known.
+ if (
+ this.chatRoomFields &&
+ this.chatRoomFields.getValue("password") == key
+ ) {
+ continue;
+ }
+ msg = lazy._("message.channelKeyAdded", aSetter, key);
+ newFields += " " + key;
+ } else {
+ msg = lazy._("message.channelKeyRemoved", aSetter);
+ }
+
+ this.writeMessage(aSetter, msg, { system: true });
+ // Store the new fields for reconnect.
+ this.chatRoomFields =
+ this._account.getChatRoomDefaultFieldValues(newFields);
+ } else if (aNewMode[i] == "b") {
+ // A banmask was added or removed.
+ let banMask = getNextParam();
+ let msgKey = "message.banMask";
+ if (addNewMode) {
+ this.banMasks.push(banMask);
+ msgKey += "Added";
+ } else {
+ this.banMasks = this.banMasks.filter(aBanMask => banMask != aBanMask);
+ msgKey += "Removed";
+ }
+ this.writeMessage(aSetter, lazy._(msgKey, banMask, aSetter), {
+ system: true,
+ });
+ } else if (["e", "I", "l"].includes(aNewMode[i])) {
+ // TODO The following have parameters that must be accounted for.
+ getNextParam();
+ } else if (
+ aNewMode[i] == "R" &&
+ aModeParams.length &&
+ peekNextParam().match(hostMaskExp)
+ ) {
+ // REOP_LIST takes a mask as a parameter, since R is a conflicted mode,
+ // try to match the parameter. Implemented by IRCNet.
+ // TODO The parameter must be acounted for.
+ getNextParam();
+ }
+ // TODO From RFC 2811: a, i, m, n, q, p, s, r, t, l, e, I.
+
+ // Keep track of the channel modes in the order they were received.
+ channelModes.unshift(aNewMode[i]);
+ }
+
+ if (aModeParams.length) {
+ this.WARN("Unused mode parameters: " + aModeParams.join(", "));
+ }
+
+ // Update the mode of each participant.
+ for (let [nick, mode] of userModes.entries()) {
+ this.getParticipant(nick).setMode(addNewMode, mode, aSetter);
+ }
+
+ // If the topic can now be set (and it couldn't previously) or vice versa,
+ // notify the UI. Note that this status can change by either a channel mode
+ // or a user mode changing.
+ if (this.topicSettable != previousTopicSettable) {
+ this.notifyObservers(this, "chat-update-topic");
+ }
+
+ // If no channel modes were being set, don't display a message for it.
+ if (!channelModes.length) {
+ return;
+ }
+
+ // Store the channel modes.
+ _setMode.call(this, addNewMode, channelModes);
+
+ // Notify the UI of changes.
+ msg = lazy._(
+ "message.channelmode",
+ aNewMode[0] + channelModes.join(""),
+ aSetter
+ );
+ this.writeMessage(aSetter, msg, { system: true });
+
+ this._receivedInitialMode = true;
+ },
+
+ setModesFromRestriction(aRestriction) {
+ // First remove all types from the list of modes.
+ for (let key in this._account.channelRestrictionToModeMap) {
+ let mode = this._account.channelRestrictionToModeMap[key];
+ this._modes.delete(mode);
+ }
+
+ // Add the new mode onto the list.
+ if (aRestriction in this._account.channelRestrictionToModeMap) {
+ let mode = this._account.channelRestrictionToModeMap[aRestriction];
+ if (mode) {
+ this._modes.add(mode);
+ }
+ }
+ },
+
+ get topic() {
+ return this._topic;
+ }, // can't add a setter without redefining the getter
+ set topic(aTopic) {
+ // Note that the UI isn't updated here because the server will echo back the
+ // TOPIC to us and we'll set it on receive.
+ this._account.sendMessage("TOPIC", [this.name, aTopic]);
+ },
+ get topicSettable() {
+ // Don't use getParticipant since we don't want to lazily create it!
+ let participant = this._participants.get(this.nick);
+
+ // We must be in the room to set the topic.
+ if (!participant) {
+ return false;
+ }
+
+ // If the channel mode is +t, hops and ops can set the topic; otherwise
+ // everyone can.
+ return !this._modes.has("t") || participant.admin || participant.moderator;
+ },
+ writeMessage(aWho, aMsg, aObject) {
+ const messageProps = this.handleTags(aWho, aMsg, aObject);
+ GenericConvChatPrototype.writeMessage.call(this, aWho, aMsg, messageProps);
+ },
+};
+Object.assign(ircChannel.prototype, GenericIRCConversation);
+
+function ircParticipant(aName, aConv) {
+ this._name = aName;
+ this._conv = aConv;
+ this._account = aConv._account;
+ this._modes = new Set();
+
+ // Handle multi-prefix modes.
+ let i;
+ for (
+ i = 0;
+ i < this._name.length && this._name[i] in this._account.userPrefixToModeMap;
+ ++i
+ ) {
+ let mode = this._account.userPrefixToModeMap[this._name[i]];
+ if (mode) {
+ this._modes.add(mode);
+ }
+ }
+ this._name = this._name.slice(i);
+}
+ircParticipant.prototype = {
+ __proto__: GenericConvChatBuddyPrototype,
+
+ setMode(aAddNewMode, aNewModes, aSetter) {
+ _setMode.call(this, aAddNewMode, aNewModes);
+
+ // Notify the UI of changes.
+ let msg = lazy._(
+ "message.usermode",
+ (aAddNewMode ? "+" : "-") + aNewModes.join(""),
+ this.name,
+ aSetter
+ );
+ this._conv.writeMessage(aSetter, msg, { system: true });
+ this._conv.notifyObservers(this, "chat-buddy-update");
+ },
+
+ get voiced() {
+ return this._modes.has("v");
+ },
+ get moderator() {
+ return this._modes.has("h");
+ },
+ get admin() {
+ return this._modes.has("o");
+ },
+ get founder() {
+ return this._modes.has("O") || this._modes.has("q");
+ },
+ get typing() {
+ return false;
+ },
+};
+
+export function ircConversation(aAccount, aName) {
+ let nick = aAccount.normalize(aName);
+ if (aAccount.whoisInformation.has(nick)) {
+ aName = aAccount.whoisInformation.get(nick).nick;
+ }
+
+ this._init(aAccount, aName);
+ this._observedNicks = [];
+
+ // Fetch correctly capitalized name.
+ // Always request the info as it may be out of date.
+ this._waitingForNick = true;
+ this.requestCurrentWhois(aName);
+}
+
+ircConversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ get buddy() {
+ return this._account.buddies.get(this.name);
+ },
+
+ unInit() {
+ this.unInitIRCConversation();
+ GenericConvIMPrototype.unInit.call(this);
+ },
+
+ updateNick(aNewNick) {
+ this._name = aNewNick;
+ this.notifyObservers(null, "update-conv-title");
+ },
+ writeMessage(aWho, aMsg, aObject) {
+ const messageProps = this.handleTags(aWho, aMsg, aObject);
+ GenericConvIMPrototype.writeMessage.call(this, aWho, aMsg, messageProps);
+ },
+};
+Object.assign(ircConversation.prototype, GenericIRCConversation);
+
+function ircSocket(aAccount) {
+ this._account = aAccount;
+ this._initCharsetConverter();
+}
+ircSocket.prototype = {
+ __proto__: Socket,
+ // Although RFCs 1459 and 2812 explicitly say that \r\n is the message
+ // separator, some networks (euIRC) only send \n.
+ delimiter: /\r?\n/,
+ connectTimeout: 60, // Failure to connect after 1 minute
+ readWriteTimeout: 300, // Failure when no data for 5 minutes
+ _converter: null,
+
+ sendPing() {
+ // Send a ping using the current timestamp as a payload prefixed with
+ // an underscore to signify this was an "automatic" PING (used to avoid
+ // socket timeouts).
+ this._account.sendMessage("PING", "_" + Date.now());
+ },
+
+ _initCharsetConverter() {
+ try {
+ this._converter = new TextDecoder(this._account._encoding);
+ } catch (e) {
+ delete this._converter;
+ this.ERROR(
+ "Failed to set character set to: " +
+ this._account._encoding +
+ " for " +
+ this._account.name +
+ "."
+ );
+ }
+ },
+
+ // Implement Section 5 of RFC 2812.
+ onDataReceived(aRawMessage) {
+ let conversionWarning = "";
+ if (this._converter) {
+ try {
+ let buffer = Uint8Array.from(aRawMessage, c => c.charCodeAt(0));
+ aRawMessage = this._converter.decode(buffer);
+ } catch (e) {
+ conversionWarning =
+ "\nThis message doesn't seem to be " +
+ this._account._encoding +
+ " encoded.";
+ // Unfortunately, if the unicode converter failed once,
+ // it will keep failing so we need to reinitialize it.
+ this._initCharsetConverter();
+ }
+ }
+
+ // We've received data and are past the authentication stage.
+ if (this._account.connected) {
+ this.resetPingTimer();
+ }
+
+ // Low level dequote: replace quote character \020 followed by 0, n, r or
+ // \020 with a \0, \n, \r or \020, respectively. Any other character is
+ // replaced with itself.
+ const lowDequote = { 0: "\0", n: "\n", r: "\r", "\x10": "\x10" };
+ let dequotedMessage = aRawMessage.replace(
+ // eslint-disable-next-line no-control-regex
+ /\x10./g,
+ aStr => lowDequote[aStr[1]] || aStr[1]
+ );
+
+ try {
+ let message = new ircMessage(
+ dequotedMessage,
+ this._account._currentServerName
+ );
+ this.DEBUG(JSON.stringify(message) + conversionWarning);
+ if (!lazy.ircHandlers.handleMessage(this._account, message)) {
+ // If the message was not handled, throw a warning containing
+ // the original quoted message.
+ this.WARN("Unhandled IRC message:\n" + aRawMessage);
+ }
+ } catch (e) {
+ // Catch the error, display it and hope the connection can continue with
+ // this message in error. Errors are also caught inside of handleMessage,
+ // but we expect to handle message parsing errors here.
+ this.DEBUG(aRawMessage + conversionWarning);
+ this.ERROR(e);
+ }
+ },
+ onConnection() {
+ this._account._connectionRegistration();
+ },
+ disconnect() {
+ if (!this._account) {
+ return;
+ }
+ Socket.disconnect.call(this);
+ delete this._account;
+ },
+
+ // Throw errors if the socket has issues.
+ onConnectionClosed() {
+ // If the account was already disconnected, e.g. in response to
+ // onConnectionReset, do nothing.
+ if (!this._account) {
+ return;
+ }
+ const msg = "Connection closed by server.";
+ if (this._account.disconnecting) {
+ // The server closed the connection before we handled the ERROR
+ // response to QUIT.
+ this.LOG(msg);
+ this._account.gotDisconnected();
+ } else {
+ this.WARN(msg);
+ this._account.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.lost")
+ );
+ }
+ },
+ onConnectionReset() {
+ this.WARN("Connection reset.");
+ this._account.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.lost")
+ );
+ },
+ onConnectionTimedOut() {
+ this.WARN("Connection timed out.");
+ this._account.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.timeOut")
+ );
+ },
+ onConnectionSecurityError(aTLSError, aNSSErrorMessage) {
+ this.WARN(
+ "Bad certificate or SSL connection for " +
+ this._account.name +
+ ":\n" +
+ aNSSErrorMessage
+ );
+ let error = this._account.handleConnectionSecurityError(this);
+ this._account.gotDisconnected(error, aNSSErrorMessage);
+ },
+
+ get DEBUG() {
+ return this._account.DEBUG;
+ },
+ get LOG() {
+ return this._account.LOG;
+ },
+ get WARN() {
+ return this._account.WARN;
+ },
+ get ERROR() {
+ return this._account.ERROR;
+ },
+};
+
+function ircAccountBuddy(aAccount, aBuddy, aTag, aUserName) {
+ this._init(aAccount, aBuddy, aTag, aUserName);
+}
+ircAccountBuddy.prototype = {
+ __proto__: GenericAccountBuddyPrototype,
+
+ // Returns an array of prplITooltipInfo objects to be displayed when the
+ // user hovers over the buddy.
+ getTooltipInfo() {
+ return this._account.getBuddyInfo(this.normalizedName);
+ },
+
+ // Allow sending of messages to buddies even if they are not online since IRC
+ // does not always provide status information in a timely fashion. (Note that
+ // this is OK since the server will throw an error if the user is not online.)
+ get canSendMessage() {
+ return this.account.connected;
+ },
+
+ // Called when the user wants to chat with the buddy.
+ createConversation() {
+ return this._account.createConversation(this.userName);
+ },
+
+ remove() {
+ this._account.removeBuddy(this);
+ GenericAccountBuddyPrototype.remove.call(this);
+ },
+};
+
+function ircRoomInfo(aName, aAccount) {
+ this.name = aName;
+ this._account = aAccount;
+}
+ircRoomInfo.prototype = {
+ __proto__: ClassInfo("prplIRoomInfo", "IRC RoomInfo Object"),
+ get topic() {
+ return this._account._channelList.get(this.name).topic;
+ },
+ get participantCount() {
+ return this._account._channelList.get(this.name).participantCount;
+ },
+ get chatRoomFieldValues() {
+ return this._account.getChatRoomDefaultFieldValues(this.name);
+ },
+};
+
+export function ircAccount(aProtocol, aImAccount) {
+ this._init(aProtocol, aImAccount);
+ this.buddies = new NormalizedMap(this.normalizeNick.bind(this));
+ this.conversations = new NormalizedMap(this.normalize.bind(this));
+
+ // Split the account name into usable parts.
+ const [accountNickname, server] = this.protocol.splitUsername(this.name);
+ this._accountNickname = accountNickname;
+ this._server = server;
+ // To avoid _currentServerName being null, initialize it to the server being
+ // connected to. This will also get overridden during the 001 response from
+ // the server.
+ this._currentServerName = this._server;
+
+ this._nickname = this._accountNickname;
+ this._requestedNickname = this._nickname;
+
+ // For more information, see where these are defined in the prototype below.
+ this.trackQueue = [];
+ this.pendingIsOnQueue = [];
+ this.whoisInformation = new NormalizedMap(this.normalizeNick.bind(this));
+ this._requestedCAPs = new Set();
+ this._availableCAPs = new Set();
+ this._activeCAPs = new Set();
+ this._queuedCAPs = [];
+ this._commandBuffers = new Map();
+ this._roomInfoCallbacks = new Set();
+}
+
+ircAccount.prototype = {
+ __proto__: GenericAccountPrototype,
+ _socket: null,
+ _MODE_WALLOPS: 1 << 2, // mode 'w'
+ _MODE_INVISIBLE: 1 << 3, // mode 'i'
+ get _mode() {
+ return 0;
+ },
+
+ // The name of the server we last connected to.
+ _currentServerName: null,
+ // Whether to attempt authenticating with NickServ.
+ shouldAuthenticate: true,
+ // Whether the user has successfully authenticated with NickServ.
+ isAuthenticated: false,
+ // The current in use nickname.
+ _nickname: null,
+ // The nickname stored in the account name.
+ _accountNickname: null,
+ // The nickname that was last requested by the user.
+ _requestedNickname: null,
+ // The nickname that was last requested. This can differ from
+ // _requestedNickname when a new nick is automatically generated (e.g. by
+ // adding digits).
+ _sentNickname: null,
+ // If we don't get the desired nick on connect, we try again a bit later,
+ // to see if it wasn't just our nick not having timed out yet.
+ _nickInUseTimeout: null,
+ get username() {
+ let username;
+ // Use a custom username in a hidden preference.
+ if (this.prefs.prefHasUserValue("username")) {
+ username = this.getString("username");
+ }
+ // But fallback to brandShortName if no username is provided (or is empty).
+ if (!username) {
+ username = Services.appinfo.name;
+ }
+
+ return username;
+ },
+ // The prefix minus the nick (!user@host) as returned by the server, this is
+ // necessary for guessing message lengths.
+ prefix: null,
+
+ // Parts of the specification give max lengths, keep track of them since a
+ // server can overwrite them. The defaults given here are from RFC 2812.
+ maxNicknameLength: 9, // 1.2.1 Users
+ maxChannelLength: 50, // 1.3 Channels
+ maxMessageLength: 512, // 2.3 Messages
+ maxHostnameLength: 63, // 2.3.1 Message format in Augmented BNF
+
+ // The default prefixes to modes.
+ userPrefixToModeMap: { "@": "o", "!": "n", "%": "h", "+": "v" },
+ get userPrefixes() {
+ return Object.keys(this.userPrefixToModeMap);
+ },
+ // Modes that have a nickname parameter and affect a participant. See 4.1
+ // Member Status of RFC 2811.
+ memberStatuses: ["a", "h", "o", "O", "q", "v", "!"],
+ channelPrefixes: ["&", "#", "+", "!"], // 1.3 Channels
+ channelRestrictionToModeMap: { "@": "s", "*": "p", "=": null }, // 353 RPL_NAMREPLY
+
+ // Handle Scandanavian lower case (optionally remove status indicators).
+ // See Section 2.2 of RFC 2812: the characters {}|^ are considered to be the
+ // lower case equivalents of the characters []\~, respectively.
+ normalizeExpression: /[\x41-\x5E]/g,
+ normalize(aStr, aPrefixes) {
+ let str = aStr;
+
+ if (aPrefixes) {
+ while (aPrefixes.includes(str[0])) {
+ str = str.slice(1);
+ }
+ }
+
+ return str.replace(this.normalizeExpression, c =>
+ String.fromCharCode(c.charCodeAt(0) + 0x20)
+ );
+ },
+ normalizeNick(aNick) {
+ return this.normalize(aNick, this.userPrefixes);
+ },
+
+ isMUCName(aStr) {
+ return this.channelPrefixes.includes(aStr[0]);
+ },
+
+ // Tell the server about status changes. IRC is only away or not away;
+ // consider the away, idle and unavailable status type to be away.
+ isAway: false,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "status-changed") {
+ return;
+ }
+
+ let { statusType: type, statusText: text } = this.imAccount.statusInfo;
+ this.DEBUG("New status received:\ntype = " + type + "\ntext = " + text);
+
+ // Tell the server to mark us as away.
+ if (type < Ci.imIStatusInfo.STATUS_AVAILABLE) {
+ // We have to have a string in order to set IRC as AWAY.
+ if (!text) {
+ // If no status is given, use the the default idle/away message.
+ const IDLE_PREF_BRANCH = "messenger.status.";
+ const IDLE_PREF = "defaultIdleAwayMessage";
+ text = Services.prefs.getComplexValue(
+ IDLE_PREF_BRANCH + IDLE_PREF,
+ Ci.nsIPrefLocalizedString
+ ).data;
+
+ if (!text) {
+ // Get the default value of the localized preference.
+ text = Services.prefs
+ .getDefaultBranch(IDLE_PREF_BRANCH)
+ .getComplexValue(IDLE_PREF, Ci.nsIPrefLocalizedString).data;
+ }
+ // The last resort, fallback to a non-localized string.
+ if (!text) {
+ text = "Away";
+ }
+ }
+ this.sendMessage("AWAY", text); // Mark as away.
+ } else if (type == Ci.imIStatusInfo.STATUS_AVAILABLE && this.isAway) {
+ // Mark as back.
+ this.sendMessage("AWAY");
+ }
+ },
+
+ // The user's user mode.
+ _modes: null,
+ _userModeReceived: false,
+ setUserMode(aNick, aNewModes, aSetter, aDisplayFullMode) {
+ if (this.normalizeNick(aNick) != this.normalizeNick(this._nickname)) {
+ this.WARN("Received unexpected mode for " + aNick);
+ return false;
+ }
+
+ // Are modes being added or removed?
+ let addNewMode = aNewModes[0] == "+";
+ if (!addNewMode && aNewModes[0] != "-") {
+ this.WARN("Invalid mode string: " + aNewModes);
+ return false;
+ }
+ _setMode.call(this, addNewMode, aNewModes.slice(1));
+
+ // The server informs us of the user's mode when connecting.
+ // We should not report this initial mode message as a mode change
+ // initiated by the user, but instead display the full mode
+ // and then remember we have done so.
+ this._userModeReceived = true;
+
+ if (this._showServerTab) {
+ let msg;
+ if (aDisplayFullMode) {
+ msg = lazy._("message.yourmode", Array.from(this._modes).join(""));
+ } else {
+ msg = lazy._(
+ "message.usermode",
+ aNewModes,
+ aNick,
+ aSetter || this._currentServerName
+ );
+ }
+ this.getConversation(this._currentServerName).writeMessage(
+ this._currentServerName,
+ msg,
+ { system: true }
+ );
+ }
+ return true;
+ },
+
+ // Room info: maps channel names to {topic, participantCount}.
+ _channelList: new Map(),
+ _roomInfoCallbacks: new Set(),
+ // If true, we have sent the LIST request and are waiting for replies.
+ _pendingList: false,
+ // Callbacks receive this many channels per call while results are incoming.
+ _channelsPerBatch: 50,
+ _currentBatch: [],
+ _lastListTime: 0,
+ get isRoomInfoStale() {
+ return Date.now() - this._lastListTime > kListRefreshInterval;
+ },
+ // Called by consumers that want a list of available channels, which are
+ // provided through the callback (prplIRoomInfoCallback instance).
+ requestRoomInfo(aCallback, aIsUserRequest) {
+ // Ignore the automaticList pref if the user explicitly requests /list.
+ if (
+ !aIsUserRequest &&
+ !Services.prefs.getBoolPref("chat.irc.automaticList")
+ ) {
+ // Pretend we can't return roomInfo.
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ if (this._roomInfoCallbacks.has(aCallback)) {
+ // Callback is not new.
+ return;
+ }
+ // Send a LIST request if the channel list is stale and a current request
+ // has not been sent.
+ if (this.isRoomInfoStale && !this._pendingList) {
+ this._channelList = new Map();
+ this._currentBatch = [];
+ this._pendingList = true;
+ this._lastListTime = Date.now();
+ this.sendMessage("LIST");
+ } else {
+ // Otherwise, pass channels that have already been received to the callback.
+ let rooms = [...this._channelList.keys()];
+ aCallback.onRoomInfoAvailable(rooms, !this._pendingList);
+ }
+
+ if (this._pendingList) {
+ this._roomInfoCallbacks.add(aCallback);
+ }
+ },
+ // Pass room info for any remaining channels to callbacks and clean up.
+ _sendRemainingRoomInfo() {
+ if (this._currentBatch.length) {
+ for (let callback of this._roomInfoCallbacks) {
+ callback.onRoomInfoAvailable(this._currentBatch, true);
+ }
+ }
+ this._roomInfoCallbacks.clear();
+ delete this._pendingList;
+ delete this._currentBatch;
+ },
+ getRoomInfo(aName) {
+ return new ircRoomInfo(aName, this);
+ },
+
+ // The last time a buffered command was sent.
+ _lastCommandSendTime: 0,
+ // A map from command names to the parameter buffer for that command.
+ // This buffer is a map from first parameter to the corresponding (optional)
+ // second parameter, to ensure automatic deduplication.
+ _commandBuffers: new Map(),
+ _handleCommandBuffer(aCommand) {
+ let buffer = this._commandBuffers.get(aCommand);
+ if (!buffer || !buffer.size) {
+ return;
+ }
+ // This short delay should usually not affect commands triggered by
+ // user action, but helps gather commands together which are sent
+ // by the prpl on connection (e.g. WHOIS sent in response to incoming
+ // WATCH results).
+ const kInterval = 1000;
+ let delay = kInterval - (Date.now() - this._lastCommandSendTime);
+ if (delay > 0) {
+ setTimeout(() => this._handleCommandBuffer(aCommand), delay);
+ return;
+ }
+ this._lastCommandSendTime = Date.now();
+
+ let getParams = aItems => {
+ // Taking the JOIN use case as an example, aItems is an array
+ // of [channel, key] pairs.
+ // To work around an inspircd bug (bug 1108596), we reorder
+ // the list so that entries with keys appear first.
+ let items = aItems.slice().sort(([c1, k1], [c2, k2]) => {
+ if (!k1 && k2) {
+ return 1;
+ }
+ if (k1 && !k2) {
+ return -1;
+ }
+ return 0;
+ });
+ // To send the command, we have to group all the channels and keys
+ // together, i.e. grab the columns of this matrix, and build the two
+ // parameters of the command from that.
+ let channels = items.map(([channel, key]) => channel);
+ let keys = items.map(([channel, key]) => key).filter(key => !!key);
+ let params = [channels.join(",")];
+ if (keys.length) {
+ params.push(keys.join(","));
+ }
+ return params;
+ };
+ let tooMany = aItems => {
+ let params = getParams(aItems);
+ let length = this.countBytes(this.buildMessage(aCommand, params)) + 2;
+ return this.maxMessageLength < length;
+ };
+ let send = aItems => {
+ let params = getParams(aItems);
+ // Send the command, but don't log the keys.
+ this.sendMessage(
+ aCommand,
+ params,
+ aCommand +
+ " " +
+ params[0] +
+ (params.length > 1 ? " <keys not logged>" : "")
+ );
+ };
+
+ let items = [];
+ for (let item of buffer) {
+ items.push(item);
+ if (tooMany(items)) {
+ items.pop();
+ send(items);
+ items = [item];
+ }
+ }
+ send(items);
+ buffer.clear();
+ },
+ // For commands which allow an arbitrary number of parameters, we use a
+ // buffer to send as few commands as possible, by gathering the parameters.
+ // On servers which impose command penalties (e.g. inspircd) this helps
+ // avoid triggering fakelags by minimizing the command penalty.
+ // aParam is the first and aKey the optional second parameter of a command
+ // with the syntax <param> *("," <param>) [<key> *("," <key>)]
+ // While this code is mostly abstracted, it is currently assumed the second
+ // parameter is only used for JOIN.
+ sendBufferedCommand(aCommand, aParam, aKey = "") {
+ if (!this._commandBuffers.has(aCommand)) {
+ this._commandBuffers.set(aCommand, new Map());
+ }
+ let buffer = this._commandBuffers.get(aCommand);
+ // If the buffer is empty, schedule sending the command, otherwise
+ // we just need to add the parameter to the buffer.
+ // We use executeSoon so as to not delay the sending of these
+ // commands when it is not necessary.
+ if (!buffer.size) {
+ executeSoon(() => this._handleCommandBuffer(aCommand));
+ }
+ buffer.set(aParam, aKey);
+ },
+
+ // The whois information: nicks are used as keys and refer to a map of field
+ // to value.
+ whoisInformation: null,
+ // Request WHOIS information on a buddy when the user requests more
+ // information. If we already have some WHOIS information stored for this
+ // nick, a notification with this (potentially out-of-date) information
+ // is sent out immediately. It is followed by another notification when
+ // the current WHOIS data is returned by the server.
+ // If you are only interested in the current WHOIS, requestCurrentWhois
+ // should be used instead.
+ requestBuddyInfo(aBuddyName) {
+ if (!this.connected) {
+ return;
+ }
+
+ // Return what we have stored immediately.
+ if (this.whoisInformation.has(aBuddyName)) {
+ this.notifyWhois(aBuddyName);
+ }
+
+ // Request the current whois and update.
+ this.requestCurrentWhois(aBuddyName);
+ },
+ // Request fresh WHOIS information on a nick.
+ requestCurrentWhois(aNick) {
+ if (!this.connected) {
+ return;
+ }
+
+ this.removeBuddyInfo(aNick);
+ this.sendBufferedCommand("WHOIS", aNick);
+ },
+ notifyWhois(aNick) {
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(this.getBuddyInfo(aNick)),
+ "user-info-received",
+ this.normalizeNick(aNick)
+ );
+ },
+ // Request WHOWAS information on a buddy when the user requests more
+ // information.
+ requestOfflineBuddyInfo(aBuddyName) {
+ this.removeBuddyInfo(aBuddyName);
+ this.sendMessage("WHOWAS", aBuddyName);
+ },
+ // Return an array of prplITooltipInfo for a given nick.
+ getBuddyInfo(aNick) {
+ if (!this.whoisInformation.has(aNick)) {
+ return [];
+ }
+
+ let whoisInformation = this.whoisInformation.get(aNick);
+ if (whoisInformation.serverName && whoisInformation.serverInfo) {
+ whoisInformation.server = lazy._(
+ "tooltip.serverValue",
+ whoisInformation.serverName,
+ whoisInformation.serverInfo
+ );
+ }
+
+ // Sort the list of channels, ignoring the prefixes of channel and user.
+ let prefixes = this.userPrefixes.concat(this.channelPrefixes);
+ let sortWithoutPrefix = function (a, b) {
+ a = this.normalize(a, prefixes);
+ b = this.normalize(b, prefixes);
+ if (a < b) {
+ return -1;
+ }
+ return a > b ? 1 : 0;
+ }.bind(this);
+ let sortChannels = channels =>
+ channels.trim().split(/\s+/).sort(sortWithoutPrefix).join(" ");
+
+ // Convert booleans into a human-readable form.
+ let normalizeBool = aBool => lazy._(aBool ? "yes" : "no");
+
+ // Convert timespan in seconds into a human-readable form.
+ let normalizeTime = function (aTime) {
+ let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(aTime);
+ // If the time is exact to the first set of units, trim off
+ // the subsequent zeroes.
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ return lazy._("tooltip.timespan", valuesAndUnits.join(" "));
+ };
+
+ // List of the names of the info to actually show in the tooltip and
+ // optionally a transform function to apply to the value. Each field here
+ // maps to tooltip.<fieldname> in irc.properties.
+ // See the various RPL_WHOIS* results for the options.
+ const kFields = {
+ realname: null,
+ server: null,
+ connectedFrom: null,
+ registered: normalizeBool,
+ registeredAs: null,
+ secure: normalizeBool,
+ ircOp: normalizeBool,
+ bot: normalizeBool,
+ lastActivity: normalizeTime,
+ channels: sortChannels,
+ };
+
+ let tooltipInfo = [];
+ for (let field in kFields) {
+ if (whoisInformation.hasOwnProperty(field) && whoisInformation[field]) {
+ let value = whoisInformation[field];
+ if (kFields[field]) {
+ value = kFields[field](value);
+ }
+ tooltipInfo.push(new TooltipInfo(lazy._("tooltip." + field), value));
+ }
+ }
+
+ const kSetIdleStatusAfterSeconds = 3600;
+ let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE;
+ let statusText = "";
+ if ("away" in whoisInformation) {
+ statusType = Ci.imIStatusInfo.STATUS_AWAY;
+ statusText = whoisInformation.away;
+ } else if ("offline" in whoisInformation) {
+ statusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ } else if (
+ "lastActivity" in whoisInformation &&
+ whoisInformation.lastActivity > kSetIdleStatusAfterSeconds
+ ) {
+ statusType = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+ tooltipInfo.push(
+ new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status)
+ );
+
+ return tooltipInfo;
+ },
+ // Remove a WHOIS entry.
+ removeBuddyInfo(aNick) {
+ return this.whoisInformation.delete(aNick);
+ },
+ // Copies the fields of aFields into the whois table. If the field already
+ // exists, that field is ignored (it is assumed that the first server response
+ // is the most up to date information, as is the case for 312/314). Note that
+ // the whois info for a nick is reset whenever whois information is requested,
+ // so the first response from each whois is recorded.
+ setWhois(aNick, aFields = {}) {
+ // If the nickname isn't in the list yet, add it.
+ if (!this.whoisInformation.has(aNick)) {
+ this.whoisInformation.set(aNick, {});
+ }
+
+ // Set non-normalized nickname field.
+ let whoisInfo = this.whoisInformation.get(aNick);
+ whoisInfo.nick = aNick;
+
+ // Set the WHOIS fields, but only the first time a field is set.
+ for (let field in aFields) {
+ if (!whoisInfo.hasOwnProperty(field)) {
+ whoisInfo[field] = aFields[field];
+ }
+ }
+
+ return true;
+ },
+
+ trackBuddy(aNick) {
+ // Put the username as the first to be checked on the next ISON call.
+ this.trackQueue.unshift(aNick);
+ },
+ untrackBuddy(aNick) {
+ let index = this.trackQueue.indexOf(aNick);
+ if (index < 0) {
+ this.ERROR(
+ "Trying to untrack a nick that was not being tracked: " + aNick
+ );
+ return;
+ }
+ this.trackQueue.splice(index, 1);
+ },
+ addBuddy(aTag, aName) {
+ let buddy = new ircAccountBuddy(this, null, aTag, aName);
+ this.buddies.set(buddy.normalizedName, buddy);
+ this.trackBuddy(buddy.userName);
+
+ IMServices.contacts.accountBuddyAdded(buddy);
+ },
+ removeBuddy(aBuddy) {
+ this.buddies.delete(aBuddy.normalizedName);
+ this.untrackBuddy(aBuddy.userName);
+ },
+ // Loads a buddy from the local storage. Called for each buddy locally stored
+ // before connecting to the server.
+ loadBuddy(aBuddy, aTag) {
+ let buddy = new ircAccountBuddy(this, aBuddy, aTag);
+ this.buddies.set(buddy.normalizedName, buddy);
+ this.trackBuddy(buddy.userName);
+
+ return buddy;
+ },
+ changeBuddyNick(aOldNick, aNewNick) {
+ if (this.normalizeNick(aOldNick) == this.normalizeNick(this._nickname)) {
+ // Your nickname changed!
+ this._nickname = aNewNick;
+ this.conversations.forEach(conversation => {
+ // Update the nick for chats, and inform the user in every conversation.
+ if (conversation.isChat) {
+ conversation.updateNick(aOldNick, aNewNick, true);
+ } else {
+ conversation.writeMessage(
+ aOldNick,
+ lazy._conv("nickSet.you", aNewNick),
+ {
+ system: true,
+ }
+ );
+ }
+ });
+ } else {
+ this.conversations.forEach(conversation => {
+ if (conversation.isChat && conversation._participants.has(aOldNick)) {
+ // Update the nick in every chat conversation it is in.
+ conversation.updateNick(aOldNick, aNewNick, false);
+ }
+ });
+ }
+
+ // Adjust the whois table where necessary.
+ this.removeBuddyInfo(aOldNick);
+ this.setWhois(aNewNick);
+
+ // If a private conversation is open with that user, change its title.
+ if (this.conversations.has(aOldNick)) {
+ // Get the current conversation and rename it.
+ let conversation = this.getConversation(aOldNick);
+
+ // Remove the old reference to the conversation and create a new one.
+ this.removeConversation(aOldNick);
+ this.conversations.set(aNewNick, conversation);
+
+ conversation.updateNick(aNewNick);
+ conversation.writeMessage(
+ aOldNick,
+ lazy._conv("nickSet", aOldNick, aNewNick),
+ { system: true }
+ );
+ }
+ },
+
+ /*
+ * Ask the server to change the user's nick.
+ */
+ changeNick(aNewNick) {
+ this._sentNickname = aNewNick;
+ this.sendMessage("NICK", aNewNick); // Nick message.
+ },
+ /*
+ * Generate a new nick to change to if the user requested nick is already in
+ * use or is otherwise invalid.
+ *
+ * First try all the alternate nicks that were chosen by the user, and if none
+ * of them work, then generate a new nick by:
+ * 1. If there was not a digit at the end of the nick, append a 1.
+ * 2. If there was a digit, then increment the number.
+ * 3. Add leading 0s back on.
+ * 4. Ensure the nick is an appropriate length.
+ */
+ tryNewNick(aOldNick) {
+ // Split the string on commas, remove whitespace around the nicks and
+ // remove empty nicks.
+ let allNicks = this.getString("alternateNicks")
+ .split(",")
+ .map(n => n.trim())
+ .filter(n => !!n);
+ allNicks.unshift(this._accountNickname);
+
+ // If the previously tried nick is in the array and not the last
+ // element, try the next nick in the array.
+ let oldIndex = allNicks.indexOf(aOldNick);
+ if (oldIndex != -1 && oldIndex < allNicks.length - 1) {
+ let newNick = allNicks[oldIndex + 1];
+ this.LOG(aOldNick + " is already in use, trying " + newNick);
+ this.changeNick(newNick);
+ return true;
+ }
+
+ // Separate the nick into the text and digits part.
+ let kNickPattern = /^(.+?)(\d*)$/;
+ let nickParts = kNickPattern.exec(aOldNick);
+ let newNick = nickParts[1];
+
+ // No nick found from the user's preferences, so just generating one.
+ // If there is not a digit at the end of the nick, just append 1.
+ let newDigits = "1";
+ // If there is a digit at the end of the nick, increment it.
+ if (nickParts[2]) {
+ newDigits = (parseInt(nickParts[2], 10) + 1).toString();
+ // If there are leading 0s, add them back on, after we've incremented (e.g.
+ // 009 --> 010).
+ let numLeadingZeros = nickParts[2].length - newDigits.length;
+ if (numLeadingZeros > 0) {
+ newDigits = "0".repeat(numLeadingZeros) + newDigits;
+ }
+ }
+
+ // Servers truncate nicks that are too long, compare the previously sent
+ // nickname with the returned nickname and check for truncation.
+ if (aOldNick.length < this._sentNickname.length) {
+ // The nick will be too long, overwrite the end of the nick instead of
+ // appending.
+ let maxLength = aOldNick.length;
+
+ let sentNickParts = kNickPattern.exec(this._sentNickname);
+ // Resend the same digits as last time, but overwrite part of the nick
+ // this time.
+ if (nickParts[2] && sentNickParts[2]) {
+ newDigits = sentNickParts[2];
+ }
+
+ // Handle the silly case of a single letter followed by all nines.
+ if (newDigits.length == this.maxNicknameLength) {
+ newDigits = newDigits.slice(1);
+ }
+ newNick = newNick.slice(0, maxLength - newDigits.length);
+ }
+ // Append the digits.
+ newNick += newDigits;
+
+ if (this.normalize(newNick) == this.normalize(this._nickname)) {
+ // The nick we were about to try next is our current nick. This means
+ // the user attempted to change to a version of the nick with a lower or
+ // absent number suffix, and this failed.
+ let msg = lazy._("message.nick.fail", this._nickname);
+ this.conversations.forEach(conversation =>
+ conversation.writeMessage(this._nickname, msg, { system: true })
+ );
+ return true;
+ }
+
+ this.LOG(aOldNick + " is already in use, trying " + newNick);
+ this.changeNick(newNick);
+ return true;
+ },
+
+ handlePingReply(aSource, aPongTime) {
+ // Received PING response, display to the user.
+ let sentTime = new Date(parseInt(aPongTime, 10));
+
+ // The received timestamp is invalid.
+ if (isNaN(sentTime)) {
+ this.WARN(
+ aSource + " returned an invalid timestamp from a PING: " + aPongTime
+ );
+ return false;
+ }
+
+ // Find the delay in milliseconds.
+ let delay = Date.now() - sentTime;
+
+ // If the delay is negative or greater than 1 minute, something is
+ // feeding us a crazy value. Don't display this to the user.
+ if (delay < 0 || 60 * 1000 < delay) {
+ this.WARN(aSource + " returned an invalid delay from a PING: " + delay);
+ return false;
+ }
+
+ let msg = lazy.PluralForm.get(
+ delay,
+ lazy._("message.ping", aSource)
+ ).replace("#2", delay);
+ this.getConversation(aSource).writeMessage(aSource, msg, { system: true });
+ return true;
+ },
+
+ countBytes(aStr) {
+ // Assume that if it's not UTF-8 then each character is 1 byte.
+ if (this._encoding != "UTF-8") {
+ return aStr.length;
+ }
+
+ // Count the number of bytes in a UTF-8 encoded string.
+ function charCodeToByteCount(c) {
+ // UTF-8 stores:
+ // - code points below U+0080 are 1 byte,
+ // - code points below U+0800 are 2 bytes,
+ // - code points U+D800 through U+DFFF are UTF-16 surrogate halves
+ // (they indicate that JS has split a 4 bytes UTF-8 character
+ // in two halves of 2 bytes each),
+ // - other code points are 3 bytes.
+ if (c < 0x80) {
+ return 1;
+ }
+ if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) {
+ return 2;
+ }
+ return 3;
+ }
+ let bytes = 0;
+ for (let i = 0; i < aStr.length; i++) {
+ bytes += charCodeToByteCount(aStr.charCodeAt(i));
+ }
+ return bytes;
+ },
+
+ // To check if users are online, we need to queue multiple messages.
+ // An internal queue of all nicks that we wish to know the status of.
+ trackQueue: [],
+ // The nicks that were last sent to the server that we're waiting for a
+ // response about.
+ pendingIsOnQueue: [],
+ // The time between sending isOn messages (milliseconds).
+ _isOnDelay: 60 * 1000,
+ _isOnTimer: null,
+ // The number of characters that are available to be filled with nicks for
+ // each ISON message.
+ _isOnLength: null,
+ // Generate and send an ISON message to poll for each nick's status.
+ sendIsOn() {
+ // If no buddies, just look again after the timeout.
+ if (this.trackQueue.length) {
+ // Calculate the possible length of names we can send.
+ if (!this._isOnLength) {
+ let length = this.countBytes(this.buildMessage("ISON", " ")) + 2;
+ this._isOnLength = this.maxMessageLength - length + 1;
+ }
+
+ // Always add the next nickname to the pending queue, this handles a silly
+ // case where the next nick is greater than or equal to the maximum
+ // message length.
+ this.pendingIsOnQueue = [this.trackQueue.shift()];
+
+ // Attempt to maximize the characters used in each message, this may mean
+ // that a specific user gets sent very often since they have a short name!
+ let buddiesLength = this.countBytes(this.pendingIsOnQueue[0]);
+ for (let i = 0; i < this.trackQueue.length; ++i) {
+ // If we can fit the nick, add it to the current buffer.
+ if (
+ buddiesLength + this.countBytes(this.trackQueue[i]) <
+ this._isOnLength
+ ) {
+ // Remove the name from the list and add it to the pending queue.
+ let nick = this.trackQueue.splice(i--, 1)[0];
+ this.pendingIsOnQueue.push(nick);
+
+ // Keep track of the length of the string, the + 1 is for the spaces.
+ buddiesLength += this.countBytes(nick) + 1;
+
+ // If we've filled up the message, stop looking for more nicks.
+ if (buddiesLength >= this._isOnLength) {
+ break;
+ }
+ }
+ }
+
+ // Send the message.
+ this.sendMessage("ISON", this.pendingIsOnQueue.join(" "));
+
+ // Append the pending nicks so trackQueue contains all the nicks.
+ this.trackQueue = this.trackQueue.concat(this.pendingIsOnQueue);
+ }
+
+ // Call this function again in _isOnDelay seconds.
+ // This makes the assumption that this._isOnDelay >> the response to ISON
+ // from the server.
+ this._isOnTimer = setTimeout(this.sendIsOn.bind(this), this._isOnDelay);
+ },
+
+ // The message of the day uses two fields to append messages.
+ _motd: null,
+ _motdTimer: null,
+
+ connect() {
+ this.reportConnecting();
+
+ // Mark existing MUCs as joining if they will be rejoined.
+ this.conversations.forEach(conversation => {
+ if (conversation.isChat && conversation.chatRoomFields) {
+ conversation.joining = true;
+ }
+ });
+
+ // Load preferences.
+ this._port = this.getInt("port");
+ this._ssl = this.getBool("ssl");
+
+ // Use the display name as the user's real name.
+ this._realname = this.imAccount.statusInfo.displayName;
+ this._encoding = this.getString("encoding") || "UTF-8";
+ this._showServerTab = this.getBool("showServerTab");
+
+ // Open the socket connection.
+ this._socket = new ircSocket(this);
+ this._socket.connect(this._server, this._port, this._ssl ? ["ssl"] : []);
+ },
+
+ // Functions for keeping track of whether the Client Capabilities is done.
+ // If a cap is to be handled, it should be registered with addCAP, where aCAP
+ // is a "unique" string defining what is being handled. When the cap is done
+ // being handled removeCAP should be called with the same string.
+ _availableCAPs: new Set(),
+ _activeCAPs: new Set(),
+ _requestedCAPs: new Set(),
+ _negotiatedCAPs: false,
+ _queuedCAPs: [],
+ addCAP(aCAP) {
+ if (this.connected) {
+ this.ERROR("Trying to add CAP " + aCAP + " after connection.");
+ return;
+ }
+
+ this._requestedCAPs.add(aCAP);
+ },
+ removeCAP(aDoneCAP) {
+ if (!this._requestedCAPs.has(aDoneCAP)) {
+ this.ERROR(
+ "Trying to remove a CAP (" + aDoneCAP + ") which isn't added."
+ );
+ return;
+ }
+ if (this.connected) {
+ this.ERROR("Trying to remove CAP " + aDoneCAP + " after connection.");
+ return;
+ }
+
+ // Remove any reference to the given capability.
+ this._requestedCAPs.delete(aDoneCAP);
+
+ // However only notify the server the first time during cap negotiation, not
+ // when the server exposes a new cap.
+ if (!this._requestedCAPs.size && !this._negotiatedCAPs) {
+ this.sendMessage("CAP", "END");
+ this._negotiatedCAPs = true;
+ }
+ },
+
+ // Used to wait for a response from the server.
+ _quitTimer: null,
+ // RFC 2812 Section 3.1.7.
+ quit(aMessage) {
+ this._reportDisconnecting(Ci.prplIAccount.NO_ERROR);
+ this.sendMessage(
+ "QUIT",
+ aMessage || this.getString("quitmsg") || undefined
+ );
+ },
+ // When the user clicks "Disconnect" in account manager, or uses /quit.
+ // aMessage is an optional parameter containing the quit message.
+ disconnect(aMessage) {
+ if (this.disconnected || this.disconnecting) {
+ return;
+ }
+
+ // If there's no socket, disconnect immediately to avoid waiting 2 seconds.
+ if (!this._socket || this._socket.disconnected) {
+ this.gotDisconnected();
+ return;
+ }
+
+ // Let the server know we're going to disconnect.
+ this.quit(aMessage);
+
+ // Reset original nickname for the next reconnect.
+ this._requestedNickname = this._accountNickname;
+
+ // Give the server 2 seconds to respond, otherwise just forcefully
+ // disconnect the socket. This will be cancelled if a response is heard from
+ // the server.
+ this._quitTimer = setTimeout(this.gotDisconnected.bind(this), 2 * 1000);
+ },
+
+ createConversation(aName) {
+ return this.getConversation(aName);
+ },
+
+ // aComponents implements prplIChatRoomFieldValues.
+ joinChat(aComponents) {
+ let channel = aComponents.getValue("channel");
+ // Mildly sanitize input.
+ channel = channel.trimLeft().split(",")[0].split(" ")[0];
+ if (!channel) {
+ this.ERROR("joinChat called without a valid channel name.");
+ return null;
+ }
+
+ // A channel prefix is required. If the user didn't include one,
+ // we prepend # automatically to match the behavior of other
+ // clients. Not doing it used to cause user confusion.
+ if (!this.channelPrefixes.includes(channel[0])) {
+ channel = "#" + channel;
+ }
+
+ if (this.conversations.has(channel)) {
+ let conv = this.getConversation(channel);
+ if (!conv.left) {
+ // No need to join a channel we are already in.
+ return conv;
+ } else if (!conv.chatRoomFields) {
+ // We are rejoining a channel that was parted by the user.
+ conv._rejoined = true;
+ }
+ }
+
+ let key = aComponents.getValue("password");
+ this.sendBufferedCommand("JOIN", channel, key);
+
+ // Open conversation early for better responsiveness.
+ let conv = this.getConversation(channel);
+ conv.joining = true;
+
+ // Store the prplIChatRoomFieldValues to enable later reconnections.
+ let defaultName = key ? channel + " " + key : channel;
+ conv.chatRoomFields = this.getChatRoomDefaultFieldValues(defaultName);
+
+ return conv;
+ },
+
+ chatRoomFields: {
+ channel: {
+ get label() {
+ return lazy._("joinChat.channel");
+ },
+ required: true,
+ },
+ password: {
+ get label() {
+ return lazy._("joinChat.password");
+ },
+ isPassword: true,
+ },
+ },
+
+ parseDefaultChatName(aDefaultName) {
+ let params = aDefaultName.trim().split(/\s+/);
+ let chatFields = { channel: params[0] };
+ if (params.length > 1) {
+ chatFields.password = params[1];
+ }
+ return chatFields;
+ },
+
+ // Attributes
+ get canJoinChat() {
+ return true;
+ },
+
+ // Returns a conversation (creates it if it doesn't exist)
+ getConversation(aName) {
+ if (!this.conversations.has(aName)) {
+ // If the whois information has been received, we have the proper nick
+ // capitalization.
+ if (this.whoisInformation.has(aName)) {
+ aName = this.whoisInformation.get(aName).nick;
+ }
+ let convClass = this.isMUCName(aName) ? ircChannel : ircConversation;
+ this.conversations.set(aName, new convClass(this, aName, this._nickname));
+ }
+ return this.conversations.get(aName);
+ },
+
+ removeConversation(aConversationName) {
+ if (this.conversations.has(aConversationName)) {
+ this.conversations.delete(aConversationName);
+ }
+ },
+
+ // This builds the message string that will be sent to the server.
+ buildMessage(aCommand, aParams = []) {
+ if (!aCommand) {
+ this.ERROR("IRC messages must have a command.");
+ return null;
+ }
+
+ // Ensure a command is only characters or numbers.
+ if (!/^[A-Z0-9]+$/i.test(aCommand)) {
+ this.ERROR("IRC command invalid: " + aCommand);
+ return null;
+ }
+
+ let message = aCommand;
+ // If aParams is not an array, consider it to be a single parameter and put
+ // it into an array.
+ let params = Array.isArray(aParams) ? aParams : [aParams];
+ if (params.length) {
+ if (params.slice(0, -1).some(p => p.includes(" "))) {
+ this.ERROR("IRC parameters cannot have spaces: " + params.slice(0, -1));
+ return null;
+ }
+ // Join the parameters with spaces. There are three cases in which the
+ // last parameter ("trailing" in RFC 2812) must be prepended with a colon:
+ // 1. If the last parameter contains a space.
+ // 2. If the first character of the last parameter is a colon.
+ // 3. If the last parameter is an empty string.
+ let trailing = params.slice(-1)[0];
+ if (
+ !trailing.length ||
+ trailing.includes(" ") ||
+ trailing.startsWith(":")
+ ) {
+ params.push(":" + params.pop());
+ }
+ message += " " + params.join(" ");
+ }
+
+ return message;
+ },
+
+ // Shortcut method to build & send a message at once. Use aLoggedData to log
+ // something different than what is actually sent.
+ // Returns false if the message could not be sent.
+ sendMessage(aCommand, aParams, aLoggedData) {
+ return this.sendRawMessage(
+ this.buildMessage(aCommand, aParams),
+ aLoggedData
+ );
+ },
+
+ // This sends a message over the socket and catches any errors. Use
+ // aLoggedData to log something different than what is actually sent.
+ // Returns false if the message could not be sent.
+ sendRawMessage(aMessage, aLoggedData) {
+ // Low level quoting, replace \0, \n, \r or \020 with \0200, \020n, \020r or
+ // \020\020, respectively.
+ const lowQuote = { "\0": "0", "\n": "n", "\r": "r", "\x10": "\x10" };
+ const lowRegex = new RegExp(
+ "[" + Object.keys(lowQuote).join("") + "]",
+ "g"
+ );
+ aMessage = aMessage.replace(lowRegex, aChar => "\x10" + lowQuote[aChar]);
+
+ if (!this._socket || this._socket.disconnected) {
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.lost")
+ );
+ }
+
+ let length = this.countBytes(aMessage) + 2;
+ if (length > this.maxMessageLength) {
+ // Log if the message is too long, but try to send it anyway.
+ this.WARN(
+ "Message length too long (" +
+ length +
+ " > " +
+ this.maxMessageLength +
+ "\n" +
+ aMessage
+ );
+ }
+
+ aMessage += "\r\n";
+
+ try {
+ this._socket.sendString(aMessage, this._encoding, aLoggedData);
+ return true;
+ } catch (e) {
+ try {
+ this._socket.sendData(aMessage, aLoggedData);
+ this.WARN(
+ "Failed to convert " +
+ aMessage +
+ " from Unicode to " +
+ this._encoding +
+ "."
+ );
+ return true;
+ } catch (e) {
+ this.ERROR("Socket error:", e);
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.lost")
+ );
+ return false;
+ }
+ }
+ },
+
+ // CTCP messages are \001<COMMAND> [<parameters>]*\001.
+ // Returns false if the message could not be sent.
+ sendCTCPMessage(aTarget, aIsNotice, aCtcpCommand, aParams = []) {
+ // Combine the CTCP command and parameters into the single IRC param.
+ let ircParam = aCtcpCommand;
+ // If aParams is not an array, consider it to be a single parameter and put
+ // it into an array.
+ let params = Array.isArray(aParams) ? aParams : [aParams];
+ if (params.length) {
+ ircParam += " " + params.join(" ");
+ }
+
+ // High/CTCP level quoting, replace \134 or \001 with \134\134 or \134a,
+ // respectively. This is only done inside the extended data message.
+ // eslint-disable-next-line no-control-regex
+ const highRegex = /\\|\x01/g;
+ ircParam = ircParam.replace(
+ highRegex,
+ aChar => "\\" + (aChar == "\\" ? "\\" : "a")
+ );
+
+ // Add the CTCP tagging.
+ ircParam = "\x01" + ircParam + "\x01";
+
+ // Send the IRC message as a NOTICE or PRIVMSG.
+ return this.sendMessage(aIsNotice ? "NOTICE" : "PRIVMSG", [
+ aTarget,
+ ircParam,
+ ]);
+ },
+
+ // Implement section 3.1 of RFC 2812
+ _connectionRegistration() {
+ // Send the Client Capabilities list command version 3.2.
+ this.sendMessage("CAP", ["LS", "302"]);
+
+ if (this.prefs.prefHasUserValue("serverPassword")) {
+ this.sendMessage(
+ "PASS",
+ this.getString("serverPassword"),
+ "PASS <password not logged>"
+ );
+ }
+
+ // Send the nick message (section 3.1.2).
+ this.changeNick(this._requestedNickname);
+
+ // Send the user message (section 3.1.3).
+ this.sendMessage("USER", [
+ this.username,
+ this._mode.toString(),
+ "*",
+ this._realname || this._requestedNickname,
+ ]);
+ },
+
+ _reportDisconnecting(aErrorReason, aErrorMessage) {
+ this.reportDisconnecting(aErrorReason, aErrorMessage);
+
+ // Cancel any pending buffered commands.
+ this._commandBuffers.clear();
+
+ // Mark all contacts on the account as having an unknown status.
+ this.buddies.forEach(aBuddy =>
+ aBuddy.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, "")
+ );
+ },
+
+ gotDisconnected(aError = Ci.prplIAccount.NO_ERROR, aErrorMessage = "") {
+ if (!this.imAccount || this.disconnected) {
+ return;
+ }
+
+ // If we are already disconnecting, this call to gotDisconnected
+ // is when the server acknowledges our disconnection.
+ // Otherwise it's because we lost the connection.
+ if (!this.disconnecting) {
+ this._reportDisconnecting(aError, aErrorMessage);
+ }
+ this._socket.disconnect();
+ delete this._socket;
+
+ // Reset cap negotiation.
+ this._availableCAPs.clear();
+ this._activeCAPs.clear();
+ this._requestedCAPs.clear();
+ this._negotiatedCAPs = false;
+ this._queuedCAPs.length = 0;
+
+ clearTimeout(this._isOnTimer);
+ delete this._isOnTimer;
+
+ // No need to call gotDisconnected a second time.
+ clearTimeout(this._quitTimer);
+ delete this._quitTimer;
+
+ // MOTD will be resent.
+ delete this._motd;
+ clearTimeout(this._motdTimer);
+ delete this._motdTimer;
+
+ // We must authenticate if we reconnect.
+ delete this.isAuthenticated;
+
+ // Clear any pending attempt to regain our nick.
+ clearTimeout(this._nickInUseTimeout);
+ delete this._nickInUseTimeout;
+
+ // Clean up each conversation: mark as left and remove participant.
+ this.conversations.forEach(conversation => {
+ if (conversation.isChat) {
+ conversation.joining = false; // In case we never finished joining.
+ if (!conversation.left) {
+ // Remove the user's nick and mark the conversation as left as that's
+ // the final known state of the room.
+ conversation.removeParticipant(this._nickname);
+ conversation.left = true;
+ }
+ }
+ });
+
+ // If we disconnected during a pending LIST request, make sure callbacks
+ // receive any remaining channels.
+ if (this._pendingList) {
+ this._sendRemainingRoomInfo();
+ }
+
+ // Clear whois table.
+ this.whoisInformation.clear();
+
+ this.reportDisconnected();
+ },
+
+ remove() {
+ this.conversations.forEach(conv => conv.close());
+ delete this.conversations;
+ this.buddies.forEach(aBuddy => aBuddy.remove());
+ delete this.buddies;
+ },
+
+ unInit() {
+ // Disconnect if we're online while this gets called.
+ if (this._socket) {
+ if (!this.disconnecting) {
+ this.quit();
+ }
+ this._socket.disconnect();
+ }
+ delete this.imAccount;
+ clearTimeout(this._isOnTimer);
+ clearTimeout(this._quitTimer);
+ },
+};
diff --git a/comm/chat/protocols/irc/ircBase.sys.mjs b/comm/chat/protocols/irc/ircBase.sys.mjs
new file mode 100644
index 0000000000..9127dd4e24
--- /dev/null
+++ b/comm/chat/protocols/irc/ircBase.sys.mjs
@@ -0,0 +1,1768 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This contains the implementation for the basic Internet Relay Chat (IRC)
+ * protocol covered by RFCs 2810, 2811, 2812 and 2813 (which obsoletes RFC
+ * 1459). RFC 2812 covers the client commands and protocol.
+ * RFC 2810: Internet Relay Chat: Architecture
+ * http://tools.ietf.org/html/rfc2810
+ * RFC 2811: Internet Relay Chat: Channel Management
+ * http://tools.ietf.org/html/rfc2811
+ * RFC 2812: Internet Relay Chat: Client Protocol
+ * http://tools.ietf.org/html/rfc2812
+ * RFC 2813: Internet Relay Chat: Server Protocol
+ * http://tools.ietf.org/html/rfc2813
+ * RFC 1459: Internet Relay Chat Protocol
+ * http://tools.ietf.org/html/rfc1459
+ */
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ l10nHelper,
+ nsSimpleEnumerator,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+import {
+ ctcpFormatToText,
+ conversationErrorMessage,
+ displayMessage,
+ kListRefreshInterval,
+} from "resource:///modules/ircUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+// Display the message and remove them from the rooms they're in.
+function leftRoom(aAccount, aNicks, aChannels, aSource, aReason, aKicked) {
+ let msgId = "message." + (aKicked ? "kicked" : "parted");
+ // If a part message was included, include it.
+ let reason = aReason ? lazy._(msgId + ".reason", aReason) : "";
+ function __(aNick, aYou) {
+ // If the user is kicked, we need to say who kicked them.
+ let msgId2 = msgId + (aYou ? ".you" : "");
+ if (aKicked) {
+ if (aYou) {
+ return lazy._(msgId2, aSource, reason);
+ }
+ return lazy._(msgId2, aNick, aSource, reason);
+ }
+ if (aYou) {
+ return lazy._(msgId2, reason);
+ }
+ return lazy._(msgId2, aNick, reason);
+ }
+
+ for (let channelName of aChannels) {
+ if (!aAccount.conversations.has(channelName)) {
+ // Handle when we closed the window.
+ continue;
+ }
+ let conversation = aAccount.getConversation(channelName);
+ for (let nick of aNicks) {
+ let msg;
+ if (aAccount.normalize(nick) == aAccount.normalize(aAccount._nickname)) {
+ msg = __(nick, true);
+ // If the user left, mark the conversation as no longer being active.
+ conversation.left = true;
+ } else {
+ msg = __(nick);
+ }
+
+ conversation.writeMessage(aSource, msg, { system: true });
+ conversation.removeParticipant(nick);
+ }
+ }
+ return true;
+}
+
+function writeMessage(aAccount, aMessage, aString, aType) {
+ let type = {};
+ type[aType] = true;
+ type.tags = aMessage.tags;
+ aAccount
+ .getConversation(aMessage.origin)
+ .writeMessage(aMessage.origin, aString, type);
+ return true;
+}
+
+// If aNoLastParam is true, the last parameter is not printed out.
+function serverMessage(aAccount, aMsg, aNoLastParam) {
+ // If we don't want to show messages from the server, just mark it as handled.
+ if (!aAccount._showServerTab) {
+ return true;
+ }
+
+ return writeMessage(
+ aAccount,
+ aMsg,
+ aMsg.params.slice(1, aNoLastParam ? -1 : undefined).join(" "),
+ "system"
+ );
+}
+
+function serverErrorMessage(aAccount, aMessage, aError) {
+ // If we don't want to show messages from the server, just mark it as handled.
+ if (!aAccount._showServerTab) {
+ return true;
+ }
+
+ return writeMessage(aAccount, aMessage, aError, "error");
+}
+
+function addMotd(aAccount, aMessage) {
+ // If there is no current MOTD to append to, start a new one.
+ if (!aAccount._motd) {
+ aAccount._motd = [];
+ }
+
+ // Traditionally, MOTD messages start with "- ", but this is not always
+ // true, try to handle that sanely.
+ let message = aMessage.params[1];
+ if (message.startsWith("-")) {
+ message = message.slice(1).trim();
+ }
+ // And traditionally, the initial message ends in " -", remove that.
+ if (message.endsWith("-")) {
+ message = message.slice(0, -1).trim();
+ }
+
+ // Actually add the message (if it still exists).
+ if (message) {
+ aAccount._motd.push(message);
+ }
+
+ // Oh, also some servers don't send a RPL_ENDOFMOTD (e.g. irc.ppy.sh), so if
+ // we don't receive another MOTD message after 1 second, consider it to be
+ // RPL_ENDOFMOTD.
+ clearTimeout(aAccount._motdTimer);
+ aAccount._motdTimer = setTimeout(
+ ircBase.commands["376"].bind(aAccount),
+ 1000,
+ aMessage
+ );
+
+ return true;
+}
+
+// See RFCs 2811 & 2812 (which obsoletes RFC 1459) for a description of these
+// commands.
+export var ircBase = {
+ // Parameters
+ name: "RFC 2812", // Name identifier
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ // The IRC commands that can be handled.
+ commands: {
+ ERROR(aMessage) {
+ // ERROR <error message>
+ // Client connection has been terminated.
+ if (!this.disconnecting) {
+ // We received an ERROR message when we weren't expecting it, this is
+ // probably the server giving us a ping timeout.
+ this.WARN("Received unexpected ERROR response:\n" + aMessage.params[0]);
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_NETWORK_ERROR,
+ lazy._("connection.error.lost")
+ );
+ } else {
+ // We received an ERROR message when expecting it (i.e. we've sent a
+ // QUIT command). Notify account manager.
+ this.gotDisconnected();
+ }
+ return true;
+ },
+ INVITE(aMessage) {
+ // INVITE <nickname> <channel>
+ let channel = aMessage.params[1];
+ this.addChatRequest(
+ channel,
+ () => {
+ this.joinChat(this.getChatRoomDefaultFieldValues(channel));
+ },
+ request => {
+ // Inform the user when an invitation was automatically ignored.
+ if (!request) {
+ // Otherwise just notify the user.
+ this.getConversation(channel).writeMessage(
+ aMessage.origin,
+ lazy._("message.inviteReceived", aMessage.origin, channel),
+ { system: true }
+ );
+ }
+ }
+ );
+ return true;
+ },
+ JOIN(aMessage) {
+ // JOIN ( <channel> *( "," <channel> ) [ <key> *( "," <key> ) ] ) / "0"
+ // Iterate over each channel.
+ for (let channelName of aMessage.params[0].split(",")) {
+ let conversation = this.getConversation(channelName);
+
+ // Check whether we joined the channel or if someone else did.
+ if (
+ this.normalize(aMessage.origin, this.userPrefixes) ==
+ this.normalize(this._nickname)
+ ) {
+ // If we join, clear the participants list to avoid errors with
+ // repeated participants.
+ conversation.removeAllParticipants();
+ conversation.left = false;
+ conversation.joining = false;
+
+ // Update the channel name if it has improper capitalization.
+ if (channelName != conversation.name) {
+ conversation._name = channelName;
+ conversation.notifyObservers(null, "update-conv-title");
+ }
+
+ // If the user parted from this room earlier, confirm the rejoin.
+ if (conversation._rejoined) {
+ conversation.writeMessage(
+ aMessage.origin,
+ lazy._("message.rejoined"),
+ {
+ system: true,
+ }
+ );
+ delete conversation._rejoined;
+ }
+
+ // Ensure chatRoomFields information is available for reconnection.
+ if (!conversation.chatRoomFields) {
+ this.WARN(
+ "Opening a MUC without storing its " +
+ "prplIChatRoomFieldValues first."
+ );
+ conversation.chatRoomFields =
+ this.getChatRoomDefaultFieldValues(channelName);
+ }
+ } else {
+ // Don't worry about adding ourself, RPL_NAMREPLY takes care of that
+ // case.
+ conversation.getParticipant(aMessage.origin, true);
+ let msg = lazy._("message.join", aMessage.origin, aMessage.source);
+ conversation.writeMessage(aMessage.origin, msg, {
+ system: true,
+ noLinkification: true,
+ });
+ }
+ }
+ // If the joiner is a buddy, mark as online.
+ let buddy = this.buddies.get(aMessage.origin);
+ if (buddy) {
+ buddy.setStatus(Ci.imIStatusInfo.STATUS_AVAILABLE, "");
+ }
+ return true;
+ },
+ KICK(aMessage) {
+ // KICK <channel> *( "," <channel> ) <user> *( "," <user> ) [<comment>]
+ let comment = aMessage.params.length == 3 ? aMessage.params[2] : null;
+ // Some servers (moznet) send the kicker as the comment.
+ if (comment == aMessage.origin) {
+ comment = null;
+ }
+ return leftRoom(
+ this,
+ aMessage.params[1].split(","),
+ aMessage.params[0].split(","),
+ aMessage.origin,
+ comment,
+ true
+ );
+ },
+ MODE(aMessage) {
+ // MODE <nickname> *( ( "+" / "-") *( "i" / "w" / "o" / "O" / "r" ) )
+ // MODE <channel> *( ( "-" / "+" ) *<modes> *<modeparams> )
+ if (this.isMUCName(aMessage.params[0])) {
+ // If the first parameter is a channel name, a channel/participant mode
+ // was updated.
+ this.getConversation(aMessage.params[0]).setMode(
+ aMessage.params[1],
+ aMessage.params.slice(2),
+ aMessage.origin
+ );
+
+ return true;
+ }
+
+ // Otherwise the user's own mode is being returned to them.
+ return this.setUserMode(
+ aMessage.params[0],
+ aMessage.params[1],
+ aMessage.origin,
+ !this._userModeReceived
+ );
+ },
+ NICK(aMessage) {
+ // NICK <nickname>
+ this.changeBuddyNick(aMessage.origin, aMessage.params[0]);
+ return true;
+ },
+ NOTICE(aMessage) {
+ // NOTICE <msgtarget> <text>
+ // If the message is from the server, don't show it unless the user wants
+ // to see it.
+ if (!this.connected || aMessage.origin == this._currentServerName) {
+ return serverMessage(this, aMessage);
+ }
+ return displayMessage(this, aMessage, { notification: true });
+ },
+ PART(aMessage) {
+ // PART <channel> *( "," <channel> ) [ <Part Message> ]
+ return leftRoom(
+ this,
+ [aMessage.origin],
+ aMessage.params[0].split(","),
+ aMessage.source,
+ aMessage.params.length == 2 ? aMessage.params[1] : null
+ );
+ },
+ PING(aMessage) {
+ // PING <server1> [ <server2> ]
+ // Keep the connection alive.
+ this.sendMessage("PONG", aMessage.params[0]);
+ return true;
+ },
+ PONG(aMessage) {
+ // PONG <server> [ <server2> ]
+ let pongTime = aMessage.params[1];
+
+ // Ping to keep the connection alive.
+ if (pongTime.startsWith("_")) {
+ this._socket.cancelDisconnectTimer();
+ return true;
+ }
+ // Otherwise, the ping was from a user command.
+ return this.handlePingReply(aMessage.origin, pongTime);
+ },
+ PRIVMSG(aMessage) {
+ // PRIVMSG <msgtarget> <text to be sent>
+ // Display message in conversation
+ return displayMessage(this, aMessage);
+ },
+ QUIT(aMessage) {
+ // QUIT [ < Quit Message> ]
+ // Some IRC servers automatically prefix a "Quit: " string. Remove the
+ // duplication and use a localized version.
+ let quitMsg = aMessage.params[0] || "";
+ if (quitMsg.startsWith("Quit: ")) {
+ quitMsg = quitMsg.slice(6); // "Quit: ".length
+ }
+ // If a quit message was included, show it.
+ let nick = aMessage.origin;
+ let msg = lazy._(
+ "message.quit",
+ nick,
+ quitMsg.length ? lazy._("message.quit2", quitMsg) : ""
+ );
+ // Loop over every conversation with the user and display that they quit.
+ this.conversations.forEach(conversation => {
+ if (conversation.isChat && conversation._participants.has(nick)) {
+ conversation.writeMessage(nick, msg, { system: true });
+ conversation.removeParticipant(nick);
+ }
+ });
+
+ // Remove from the whois table.
+ this.removeBuddyInfo(nick);
+
+ // If the leaver is a buddy, mark as offline.
+ let buddy = this.buddies.get(nick);
+ if (buddy) {
+ buddy.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, "");
+ }
+
+ // If we wanted this nickname, grab it.
+ if (nick == this._requestedNickname && nick != this._nickname) {
+ this.changeNick(this._requestedNickname);
+ clearTimeout(this._nickInUseTimeout);
+ delete this._nickInUseTimeout;
+ }
+ return true;
+ },
+ SQUIT(aMessage) {
+ // <server> <comment>
+ return true;
+ },
+ TOPIC(aMessage) {
+ // TOPIC <channel> [ <topic> ]
+ // Show topic as a message.
+ let conversation = this.getConversation(aMessage.params[0]);
+ let topic = aMessage.params[1];
+ // Set the topic in the conversation and update the UI.
+ conversation.setTopic(
+ topic ? ctcpFormatToText(topic) : "",
+ aMessage.origin
+ );
+ return true;
+ },
+ "001": function (aMessage) {
+ // RPL_WELCOME
+ // Welcome to the Internet Relay Network <nick>!<user>@<host>
+ this._socket.resetPingTimer();
+ // This seems a little strange, but we don't differentiate between a
+ // nickname and the servername since it can be ambiguous.
+ this._currentServerName = aMessage.origin;
+
+ // Clear user mode.
+ this._modes = new Set();
+ this._userModeReceived = false;
+
+ // Check if autoUserMode is set in the account preferences. If it is set,
+ // then notify the server that the user wants a specific mode.
+ if (this.prefs.prefHasUserValue("autoUserMode")) {
+ this.sendMessage("MODE", [
+ this._nickname,
+ this.getString("autoUserMode"),
+ ]);
+ }
+
+ // Check if our nick has changed.
+ if (aMessage.params[0] != this._nickname) {
+ this.changeBuddyNick(this._nickname, aMessage.params[0]);
+ }
+
+ // Request our own whois entry so we can set the prefix.
+ this.requestCurrentWhois(this._nickname);
+
+ // If our status is Unavailable, tell the server.
+ if (
+ this.imAccount.statusInfo.statusType < Ci.imIStatusInfo.STATUS_AVAILABLE
+ ) {
+ this.observe(null, "status-changed");
+ }
+
+ // Check if any of our buddies are online!
+ const kInitialIsOnDelay = 1000;
+ this._isOnTimer = setTimeout(this.sendIsOn.bind(this), kInitialIsOnDelay);
+
+ // If we didn't handle all the CAPs we added, something is wrong.
+ if (this._requestedCAPs.size) {
+ this.ERROR(
+ "Connected without removing CAPs: " + [...this._requestedCAPs]
+ );
+ }
+
+ // Done!
+ this.reportConnected();
+ return serverMessage(this, aMessage);
+ },
+ "002": function (aMessage) {
+ // RPL_YOURHOST
+ // Your host is <servername>, running version <ver>
+ return serverMessage(this, aMessage);
+ },
+ "003": function (aMessage) {
+ // RPL_CREATED
+ // This server was created <date>
+ // TODO parse this date and keep it for some reason? Do we care?
+ return serverMessage(this, aMessage);
+ },
+ "004": function (aMessage) {
+ // RPL_MYINFO
+ // <servername> <version> <available user modes> <available channel modes>
+ // TODO parse the available modes, let the UI respond and inform the user
+ return serverMessage(this, aMessage);
+ },
+ "005": function (aMessage) {
+ // RPL_BOUNCE
+ // Try server <server name>, port <port number>
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * Handle response to TRACE message
+ */
+ 200(aMessage) {
+ // RPL_TRACELINK
+ // Link <version & debug level> <destination> <next server>
+ // V<protocol version> <link updateime in seconds> <backstream sendq>
+ // <upstream sendq>
+ return serverMessage(this, aMessage);
+ },
+ 201(aMessage) {
+ // RPL_TRACECONNECTING
+ // Try. <class> <server>
+ return serverMessage(this, aMessage);
+ },
+ 202(aMessage) {
+ // RPL_TRACEHANDSHAKE
+ // H.S. <class> <server>
+ return serverMessage(this, aMessage);
+ },
+ 203(aMessage) {
+ // RPL_TRACEUNKNOWN
+ // ???? <class> [<client IP address in dot form>]
+ return serverMessage(this, aMessage);
+ },
+ 204(aMessage) {
+ // RPL_TRACEOPERATOR
+ // Oper <class> <nick>
+ return serverMessage(this, aMessage);
+ },
+ 205(aMessage) {
+ // RPL_TRACEUSER
+ // User <class> <nick>
+ return serverMessage(this, aMessage);
+ },
+ 206(aMessage) {
+ // RPL_TRACESERVER
+ // Serv <class> <int>S <int>C <server> <nick!user|*!*>@<host|server>
+ // V<protocol version>
+ return serverMessage(this, aMessage);
+ },
+ 207(aMessage) {
+ // RPL_TRACESERVICE
+ // Service <class> <name> <type> <active type>
+ return serverMessage(this, aMessage);
+ },
+ 208(aMessage) {
+ // RPL_TRACENEWTYPE
+ // <newtype> 0 <client name>
+ return serverMessage(this, aMessage);
+ },
+ 209(aMessage) {
+ // RPL_TRACECLASS
+ // Class <class> <count>
+ return serverMessage(this, aMessage);
+ },
+ 210(aMessage) {
+ // RPL_TRACERECONNECTION
+ // Unused.
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * Handle stats messages.
+ **/
+ 211(aMessage) {
+ // RPL_STATSLINKINFO
+ // <linkname> <sendq> <sent messages> <sent Kbytes> <received messages>
+ // <received Kbytes> <time open>
+ return serverMessage(this, aMessage);
+ },
+ 212(aMessage) {
+ // RPL_STATSCOMMAND
+ // <command> <count> <byte count> <remote count>
+ return serverMessage(this, aMessage);
+ },
+ 213(aMessage) {
+ // RPL_STATSCLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 214(aMessage) {
+ // RPL_STATSNLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 215(aMessage) {
+ // RPL_STATSILINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 216(aMessage) {
+ // RPL_STATSKLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 217(aMessage) {
+ // RPL_STATSQLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 218(aMessage) {
+ // RPL_STATSYLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 219(aMessage) {
+ // RPL_ENDOFSTATS
+ // <stats letter> :End of STATS report
+ return serverMessage(this, aMessage);
+ },
+
+ 221(aMessage) {
+ // RPL_UMODEIS
+ // <user mode string>
+ return this.setUserMode(
+ aMessage.params[0],
+ aMessage.params[1],
+ aMessage.origin,
+ true
+ );
+ },
+
+ /*
+ * Services
+ */
+ 231(aMessage) {
+ // RPL_SERVICEINFO
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 232(aMessage) {
+ // RPL_ENDOFSERVICES
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 233(aMessage) {
+ // RPL_SERVICE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * Server
+ */
+ 234(aMessage) {
+ // RPL_SERVLIST
+ // <name> <server> <mask> <type> <hopcount> <info>
+ return serverMessage(this, aMessage);
+ },
+ 235(aMessage) {
+ // RPL_SERVLISTEND
+ // <mask> <type> :End of service listing
+ return serverMessage(this, aMessage, true);
+ },
+
+ /*
+ * Stats
+ * TODO some of these have real information we could try to parse.
+ */
+ 240(aMessage) {
+ // RPL_STATSVLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 241(aMessage) {
+ // RPL_STATSLLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 242(aMessage) {
+ // RPL_STATSUPTIME
+ // :Server Up %d days %d:%02d:%02d
+ return serverMessage(this, aMessage);
+ },
+ 243(aMessage) {
+ // RPL_STATSOLINE
+ // O <hostmask> * <name>
+ return serverMessage(this, aMessage);
+ },
+ 244(aMessage) {
+ // RPL_STATSHLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 245(aMessage) {
+ // RPL_STATSSLINE
+ // Non-generic
+ // Note that this is given as 244 in RFC 2812, this seems to be incorrect.
+ return serverMessage(this, aMessage);
+ },
+ 246(aMessage) {
+ // RPL_STATSPING
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 247(aMessage) {
+ // RPL_STATSBLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+ 250(aMessage) {
+ // RPL_STATSDLINE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * LUSER messages
+ */
+ 251(aMessage) {
+ // RPL_LUSERCLIENT
+ // :There are <integer> users and <integer> services on <integer> servers
+ return serverMessage(this, aMessage);
+ },
+ 252(aMessage) {
+ // RPL_LUSEROP, 0 if not sent
+ // <integer> :operator(s) online
+ return serverMessage(this, aMessage);
+ },
+ 253(aMessage) {
+ // RPL_LUSERUNKNOWN, 0 if not sent
+ // <integer> :unknown connection(s)
+ return serverMessage(this, aMessage);
+ },
+ 254(aMessage) {
+ // RPL_LUSERCHANNELS, 0 if not sent
+ // <integer> :channels formed
+ return serverMessage(this, aMessage);
+ },
+ 255(aMessage) {
+ // RPL_LUSERME
+ // :I have <integer> clients and <integer> servers
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * ADMIN messages
+ */
+ 256(aMessage) {
+ // RPL_ADMINME
+ // <server> :Administrative info
+ return serverMessage(this, aMessage);
+ },
+ 257(aMessage) {
+ // RPL_ADMINLOC1
+ // :<admin info>
+ // City, state & country
+ return serverMessage(this, aMessage);
+ },
+ 258(aMessage) {
+ // RPL_ADMINLOC2
+ // :<admin info>
+ // Institution details
+ return serverMessage(this, aMessage);
+ },
+ 259(aMessage) {
+ // RPL_ADMINEMAIL
+ // :<admin info>
+ // TODO We could parse this for a contact email.
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * TRACELOG
+ */
+ 261(aMessage) {
+ // RPL_TRACELOG
+ // File <logfile> <debug level>
+ return serverMessage(this, aMessage);
+ },
+ 262(aMessage) {
+ // RPL_TRACEEND
+ // <server name> <version & debug level> :End of TRACE
+ return serverMessage(this, aMessage, true);
+ },
+
+ /*
+ * Try again.
+ */
+ 263(aMessage) {
+ // RPL_TRYAGAIN
+ // <command> :Please wait a while and try again.
+ if (aMessage.params[1] == "LIST" && this._pendingList) {
+ // We may receive this from servers which rate-limit LIST if the
+ // server believes us to be asking for LIST data too soon after the
+ // previous request.
+ // Tidy up as we won't be receiving any more channels.
+ this._sendRemainingRoomInfo();
+ // Fake the last LIST time so that we may try again in one hour.
+ const kHour = 60 * 60 * 1000;
+ this._lastListTime = Date.now() - kListRefreshInterval + kHour;
+ return true;
+ }
+ return serverMessage(this, aMessage);
+ },
+
+ 265(aMessage) {
+ // nonstandard
+ // :Current Local Users: <integer> Max: <integer>
+ return serverMessage(this, aMessage);
+ },
+ 266(aMessage) {
+ // nonstandard
+ // :Current Global Users: <integer> Max: <integer>
+ return serverMessage(this, aMessage);
+ },
+ 300(aMessage) {
+ // RPL_NONE
+ // Non-generic
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * Status messages
+ */
+ 301(aMessage) {
+ // RPL_AWAY
+ // <nick> :<away message>
+ // TODO set user as away on buddy list / conversation lists
+ // TODO Display an autoResponse if this is after sending a private message
+ // If the conversation is waiting for a response, it's received one.
+ if (this.conversations.has(aMessage.params[1])) {
+ delete this.getConversation(aMessage.params[1])._pendingMessage;
+ }
+ return this.setWhois(aMessage.params[1], { away: aMessage.params[2] });
+ },
+ 302(aMessage) {
+ // RPL_USERHOST
+ // :*1<reply> *( " " <reply )"
+ // reply = nickname [ "*" ] "=" ( "+" / "-" ) hostname
+ // TODO Can tell op / away from this
+ return false;
+ },
+ 303(aMessage) {
+ // RPL_ISON
+ // :*1<nick> *( " " <nick> )"
+ // Set the status of the buddies based the latest ISON response.
+ let receivedBuddyNames = [];
+ // The buddy names as returned by the server.
+ if (aMessage.params.length > 1) {
+ receivedBuddyNames = aMessage.params[1].trim().split(" ");
+ }
+
+ // This was received in response to the last ISON message sent.
+ for (let buddyName of this.pendingIsOnQueue) {
+ // If the buddy name is in the list returned from the server, they're
+ // online.
+ let status = !receivedBuddyNames.includes(buddyName)
+ ? Ci.imIStatusInfo.STATUS_OFFLINE
+ : Ci.imIStatusInfo.STATUS_AVAILABLE;
+
+ // Set the status with no status message, only if the buddy actually
+ // exists in the buddy list.
+ let buddy = this.buddies.get(buddyName);
+ if (buddy) {
+ buddy.setStatus(status, "");
+ }
+ }
+ return true;
+ },
+ 305(aMessage) {
+ // RPL_UNAWAY
+ // :You are no longer marked as being away
+ this.isAway = false;
+ return true;
+ },
+ 306(aMessage) {
+ // RPL_NOWAWAY
+ // :You have been marked as away
+ this.isAway = true;
+ return true;
+ },
+
+ /*
+ * WHOIS
+ */
+ 311(aMessage) {
+ // RPL_WHOISUSER
+ // <nick> <user> <host> * :<real name>
+ // <username>@<hostname>
+ let nick = aMessage.params[1];
+ let source = aMessage.params[2] + "@" + aMessage.params[3];
+ // Some servers obfuscate the host when sending messages. Therefore,
+ // we set the account prefix by using the host from this response.
+ // We store it separately to avoid glitches due to the whois entry
+ // being temporarily deleted during future updates of the entry.
+ if (this.normalize(nick) == this.normalize(this._nickname)) {
+ this.prefix = "!" + source;
+ }
+ return this.setWhois(nick, {
+ realname: aMessage.params[5],
+ connectedFrom: source,
+ });
+ },
+ 312(aMessage) {
+ // RPL_WHOISSERVER
+ // <nick> <server> :<server info>
+ return this.setWhois(aMessage.params[1], {
+ serverName: aMessage.params[2],
+ serverInfo: aMessage.params[3],
+ });
+ },
+ 313(aMessage) {
+ // RPL_WHOISOPERATOR
+ // <nick> :is an IRC operator
+ return this.setWhois(aMessage.params[1], { ircOp: true });
+ },
+ 314(aMessage) {
+ // RPL_WHOWASUSER
+ // <nick> <user> <host> * :<real name>
+ let source = aMessage.params[2] + "@" + aMessage.params[3];
+ return this.setWhois(aMessage.params[1], {
+ offline: true,
+ realname: aMessage.params[5],
+ connectedFrom: source,
+ });
+ },
+ 315(aMessage) {
+ // RPL_ENDOFWHO
+ // <name> :End of WHO list
+ return false;
+ },
+ 316(aMessage) {
+ // RPL_WHOISCHANOP
+ // Non-generic
+ return false;
+ },
+ 317(aMessage) {
+ // RPL_WHOISIDLE
+ // <nick> <integer> :seconds idle
+ return this.setWhois(aMessage.params[1], {
+ lastActivity: parseInt(aMessage.params[2]),
+ });
+ },
+ 318(aMessage) {
+ // RPL_ENDOFWHOIS
+ // <nick> :End of WHOIS list
+ // We've received everything about WHOIS, tell the tooltip that is waiting
+ // for this information.
+ let nick = aMessage.params[1];
+
+ if (this.whoisInformation.has(nick)) {
+ this.notifyWhois(nick);
+ } else {
+ // If there is no whois information stored at this point, the nick
+ // is either offline or does not exist, so we run WHOWAS.
+ this.requestOfflineBuddyInfo(nick);
+ }
+ return true;
+ },
+ 319(aMessage) {
+ // RPL_WHOISCHANNELS
+ // <nick> :*( ( "@" / "+" ) <channel> " " )
+ return this.setWhois(aMessage.params[1], {
+ channels: aMessage.params[2],
+ });
+ },
+
+ /*
+ * LIST
+ */
+ 321(aMessage) {
+ // RPL_LISTSTART
+ // Channel :Users Name
+ // Obsolete. Not used.
+ return true;
+ },
+ 322(aMessage) {
+ // RPL_LIST
+ // <channel> <# visible> :<topic>
+ let name = aMessage.params[1];
+ let participantCount = aMessage.params[2];
+ let topic = aMessage.params[3];
+ // Some servers (e.g. Unreal) include the channel's modes before the topic.
+ // Omit this.
+ topic = topic.replace(/^\[\+[a-zA-Z]*\] /, "");
+ // Force the allocation of a new copy of the string so as to prevent
+ // the JS engine from retaining the whole original socket string. See bug
+ // 1058584. This hack can be removed when bug 1058653 is fixed.
+ topic = topic ? topic.normalize() : "";
+
+ this._channelList.set(name, { topic, participantCount });
+ this._currentBatch.push(name);
+ // Give callbacks a batch of channels of length _channelsPerBatch.
+ if (this._currentBatch.length == this._channelsPerBatch) {
+ for (let callback of this._roomInfoCallbacks) {
+ callback.onRoomInfoAvailable(this._currentBatch, false);
+ }
+ this._currentBatch = [];
+ }
+ return true;
+ },
+ 323(aMessage) {
+ // RPL_LISTEND
+ // :End of LIST
+ this._sendRemainingRoomInfo();
+ return true;
+ },
+
+ /*
+ * Channel functions
+ */
+ 324(aMessage) {
+ // RPL_CHANNELMODEIS
+ // <channel> <mode> <mode params>
+ this.getConversation(aMessage.params[1]).setMode(
+ aMessage.params[2],
+ aMessage.params.slice(3),
+ aMessage.origin
+ );
+
+ return true;
+ },
+ 325(aMessage) {
+ // RPL_UNIQOPIS
+ // <channel> <nickname>
+ // TODO parse this and have the UI respond accordingly.
+ return false;
+ },
+ 331(aMessage) {
+ // RPL_NOTOPIC
+ // <channel> :No topic is set
+ let conversation = this.getConversation(aMessage.params[1]);
+ // Clear the topic.
+ conversation.setTopic("");
+ return true;
+ },
+ 332(aMessage) {
+ // RPL_TOPIC
+ // <channel> :<topic>
+ // Update the topic UI
+ let conversation = this.getConversation(aMessage.params[1]);
+ let topic = aMessage.params[2];
+ conversation.setTopic(topic ? ctcpFormatToText(topic) : "");
+ return true;
+ },
+ 333(aMessage) {
+ // nonstandard
+ // <channel> <nickname> <time>
+ return true;
+ },
+
+ /*
+ * Invitations
+ */
+ 341(aMessage) {
+ // RPL_INVITING
+ // <channel> <nick>
+ // Note that servers reply with parameters in the reverse order from the
+ // above (which is as specified by RFC 2812).
+ this.getConversation(aMessage.params[2]).writeMessage(
+ aMessage.origin,
+ lazy._("message.invited", aMessage.params[1], aMessage.params[2]),
+ { system: true }
+ );
+ return true;
+ },
+ 342(aMessage) {
+ // RPL_SUMMONING
+ // <user> :Summoning user to IRC
+ return writeMessage(
+ this,
+ aMessage,
+ lazy._("message.summoned", aMessage.params[0])
+ );
+ },
+ 346(aMessage) {
+ // RPL_INVITELIST
+ // <channel> <invitemask>
+ // TODO what do we do?
+ return false;
+ },
+ 347(aMessage) {
+ // RPL_ENDOFINVITELIST
+ // <channel> :End of channel invite list
+ // TODO what do we do?
+ return false;
+ },
+ 348(aMessage) {
+ // RPL_EXCEPTLIST
+ // <channel> <exceptionmask>
+ // TODO what do we do?
+ return false;
+ },
+ 349(aMessage) {
+ // RPL_ENDOFEXCEPTIONLIST
+ // <channel> :End of channel exception list
+ // TODO update UI?
+ return false;
+ },
+
+ /*
+ * Version
+ */
+ 351(aMessage) {
+ // RPL_VERSION
+ // <version>.<debuglevel> <server> :<comments>
+ return serverMessage(this, aMessage);
+ },
+
+ /*
+ * WHO
+ */
+ 352(aMessage) {
+ // RPL_WHOREPLY
+ // <channel> <user> <host> <server> <nick> ( "H" / "G" ) ["*"] [ ("@" / "+" ) ] :<hopcount> <real name>
+ // TODO parse and display this?
+ return false;
+ },
+
+ /*
+ * NAMREPLY
+ */
+ 353(aMessage) {
+ // RPL_NAMREPLY
+ // <target> ( "=" / "*" / "@" ) <channel> :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
+ let conversation = this.getConversation(aMessage.params[2]);
+ // Keep if this is secret (@), private (*) or public (=).
+ conversation.setModesFromRestriction(aMessage.params[1]);
+ // Add the participants.
+ let newParticipants = [];
+ aMessage.params[3]
+ .trim()
+ .split(" ")
+ .forEach(aNick =>
+ newParticipants.push(conversation.getParticipant(aNick, false))
+ );
+ conversation.notifyObservers(
+ new nsSimpleEnumerator(newParticipants),
+ "chat-buddy-add"
+ );
+ return true;
+ },
+
+ 361(aMessage) {
+ // RPL_KILLDONE
+ // Non-generic
+ // TODO What is this?
+ return false;
+ },
+ 362(aMessage) {
+ // RPL_CLOSING
+ // Non-generic
+ // TODO What is this?
+ return false;
+ },
+ 363(aMessage) {
+ // RPL_CLOSEEND
+ // Non-generic
+ // TODO What is this?
+ return false;
+ },
+
+ /*
+ * Links.
+ */
+ 364(aMessage) {
+ // RPL_LINKS
+ // <mask> <server> :<hopcount> <server info>
+ return serverMessage(this, aMessage);
+ },
+ 365(aMessage) {
+ // RPL_ENDOFLINKS
+ // <mask> :End of LINKS list
+ return true;
+ },
+
+ /*
+ * Names
+ */
+ 366(aMessage) {
+ // RPL_ENDOFNAMES
+ // <target> <channel> :End of NAMES list
+ // All participants have already been added by the 353 handler.
+
+ // This assumes that this is the last message received when joining a
+ // channel, so a few "clean up" tasks are done here.
+ let conversation = this.getConversation(aMessage.params[1]);
+
+ // Update the topic as we may have added the participant for
+ // the user after the mode message was handled, and so
+ // topicSettable may have changed.
+ conversation.notifyObservers(this, "chat-update-topic");
+
+ // If we haven't received the MODE yet, request it.
+ if (!conversation._receivedInitialMode) {
+ this.sendMessage("MODE", aMessage.params[1]);
+ }
+
+ return true;
+ },
+ /*
+ * End of a bunch of lists
+ */
+ 367(aMessage) {
+ // RPL_BANLIST
+ // <channel> <banmask>
+ let conv = this.getConversation(aMessage.params[1]);
+ if (!conv.banMasks.includes(aMessage.params[2])) {
+ conv.banMasks.push(aMessage.params[2]);
+ }
+ return true;
+ },
+ 368(aMessage) {
+ // RPL_ENDOFBANLIST
+ // <channel> :End of channel ban list
+ let conv = this.getConversation(aMessage.params[1]);
+ let msg;
+ if (conv.banMasks.length) {
+ msg = [lazy._("message.banMasks", aMessage.params[1])]
+ .concat(conv.banMasks)
+ .join("\n");
+ } else {
+ msg = lazy._("message.noBanMasks", aMessage.params[1]);
+ }
+ conv.writeMessage(aMessage.origin, msg, { system: true });
+ return true;
+ },
+ 369(aMessage) {
+ // RPL_ENDOFWHOWAS
+ // <nick> :End of WHOWAS
+ // We've received everything about WHOWAS, tell the tooltip that is waiting
+ // for this information.
+ this.notifyWhois(aMessage.params[1]);
+ return true;
+ },
+
+ /*
+ * Server info
+ */
+ 371(aMessage) {
+ // RPL_INFO
+ // :<string>
+ return serverMessage(this, aMessage);
+ },
+ 372(aMessage) {
+ // RPL_MOTD
+ // :- <text>
+ return addMotd(this, aMessage);
+ },
+ 373(aMessage) {
+ // RPL_INFOSTART
+ // Non-generic
+ // This is unnecessary and servers just send RPL_INFO.
+ return true;
+ },
+ 374(aMessage) {
+ // RPL_ENDOFINFO
+ // :End of INFO list
+ return true;
+ },
+ 375(aMessage) {
+ // RPL_MOTDSTART
+ // :- <server> Message of the day -
+ return addMotd(this, aMessage);
+ },
+ 376(aMessage) {
+ // RPL_ENDOFMOTD
+ // :End of MOTD command
+ // Show the MOTD if the user wants to see server messages or if
+ // RPL_WELCOME has not been received since some servers (e.g. irc.ppy.sh)
+ // use this as a CAPTCHA like mechanism before login can occur.
+ if (this._showServerTab || !this.connected) {
+ writeMessage(this, aMessage, this._motd.join("\n"), "incoming");
+ }
+ // No reason to keep the MOTD in memory.
+ delete this._motd;
+ // Clear the MOTD timer.
+ clearTimeout(this._motdTimer);
+ delete this._motdTimer;
+
+ return true;
+ },
+
+ /*
+ * OPER
+ */
+ 381(aMessage) {
+ // RPL_YOUREOPER
+ // :You are now an IRC operator
+ // TODO update UI accordingly to show oper status
+ return serverMessage(this, aMessage);
+ },
+ 382(aMessage) {
+ // RPL_REHASHING
+ // <config file> :Rehashing
+ return serverMessage(this, aMessage);
+ },
+ 383(aMessage) {
+ // RPL_YOURESERVICE
+ // You are service <servicename>
+ this.WARN('Received "You are a service" message.');
+ return true;
+ },
+
+ /*
+ * Info
+ */
+ 384(aMessage) {
+ // RPL_MYPORTIS
+ // Non-generic
+ // TODO Parse and display?
+ return false;
+ },
+ 391(aMessage) {
+ // RPL_TIME
+ // <server> :<string showing server's local time>
+
+ let msg = lazy._("ctcp.time", aMessage.params[1], aMessage.params[2]);
+ // Show the date returned from the server, note that this doesn't use
+ // the serverMessage function: since this is in response to a command, it
+ // should always be shown.
+ return writeMessage(this, aMessage, msg, "system");
+ },
+ 392(aMessage) {
+ // RPL_USERSSTART
+ // :UserID Terminal Host
+ // TODO
+ return false;
+ },
+ 393(aMessage) {
+ // RPL_USERS
+ // :<username> <ttyline> <hostname>
+ // TODO store into buddy list? List out?
+ return false;
+ },
+ 394(aMessage) {
+ // RPL_ENDOFUSERS
+ // :End of users
+ // TODO Notify observers of the buddy list?
+ return false;
+ },
+ 395(aMessage) {
+ // RPL_NOUSERS
+ // :Nobody logged in
+ // TODO clear buddy list?
+ return false;
+ },
+
+ // Error messages, Implement Section 5.2 of RFC 2812
+ 401(aMessage) {
+ // ERR_NOSUCHNICK
+ // <nickname> :No such nick/channel
+ // Can arise in response to /mode, /invite, /kill, /msg, /whois.
+ // TODO Handled in the conversation for /whois and /mgs so far.
+ let msgId =
+ "error.noSuch" +
+ (this.isMUCName(aMessage.params[1]) ? "Channel" : "Nick");
+ if (this.conversations.has(aMessage.params[1])) {
+ // If the conversation exists and we just sent a message from it, then
+ // notify that the user is offline.
+ if (this.getConversation(aMessage.params[1])._pendingMessage) {
+ conversationErrorMessage(this, aMessage, msgId);
+ }
+ }
+
+ return serverErrorMessage(
+ this,
+ aMessage,
+ lazy._(msgId, aMessage.params[1])
+ );
+ },
+ 402(aMessage) {
+ // ERR_NOSUCHSERVER
+ // <server name> :No such server
+ // TODO Parse & display an error to the user.
+ return false;
+ },
+ 403(aMessage) {
+ // ERR_NOSUCHCHANNEL
+ // <channel name> :No such channel
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.noChannel",
+ true,
+ false
+ );
+ },
+ 404(aMessage) {
+ // ERR_CANNOTSENDTOCHAN
+ // <channel name> :Cannot send to channel
+ // Notify the user that they can't send to that channel.
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.cannotSendToChannel"
+ );
+ },
+ 405(aMessage) {
+ // ERR_TOOMANYCHANNELS
+ // <channel name> :You have joined too many channels
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.tooManyChannels",
+ true
+ );
+ },
+ 406(aMessage) {
+ // ERR_WASNOSUCHNICK
+ // <nickname> :There was no such nickname
+ // Can arise in response to WHOWAS.
+ return serverErrorMessage(
+ this,
+ aMessage,
+ lazy._("error.wasNoSuchNick", aMessage.params[1])
+ );
+ },
+ 407(aMessage) {
+ // ERR_TOOMANYTARGETS
+ // <target> :<error code> recipients. <abort message>
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.nonUniqueTarget",
+ false,
+ false
+ );
+ },
+ 408(aMessage) {
+ // ERR_NOSUCHSERVICE
+ // <service name> :No such service
+ // TODO
+ return false;
+ },
+ 409(aMessage) {
+ // ERR_NOORIGIN
+ // :No origin specified
+ // TODO failed PING/PONG message, this should never occur?
+ return false;
+ },
+ 411(aMessage) {
+ // ERR_NORECIPIENT
+ // :No recipient given (<command>)
+ // If this happens a real error with the protocol occurred.
+ this.ERROR("ERR_NORECIPIENT: No recipient given for PRIVMSG.");
+ return true;
+ },
+ 412(aMessage) {
+ // ERR_NOTEXTTOSEND
+ // :No text to send
+ // If this happens a real error with the protocol occurred: we should
+ // always block the user from sending empty messages.
+ this.ERROR("ERR_NOTEXTTOSEND: No text to send for PRIVMSG.");
+ return true;
+ },
+ 413(aMessage) {
+ // ERR_NOTOPLEVEL
+ // <mask> :No toplevel domain specified
+ // If this response is received, a real error occurred in the protocol.
+ this.ERROR("ERR_NOTOPLEVEL: Toplevel domain not specified.");
+ return true;
+ },
+ 414(aMessage) {
+ // ERR_WILDTOPLEVEL
+ // <mask> :Wildcard in toplevel domain
+ // If this response is received, a real error occurred in the protocol.
+ this.ERROR("ERR_WILDTOPLEVEL: Wildcard toplevel domain specified.");
+ return true;
+ },
+ 415(aMessage) {
+ // ERR_BADMASK
+ // <mask> :Bad Server/host mask
+ // If this response is received, a real error occurred in the protocol.
+ this.ERROR("ERR_BADMASK: Bad server/host mask specified.");
+ return true;
+ },
+ 421(aMessage) {
+ // ERR_UNKNOWNCOMMAND
+ // <command> :Unknown command
+ // TODO This shouldn't occur.
+ return false;
+ },
+ 422(aMessage) {
+ // ERR_NOMOTD
+ // :MOTD File is missing
+ // No message of the day to display.
+ return true;
+ },
+ 423(aMessage) {
+ // ERR_NOADMININFO
+ // <server> :No administrative info available
+ // TODO
+ return false;
+ },
+ 424(aMessage) {
+ // ERR_FILEERROR
+ // :File error doing <file op> on <file>
+ // TODO
+ return false;
+ },
+ 431(aMessage) {
+ // ERR_NONICKNAMEGIVEN
+ // :No nickname given
+ // TODO
+ return false;
+ },
+ 432(aMessage) {
+ // ERR_ERRONEUSNICKNAME
+ // <nick> :Erroneous nickname
+ let msg = lazy._("error.erroneousNickname", this._requestedNickname);
+ serverErrorMessage(this, aMessage, msg);
+ if (this._requestedNickname == this._accountNickname) {
+ // The account has been set up with an illegal nickname.
+ this.ERROR(
+ "Erroneous nickname " +
+ this._requestedNickname +
+ ": " +
+ aMessage.params.slice(1).join(" ")
+ );
+ this.gotDisconnected(Ci.prplIAccount.ERROR_INVALID_USERNAME, msg);
+ } else {
+ // Reset original nickname to the account nickname in case of
+ // later reconnections.
+ this._requestedNickname = this._accountNickname;
+ }
+ return true;
+ },
+ 433(aMessage) {
+ // ERR_NICKNAMEINUSE
+ // <nick> :Nickname is already in use
+ // Try to get the desired nick back in 2.5 minutes if this happens when
+ // connecting, in case it was just due to the user's nick not having
+ // timed out yet on the server.
+ if (this.connecting && aMessage.params[1] == this._requestedNickname) {
+ this._nickInUseTimeout = setTimeout(() => {
+ this.changeNick(this._requestedNickname);
+ delete this._nickInUseTimeout;
+ }, 150000);
+ }
+ return this.tryNewNick(aMessage.params[1]);
+ },
+ 436(aMessage) {
+ // ERR_NICKCOLLISION
+ // <nick> :Nickname collision KILL from <user>@<host>
+ return this.tryNewNick(aMessage.params[1]);
+ },
+ 437(aMessage) {
+ // ERR_UNAVAILRESOURCE
+ // <nick/channel> :Nick/channel is temporarily unavailable
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.unavailable",
+ true
+ );
+ },
+ 441(aMessage) {
+ // ERR_USERNOTINCHANNEL
+ // <nick> <channel> :They aren't on that channel
+ // TODO
+ return false;
+ },
+ 442(aMessage) {
+ // ERR_NOTONCHANNEL
+ // <channel> :You're not on that channel
+ this.ERROR(
+ "A command affecting " +
+ aMessage.params[1] +
+ " failed because you aren't in that channel."
+ );
+ return true;
+ },
+ 443(aMessage) {
+ // ERR_USERONCHANNEL
+ // <user> <channel> :is already on channel
+ this.getConversation(aMessage.params[2]).writeMessage(
+ aMessage.origin,
+ lazy._(
+ "message.alreadyInChannel",
+ aMessage.params[1],
+ aMessage.params[2]
+ ),
+ { system: true }
+ );
+ return true;
+ },
+ 444(aMessage) {
+ // ERR_NOLOGIN
+ // <user> :User not logged in
+ // TODO
+ return false;
+ },
+ 445(aMessage) {
+ // ERR_SUMMONDISABLED
+ // :SUMMON has been disabled
+ // TODO keep track of this and disable UI associated?
+ return false;
+ },
+ 446(aMessage) {
+ // ERR_USERSDISABLED
+ // :USERS has been disabled
+ // TODO Disabled all buddy list etc.
+ return false;
+ },
+ 451(aMessage) {
+ // ERR_NOTREGISTERED
+ // :You have not registered
+ // If the server doesn't understand CAP it might return this error.
+ if (aMessage.params[0] == "CAP") {
+ this.LOG("Server doesn't support CAP.");
+ return true;
+ }
+ // TODO
+ return false;
+ },
+ 461(aMessage) {
+ // ERR_NEEDMOREPARAMS
+ // <command> :Not enough parameters
+
+ if (!this.connected) {
+ // The account has been set up with an illegal username.
+ this.ERROR("Erroneous username: " + this.username);
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_INVALID_USERNAME,
+ lazy._("connection.error.invalidUsername", this.user)
+ );
+ return true;
+ }
+
+ return false;
+ },
+ 462(aMessage) {
+ // ERR_ALREADYREGISTERED
+ // :Unauthorized command (already registered)
+ // TODO
+ return false;
+ },
+ 463(aMessage) {
+ // ERR_NOPERMFORHOST
+ // :Your host isn't among the privileged
+ // TODO
+ return false;
+ },
+ 464(aMessage) {
+ // ERR_PASSWDMISMATCH
+ // :Password incorrect
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.invalidPassword")
+ );
+ return true;
+ },
+ 465(aMessage) {
+ // ERR_YOUREBANEDCREEP
+ // :You are banned from this server
+ serverErrorMessage(this, aMessage, lazy._("error.banned"));
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("error.banned")
+ ); // Notify account manager.
+ return true;
+ },
+ 466(aMessage) {
+ // ERR_YOUWILLBEBANNED
+ return serverErrorMessage(this, aMessage, lazy._("error.bannedSoon"));
+ },
+ 467(aMessage) {
+ // ERR_KEYSET
+ // <channel> :Channel key already set
+ // TODO
+ return false;
+ },
+ 471(aMessage) {
+ // ERR_CHANNELISFULL
+ // <channel> :Cannot join channel (+l)
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.channelFull",
+ true
+ );
+ },
+ 472(aMessage) {
+ // ERR_UNKNOWNMODE
+ // <char> :is unknown mode char to me for <channel>
+ // TODO
+ return false;
+ },
+ 473(aMessage) {
+ // ERR_INVITEONLYCHAN
+ // <channel> :Cannot join channel (+i)
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.inviteOnly",
+ true,
+ false
+ );
+ },
+ 474(aMessage) {
+ // ERR_BANNEDFROMCHAN
+ // <channel> :Cannot join channel (+b)
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.channelBanned",
+ true,
+ false
+ );
+ },
+ 475(aMessage) {
+ // ERR_BADCHANNELKEY
+ // <channel> :Cannot join channel (+k)
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.wrongKey",
+ true,
+ false
+ );
+ },
+ 476(aMessage) {
+ // ERR_BADCHANMASK
+ // <channel> :Bad Channel Mask
+ // TODO
+ return false;
+ },
+ 477(aMessage) {
+ // ERR_NOCHANMODES
+ // <channel> :Channel doesn't support modes
+ // TODO
+ return false;
+ },
+ 478(aMessage) {
+ // ERR_BANLISTFULL
+ // <channel> <char> :Channel list is full
+ // TODO
+ return false;
+ },
+ 481(aMessage) {
+ // ERR_NOPRIVILEGES
+ // :Permission Denied- You're not an IRC operator
+ // TODO ask to auth?
+ return false;
+ },
+ 482(aMessage) {
+ // ERR_CHANOPRIVSNEEDED
+ // <channel> :You're not channel operator
+ return conversationErrorMessage(this, aMessage, "error.notChannelOp");
+ },
+ 483(aMessage) {
+ // ERR_CANTKILLSERVER
+ // :You can't kill a server!
+ // TODO Display error?
+ return false;
+ },
+ 484(aMessage) {
+ // ERR_RESTRICTED
+ // :Your connection is restricted!
+ // Indicates user mode +r
+ // TODO
+ return false;
+ },
+ 485(aMessage) {
+ // ERR_UNIQOPPRIVSNEEDED
+ // :You're not the original channel operator
+ // TODO ask to auth?
+ return false;
+ },
+ 491(aMessage) {
+ // ERR_NOOPERHOST
+ // :No O-lines for your host
+ // TODO
+ return false;
+ },
+ 492(aMessage) {
+ // ERR_NOSERVICEHOST
+ // Non-generic
+ // TODO
+ return false;
+ },
+ 501(aMessage) {
+ // ERR_UMODEUNKNOWNFLAGS
+ // :Unknown MODE flag
+ return serverErrorMessage(
+ this,
+ aMessage,
+ lazy._("error.unknownMode", aMessage.params[1])
+ );
+ },
+ 502(aMessage) {
+ // ERR_USERSDONTMATCH
+ // :Cannot change mode for other users
+ return serverErrorMessage(this, aMessage, lazy._("error.mode.wrongUser"));
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircCAP.sys.mjs b/comm/chat/protocols/irc/ircCAP.sys.mjs
new file mode 100644
index 0000000000..3bcff48d8b
--- /dev/null
+++ b/comm/chat/protocols/irc/ircCAP.sys.mjs
@@ -0,0 +1,170 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements the IRC Client Capabilities sub-protocol.
+ * Client Capab Proposal
+ * http://www.leeh.co.uk/ircd/client-cap.txt
+ * RFC Drafts: IRC Client Capabilities
+ * http://tools.ietf.org/html/draft-baudis-irc-capab-00
+ * http://tools.ietf.org/html/draft-mitchell-irc-capabilities-01
+ * IRCv3
+ * https://ircv3.net/specs/core/capability-negotiation.html
+ *
+ * Note that this doesn't include any implementation as these RFCs do not even
+ * include example parameters.
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+/*
+ * Parses a CAP message of the form:
+ * CAP [*|<user>] <subcommand> [*] [<parameters>]
+ * The cap field is added to the message and it has the following fields:
+ * subcommand
+ * parameters A list of capabilities.
+ */
+export function capMessage(aMessage, aAccount) {
+ // The CAP parameters are space separated as the last parameter.
+ let parameters = aMessage.params.slice(-1)[0].trim().split(" ");
+ // The subcommand is the second parameter...although sometimes it's the first
+ // parameter.
+ aMessage.cap = {
+ subcommand: aMessage.params[aMessage.params.length >= 3 ? 1 : 0],
+ };
+
+ const messages = parameters.map(function (aParameter) {
+ // Clone the original object.
+ let message = Object.assign({}, aMessage);
+ message.cap = Object.assign({}, aMessage.cap);
+
+ // If there's a modifier...pull it off. (This is pretty much unused, but we
+ // have to pull it off for backward compatibility.)
+ if ("-=~".includes(aParameter[0])) {
+ message.cap.modifier = aParameter[0];
+ aParameter = aParameter.substr(1);
+ } else {
+ message.cap.modifier = undefined;
+ }
+
+ // CAP v3.2 capability value
+ if (aParameter.includes("=")) {
+ let paramParts = aParameter.split("=");
+ aParameter = paramParts[0];
+ // The value itself may contain an = sign, join the rest of the parts back together.
+ message.cap.value = paramParts.slice(1).join("=");
+ }
+
+ // The names are case insensitive, arbitrarily choose lowercase.
+ message.cap.parameter = aParameter.toLowerCase();
+ message.cap.disable = message.cap.modifier == "-";
+ message.cap.sticky = message.cap.modifier == "=";
+ message.cap.ack = message.cap.modifier == "~";
+
+ return message;
+ });
+
+ // Queue up messages if the server is indicating multiple lines of caps to list.
+ if (
+ (aMessage.cap.subcommand === "LS" || aMessage.cap.subcommand === "LIST") &&
+ aMessage.params.length == 4
+ ) {
+ aAccount._queuedCAPs = aAccount._queuedCAPs.concat(messages);
+ return [];
+ }
+
+ const retMessages = aAccount._queuedCAPs.concat(messages);
+ aAccount._queuedCAPs.length = 0;
+ return retMessages;
+}
+
+export var ircCAP = {
+ name: "Client Capabilities",
+ // Slightly above default RFC 2812 priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ CAP(message, ircHandlers) {
+ // [* | <nick>] <subcommand> :<parameters>
+ let messages = capMessage(message, this);
+
+ for (const capCommandMessage of messages) {
+ if (
+ capCommandMessage.cap.subcommand === "LS" ||
+ capCommandMessage.cap.subcommand === "NEW"
+ ) {
+ this._availableCAPs.add(capCommandMessage.cap.parameter);
+ } else if (capCommandMessage.cap.subcommand === "ACK") {
+ this._activeCAPs.add(capCommandMessage.cap.parameter);
+ } else if (capCommandMessage.cap.subcommand === "DEL") {
+ this._availableCAPs.delete(capCommandMessage.cap.parameter);
+ this._activeCAPs.delete(capCommandMessage.cap.parameter);
+ }
+ }
+
+ messages = messages.filter(
+ aMessage => !ircHandlers.handleCAPMessage(this, aMessage)
+ );
+ if (messages.length) {
+ // Display the list of unhandled CAP messages.
+ let unhandledMessages = messages
+ .map(aMsg => aMsg.cap.parameter)
+ .join(" ");
+ this.LOG(
+ "Unhandled CAP messages: " +
+ unhandledMessages +
+ "\nRaw message: " +
+ message.rawMessage
+ );
+ }
+
+ // If no CAP handlers were added, just tell the server we're done.
+ if (
+ message.cap.subcommand == "LS" &&
+ !this._requestedCAPs.size &&
+ !this._queuedCAPs.length
+ ) {
+ this.sendMessage("CAP", "END");
+ this._negotiatedCAPs = true;
+ }
+ return true;
+ },
+
+ 410(aMessage) {
+ // ERR_INVALIDCAPCMD
+ // <unrecognized subcommand> :Invalid CAP subcommand
+ this.WARN("Invalid subcommand: " + aMessage.params[1]);
+ return true;
+ },
+ },
+};
+
+export var capNotify = {
+ name: "Client Capabilities",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ // This is implicitly enabled as part of CAP v3.2, so always enable it.
+ isEnabled: () => true,
+
+ commands: {
+ "cap-notify": function (aMessage) {
+ // This negotiation is entirely optional. cap-notify may thus never be formally registered.
+ if (
+ aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW"
+ ) {
+ this.addCAP("cap-notify");
+ this.sendMessage("CAP", ["REQ", "cap-notify"]);
+ } else if (
+ aMessage.cap.subcommand === "ACK" ||
+ aMessage.cap.subcommand === "NAK"
+ ) {
+ this.removeCAP("cap-notify");
+ } else {
+ return false;
+ }
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircCTCP.sys.mjs b/comm/chat/protocols/irc/ircCTCP.sys.mjs
new file mode 100644
index 0000000000..475f1f8a8d
--- /dev/null
+++ b/comm/chat/protocols/irc/ircCTCP.sys.mjs
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This implements the Client-to-Client Protocol (CTCP), a subprotocol of IRC.
+ * REVISED AND UPDATED CTCP SPECIFICATION
+ * http://www.alien.net.au/irc/ctcp.txt
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+import { displayMessage } from "resource:///modules/ircUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+// Split into a CTCP message which is a single command and a single parameter:
+// <command> " " <parameter>
+// The high level dequote is to unescape \001 in the message content.
+export function CTCPMessage(aMessage, aRawCTCPMessage) {
+ let message = Object.assign({}, aMessage);
+ message.ctcp = {};
+ message.ctcp.rawMessage = aRawCTCPMessage;
+
+ // High/CTCP level dequote: replace the quote char \134 followed by a or \134
+ // with \001 or \134, respectively. Any other character after \134 is replaced
+ // with itself.
+ let dequotedCTCPMessage = message.ctcp.rawMessage.replace(
+ /\\(.|$)/g,
+ aStr => {
+ if (aStr[1]) {
+ return aStr[1] == "a" ? "\x01" : aStr[1];
+ }
+ return "";
+ }
+ );
+
+ let separator = dequotedCTCPMessage.indexOf(" ");
+ // If there's no space, then only a command is given.
+ // Do not capitalize the command, case sensitive
+ if (separator == -1) {
+ message.ctcp.command = dequotedCTCPMessage;
+ message.ctcp.param = "";
+ } else {
+ message.ctcp.command = dequotedCTCPMessage.slice(0, separator);
+ message.ctcp.param = dequotedCTCPMessage.slice(separator + 1);
+ }
+ return message;
+}
+
+// This is the CTCP handler for IRC protocol, it will call each CTCP handler.
+export var ircCTCP = {
+ name: "CTCP",
+ // Slightly above default RFC 2812 priority.
+ priority: ircHandlerPriorities.HIGH_PRIORITY,
+ isEnabled: () => true,
+
+ // CTCP uses only PRIVMSG and NOTICE commands.
+ commands: {
+ PRIVMSG: ctcpHandleMessage,
+ NOTICE: ctcpHandleMessage,
+ },
+};
+
+// Parse the message and call all CTCP handlers on the message.
+function ctcpHandleMessage(message, ircHandlers) {
+ // If there are no CTCP handlers, then don't parse the CTCP message.
+ if (!ircHandlers.hasCTCPHandlers) {
+ return false;
+ }
+
+ // The raw CTCP message is in the last parameter of the IRC message.
+ let rawCTCPParam = message.params.slice(-1)[0];
+
+ // Split the raw message into the multiple CTCP messages and pull out the
+ // command and parameters.
+ let ctcpMessages = [];
+ let otherMessage = rawCTCPParam.replace(
+ // eslint-disable-next-line no-control-regex
+ /\x01([^\x01]*)\x01/g,
+ function (aMatch, aMsg) {
+ if (aMsg) {
+ ctcpMessages.push(new CTCPMessage(message, aMsg));
+ }
+ return "";
+ }
+ );
+
+ // If no CTCP messages were found, return false.
+ if (!ctcpMessages.length) {
+ return false;
+ }
+
+ // If there's some message left, send it back through the IRC handlers after
+ // stripping out the CTCP information. I highly doubt this will ever happen,
+ // but just in case. ;)
+ if (otherMessage) {
+ message.params.pop();
+ message.params.push(otherMessage);
+ ircHandlers.handleMessage(this, message);
+ }
+
+ // Loop over each raw CTCP message.
+ for (let message of ctcpMessages) {
+ if (!ircHandlers.handleCTCPMessage(this, message)) {
+ this.WARN(
+ "Unhandled CTCP message: " +
+ message.ctcp.rawMessage +
+ "\nin IRC message: " +
+ message.rawMessage
+ );
+ // For unhandled CTCP message, respond with a NOTICE ERRMSG that echoes
+ // back the original command.
+ this.sendCTCPMessage(message.origin, true, "ERRMSG", [
+ message.ctcp.rawMessage,
+ ":Unhandled CTCP command",
+ ]);
+ }
+ }
+
+ // We have handled this message as much as we can.
+ return true;
+}
+
+// This is the the basic CTCP protocol.
+export var ctcpBase = {
+ // Parameters
+ name: "CTCP",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ // These represent CTCP commands.
+ commands: {
+ ACTION(aMessage) {
+ // ACTION <text>
+ // Display message in conversation
+ return displayMessage(
+ this,
+ aMessage,
+ { action: true },
+ aMessage.ctcp.param
+ );
+ },
+
+ // Used when an error needs to be replied with.
+ ERRMSG(aMessage) {
+ this.WARN(
+ aMessage.origin +
+ " failed to handle CTCP message: " +
+ aMessage.ctcp.param
+ );
+ return true;
+ },
+
+ // This is commented out since CLIENTINFO automatically returns the
+ // supported CTCP parameters and this is not supported.
+
+ // Returns the user's full name, and idle time.
+ // "FINGER": function(aMessage) { return false; },
+
+ // Dynamic master index of what a client knows.
+ CLIENTINFO(message, ircHandlers) {
+ if (message.command == "PRIVMSG") {
+ // Received a CLIENTINFO request, respond with the support CTCP
+ // messages.
+ let info = new Set();
+ for (let handler of ircHandlers._ctcpHandlers) {
+ for (let command in handler.commands) {
+ info.add(command);
+ }
+ }
+
+ let supportedCtcp = [...info].join(" ");
+ this.LOG(
+ "Reporting support for the following CTCP messages: " + supportedCtcp
+ );
+ this.sendCTCPMessage(message.origin, true, "CLIENTINFO", supportedCtcp);
+ } else {
+ // Received a CLIENTINFO response, store the information for future
+ // use.
+ let info = message.ctcp.param.split(" ");
+ this.setWhois(message.origin, { clientInfo: info });
+ }
+ return true;
+ },
+
+ // Used to measure the delay of the IRC network between clients.
+ PING(aMessage) {
+ // PING timestamp
+ if (aMessage.command == "PRIVMSG") {
+ // Received PING request, send PING response.
+ this.LOG(
+ "Received PING request from " +
+ aMessage.origin +
+ '. Sending PING response: "' +
+ aMessage.ctcp.param +
+ '".'
+ );
+ this.sendCTCPMessage(
+ aMessage.origin,
+ true,
+ "PING",
+ aMessage.ctcp.param
+ );
+ return true;
+ }
+ return this.handlePingReply(aMessage.origin, aMessage.ctcp.param);
+ },
+
+ // These are commented out since CLIENTINFO automatically returns the
+ // supported CTCP parameters and this is not supported.
+
+ // An encryption protocol between clients without any known reference.
+ // "SED": function(aMessage) { return false; },
+
+ // Where to obtain a copy of a client.
+ // "SOURCE": function(aMessage) { return false; },
+
+ // Gets the local date and time from other clients.
+ TIME(aMessage) {
+ if (aMessage.command == "PRIVMSG") {
+ // TIME
+ // Received a TIME request, send a human readable response.
+ let now = new Date().toString();
+ this.LOG(
+ "Received TIME request from " +
+ aMessage.origin +
+ '. Sending TIME response: "' +
+ now +
+ '".'
+ );
+ this.sendCTCPMessage(aMessage.origin, true, "TIME", ":" + now);
+ } else {
+ // TIME :<human-readable-time-string>
+ // Received a TIME reply, display it.
+ // Remove the : prefix, if it exists and display the result.
+ let time = aMessage.ctcp.param.slice(aMessage.ctcp.param[0] == ":");
+ this.getConversation(aMessage.origin).writeMessage(
+ aMessage.origin,
+ lazy._("ctcp.time", aMessage.origin, time),
+ { system: true, tags: aMessage.tags }
+ );
+ }
+ return true;
+ },
+
+ // This is commented out since CLIENTINFO automatically returns the
+ // supported CTCP parameters and this is not supported.
+
+ // A string set by the user (never the client coder)
+ // "USERINFO": function(aMessage) { return false; },
+
+ // The version and type of the client.
+ VERSION(aMessage) {
+ if (aMessage.command == "PRIVMSG") {
+ // VERSION
+ // Received VERSION request, send VERSION response.
+ let version = Services.appinfo.name + " " + Services.appinfo.version;
+ this.LOG(
+ "Received VERSION request from " +
+ aMessage.origin +
+ '. Sending VERSION response: "' +
+ version +
+ '".'
+ );
+ this.sendCTCPMessage(aMessage.origin, true, "VERSION", version);
+ } else if (aMessage.command == "NOTICE" && aMessage.ctcp.param.length) {
+ // VERSION #:#:#
+ // Received VERSION response, display to the user.
+ let response = lazy._(
+ "ctcp.version",
+ aMessage.origin,
+ aMessage.ctcp.param
+ );
+ this.getConversation(aMessage.origin).writeMessage(
+ aMessage.origin,
+ response,
+ {
+ system: true,
+ tags: aMessage.tags,
+ }
+ );
+ }
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircCommands.sys.mjs b/comm/chat/protocols/irc/ircCommands.sys.mjs
new file mode 100644
index 0000000000..78d984b179
--- /dev/null
+++ b/comm/chat/protocols/irc/ircCommands.sys.mjs
@@ -0,0 +1,599 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This is to be exported directly onto the IRC prplIProtocol object, directly
+// implementing the commands field before we register them.
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+// Shortcut to get the JavaScript conversation object.
+function getConv(aConv) {
+ return aConv.wrappedJSObject;
+}
+
+// Shortcut to get the JavaScript account object.
+function getAccount(aConv) {
+ return getConv(aConv)._account;
+}
+
+// Trim leading and trailing spaces and split a string by any type of space.
+function splitInput(aString) {
+ return aString.trim().split(/\s+/);
+}
+
+// Kick a user from a channel
+// aMsg is <user> [comment]
+function kickCommand(aMsg, aConv) {
+ if (!aMsg.length) {
+ return false;
+ }
+
+ let params = [aConv.name];
+ let offset = aMsg.indexOf(" ");
+ if (offset != -1) {
+ params.push(aMsg.slice(0, offset));
+ params.push(aMsg.slice(offset + 1));
+ } else {
+ params.push(aMsg);
+ }
+
+ getAccount(aConv).sendMessage("KICK", params);
+ return true;
+}
+
+// Send a message directly to a user.
+// aMsg is <user> <message>
+// aReturnedConv is optional and returns the resulting conversation.
+function messageCommand(aMsg, aConv, aReturnedConv, aIsNotice = false) {
+ // Trim leading whitespace.
+ aMsg = aMsg.trimLeft();
+
+ let nickname = aMsg;
+ let message = "";
+
+ let sep = aMsg.indexOf(" ");
+ if (sep > -1) {
+ nickname = aMsg.slice(0, sep);
+ message = aMsg.slice(sep + 1);
+ }
+ if (!nickname.length) {
+ return false;
+ }
+
+ let conv = getAccount(aConv).getConversation(nickname);
+ if (aReturnedConv) {
+ aReturnedConv.value = conv;
+ }
+
+ if (!message.length) {
+ return true;
+ }
+
+ return privateMessage(aConv, message, nickname, aReturnedConv, aIsNotice);
+}
+
+// aAdd is true to add a mode, false to remove a mode.
+function setMode(aNickname, aConv, aMode, aAdd) {
+ if (!aNickname.length) {
+ return false;
+ }
+
+ // Change the mode for each nick, as separator by spaces.
+ return splitInput(aNickname).every(aNick =>
+ simpleCommand(aConv, "MODE", [
+ aConv.name,
+ (aAdd ? "+" : "-") + aMode,
+ aNick,
+ ])
+ );
+}
+
+function actionCommand(aMsg, aConv) {
+ // Don't try to send an empty action.
+ if (!aMsg || !aMsg.trim().length) {
+ return false;
+ }
+
+ let conv = getConv(aConv);
+
+ conv.sendMsg(aMsg, true);
+
+ return true;
+}
+
+// This will open the conversation, and send and display the text.
+// aReturnedConv is optional and returns the resulting conversation.
+// aIsNotice is optional and sends a NOTICE instead of a PRIVMSG.
+function privateMessage(aConv, aMsg, aNickname, aReturnedConv, aIsNotice) {
+ if (!aMsg.length) {
+ return false;
+ }
+
+ let conv = getAccount(aConv).getConversation(aNickname);
+ conv.sendMsg(aMsg, false, aIsNotice);
+ if (aReturnedConv) {
+ aReturnedConv.value = conv;
+ }
+ return true;
+}
+
+// This will send a command to the server, if no parameters are given, it is
+// assumed that the command takes no parameters. aParams can be either a single
+// string or an array of parameters.
+function simpleCommand(aConv, aCommand, aParams) {
+ if (!aParams || !aParams.length) {
+ getAccount(aConv).sendMessage(aCommand);
+ } else {
+ getAccount(aConv).sendMessage(aCommand, aParams);
+ }
+ return true;
+}
+
+// Sends a CTCP message to aTarget using the CTCP command aCommand and aMsg as
+// a CTCP parameter.
+function ctcpCommand(aConv, aTarget, aCommand, aParams) {
+ return getAccount(aConv).sendCTCPMessage(aTarget, false, aCommand, aParams);
+}
+
+// Replace the command name in the help string so translators do not attempt to
+// translate it.
+export var commands = [
+ {
+ name: "action",
+ get helpString() {
+ return lazy._("command.action", "action");
+ },
+ run: actionCommand,
+ },
+ {
+ name: "ban",
+ get helpString() {
+ return lazy._("command.ban", "ban");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: (aMsg, aConv) => setMode(aMsg, aConv, "b", true),
+ },
+ {
+ name: "ctcp",
+ get helpString() {
+ return lazy._("command.ctcp", "ctcp");
+ },
+ run(aMsg, aConv) {
+ let separator = aMsg.indexOf(" ");
+ // Ensure we have two non-empty parameters.
+ if (separator < 1 || separator + 1 == aMsg.length) {
+ return false;
+ }
+
+ // The first word is used as the target, the rest is used as CTCP command
+ // and parameters.
+ ctcpCommand(aConv, aMsg.slice(0, separator), aMsg.slice(separator + 1));
+ return true;
+ },
+ },
+ {
+ name: "chanserv",
+ get helpString() {
+ return lazy._("command.chanserv", "chanserv");
+ },
+ run: (aMsg, aConv) => privateMessage(aConv, aMsg, "ChanServ"),
+ },
+ {
+ name: "deop",
+ get helpString() {
+ return lazy._("command.deop", "deop");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: (aMsg, aConv) => setMode(aMsg, aConv, "o", false),
+ },
+ {
+ name: "devoice",
+ get helpString() {
+ return lazy._("command.devoice", "devoice");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: (aMsg, aConv) => setMode(aMsg, aConv, "v", false),
+ },
+ {
+ name: "invite",
+ get helpString() {
+ return lazy._("command.invite2", "invite");
+ },
+ run(aMsg, aConv) {
+ let params = splitInput(aMsg);
+
+ // Try to find one, and only one, channel in the list of parameters.
+ let channel;
+ let account = getAccount(aConv);
+ // Find the first param that could be a channel name.
+ for (let i = 0; i < params.length; ++i) {
+ if (account.isMUCName(params[i])) {
+ // If channel is set, two channel names have been found.
+ if (channel) {
+ return false;
+ }
+
+ // Remove that parameter and store it.
+ channel = params.splice(i, 1)[0];
+ }
+ }
+
+ // If no parameters or only a channel are given.
+ if (!params[0].length) {
+ return false;
+ }
+
+ // Default to using the current conversation as the channel to invite to.
+ if (!channel) {
+ channel = aConv.name;
+ }
+
+ params.forEach(p => simpleCommand(aConv, "INVITE", [p, channel]));
+ return true;
+ },
+ },
+ {
+ name: "join",
+ get helpString() {
+ return lazy._("command.join", "join");
+ },
+ run(aMsg, aConv, aReturnedConv) {
+ let params = aMsg.trim().split(/,\s*/);
+ let account = getAccount(aConv);
+ let conv;
+ if (!params[0]) {
+ conv = getConv(aConv);
+ if (!conv.isChat || !conv.left) {
+ return false;
+ }
+ // Rejoin the current channel. If the channel was explicitly parted
+ // by the user, chatRoomFields will have been deleted.
+ // Otherwise, make use of it (e.g. if the user was kicked).
+ if (conv.chatRoomFields) {
+ account.joinChat(conv.chatRoomFields);
+ return true;
+ }
+ params = [conv.name];
+ }
+ params.forEach(function (joinParam) {
+ if (joinParam) {
+ let chatroomfields = account.getChatRoomDefaultFieldValues(joinParam);
+ conv = account.joinChat(chatroomfields);
+ }
+ });
+ if (aReturnedConv) {
+ aReturnedConv.value = conv;
+ }
+ return true;
+ },
+ },
+ {
+ name: "kick",
+ get helpString() {
+ return lazy._("command.kick", "kick");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: kickCommand,
+ },
+ {
+ name: "list",
+ get helpString() {
+ return lazy._("command.list", "list");
+ },
+ run(aMsg, aConv, aReturnedConv) {
+ let account = getAccount(aConv);
+ let serverName = account._currentServerName;
+ let serverConv = account.getConversation(serverName);
+ let pendingChats = [];
+ account.requestRoomInfo(
+ {
+ onRoomInfoAvailable(aRooms) {
+ if (!pendingChats.length) {
+ (async function () {
+ // pendingChats has no rooms added yet, so ensure we wait a tick.
+ let t = 0;
+ const kMaxBlockTime = 10; // Unblock every 10ms.
+ do {
+ if (Date.now() > t) {
+ await new Promise(resolve =>
+ Services.tm.dispatchToMainThread(resolve)
+ );
+ t = Date.now() + kMaxBlockTime;
+ }
+ let name = pendingChats.pop();
+ let roomInfo = account.getRoomInfo(name);
+ serverConv.writeMessage(
+ serverName,
+ name +
+ " (" +
+ roomInfo.participantCount +
+ ") " +
+ roomInfo.topic,
+ {
+ incoming: true,
+ noLog: true,
+ }
+ );
+ } while (pendingChats.length);
+ })();
+ }
+ pendingChats = pendingChats.concat(aRooms);
+ },
+ },
+ true
+ );
+ if (aReturnedConv) {
+ aReturnedConv.value = serverConv;
+ }
+ return true;
+ },
+ },
+ {
+ name: "me",
+ get helpString() {
+ return lazy._("command.action", "me");
+ },
+ run: actionCommand,
+ },
+ {
+ name: "memoserv",
+ get helpString() {
+ return lazy._("command.memoserv", "memoserv");
+ },
+ run: (aMsg, aConv) => privateMessage(aConv, aMsg, "MemoServ"),
+ },
+ {
+ name: "mode",
+ get helpString() {
+ return (
+ lazy._("command.modeUser2", "mode") +
+ "\n" +
+ lazy._("command.modeChannel2", "mode")
+ );
+ },
+ run(aMsg, aConv) {
+ function isMode(aString) {
+ return "+-".includes(aString[0]);
+ }
+ let params = splitInput(aMsg);
+ let channel = aConv.name;
+ // Add the channel as parameter when the target is not specified. i.e
+ // 1. message is empty.
+ // 2. the first parameter is a mode.
+ if (!aMsg) {
+ params = [channel];
+ } else if (isMode(params[0])) {
+ params.unshift(channel);
+ }
+
+ // Ensure mode string to be the second argument.
+ if (params.length >= 2 && !isMode(params[1])) {
+ return false;
+ }
+
+ return simpleCommand(aConv, "MODE", params);
+ },
+ },
+ {
+ name: "msg",
+ get helpString() {
+ return lazy._("command.msg", "msg");
+ },
+ run: messageCommand,
+ },
+ {
+ name: "nick",
+ get helpString() {
+ return lazy._("command.nick", "nick");
+ },
+ run(aMsg, aConv) {
+ let newNick = aMsg.trim();
+ // eslint-disable-next-line mozilla/use-includes-instead-of-indexOf
+ if (newNick.indexOf(/\s+/) != -1) {
+ return false;
+ }
+
+ let account = getAccount(aConv);
+ // The user wants to change their nick, so overwrite the account
+ // nickname for this session.
+ account._requestedNickname = newNick;
+ account.changeNick(newNick);
+
+ return true;
+ },
+ },
+ {
+ name: "nickserv",
+ get helpString() {
+ return lazy._("command.nickserv", "nickserv");
+ },
+ run: (aMsg, aConv) => privateMessage(aConv, aMsg, "NickServ"),
+ },
+ {
+ name: "notice",
+ get helpString() {
+ return lazy._("command.notice", "notice");
+ },
+ run: (aMsg, aConv, aReturnedConv) =>
+ messageCommand(aMsg, aConv, aReturnedConv, true),
+ },
+ {
+ name: "op",
+ get helpString() {
+ return lazy._("command.op", "op");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: (aMsg, aConv) => setMode(aMsg, aConv, "o", true),
+ },
+ {
+ name: "operserv",
+ get helpString() {
+ return lazy._("command.operserv", "operserv");
+ },
+ run: (aMsg, aConv) => privateMessage(aConv, aMsg, "OperServ"),
+ },
+ {
+ name: "part",
+ get helpString() {
+ return lazy._("command.part", "part");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ getConv(aConv).part(aMsg);
+ return true;
+ },
+ },
+ {
+ name: "ping",
+ get helpString() {
+ return lazy._("command.ping", "ping");
+ },
+ run(aMsg, aConv) {
+ // Send a ping to the entered nick using the current time (in
+ // milliseconds) as the param. If no nick is entered, ping the
+ // server.
+ if (aMsg && aMsg.trim().length) {
+ ctcpCommand(aConv, aMsg, "PING", Date.now());
+ } else {
+ getAccount(aConv).sendMessage("PING", Date.now());
+ }
+
+ return true;
+ },
+ },
+ {
+ name: "query",
+ get helpString() {
+ return lazy._("command.msg", "query");
+ },
+ run: messageCommand,
+ },
+ {
+ name: "quit",
+ get helpString() {
+ return lazy._("command.quit", "quit");
+ },
+ run(aMsg, aConv) {
+ let account = getAccount(aConv);
+ account.disconnect(aMsg);
+ // While prpls shouldn't usually touch imAccount, this disconnection
+ // is an action the user requested via the UI. Without this call,
+ // the imAccount would immediately reconnect the account.
+ account.imAccount.disconnect();
+ return true;
+ },
+ },
+ {
+ name: "quote",
+ get helpString() {
+ return lazy._("command.quote", "quote");
+ },
+ run(aMsg, aConv) {
+ if (!aMsg.length) {
+ return false;
+ }
+
+ getAccount(aConv).sendRawMessage(aMsg);
+ return true;
+ },
+ },
+ {
+ name: "remove",
+ get helpString() {
+ return lazy._("command.kick", "remove");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: kickCommand,
+ },
+ {
+ name: "time",
+ get helpString() {
+ return lazy._("command.time", "time");
+ },
+ run(aMsg, aConv) {
+ // Send a time command to the entered nick using the current time (in
+ // milliseconds) as the param. If no nick is entered, get the current
+ // server time.
+ if (aMsg && aMsg.trim().length) {
+ ctcpCommand(aConv, aMsg, "TIME");
+ } else {
+ getAccount(aConv).sendMessage("TIME");
+ }
+
+ return true;
+ },
+ },
+ {
+ name: "topic",
+ get helpString() {
+ return lazy._("command.topic", "topic");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ aConv.topic = aMsg;
+ return true;
+ },
+ },
+ {
+ name: "umode",
+ get helpString() {
+ return lazy._("command.umode", "umode");
+ },
+ run(aMsg, aConv) {
+ let params = aMsg ? splitInput(aMsg) : [];
+ params.unshift(getAccount(aConv)._nickname);
+ return simpleCommand(aConv, "MODE", params);
+ },
+ },
+ {
+ name: "version",
+ get helpString() {
+ return lazy._("command.version", "version");
+ },
+ run(aMsg, aConv) {
+ if (!aMsg || !aMsg.trim().length) {
+ return false;
+ }
+ ctcpCommand(aConv, aMsg, "VERSION");
+ return true;
+ },
+ },
+ {
+ name: "voice",
+ get helpString() {
+ return lazy._("command.voice", "voice");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: (aMsg, aConv) => setMode(aMsg, aConv, "v", true),
+ },
+ {
+ name: "whois",
+ get helpString() {
+ return lazy._("command.whois2", "whois");
+ },
+ run(aMsg, aConv) {
+ // Note that this will automatically run whowas if the nick is offline.
+ aMsg = aMsg.trim();
+ // If multiple parameters are given, this is an error.
+ if (aMsg.includes(" ")) {
+ return false;
+ }
+ // If the user does not provide a nick, but is in a private conversation,
+ // assume the user is trying to whois the person they are talking to.
+ if (!aMsg) {
+ if (aConv.isChat) {
+ return false;
+ }
+ aMsg = aConv.name;
+ }
+ getConv(aConv).requestCurrentWhois(aMsg);
+ return true;
+ },
+ },
+];
diff --git a/comm/chat/protocols/irc/ircDCC.sys.mjs b/comm/chat/protocols/irc/ircDCC.sys.mjs
new file mode 100644
index 0000000000..afd88f52be
--- /dev/null
+++ b/comm/chat/protocols/irc/ircDCC.sys.mjs
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This contains an implementation of the Direct Client-to-Client (DCC)
+ * protocol.
+ * A description of the DCC protocol
+ * http://www.irchelp.org/irchelp/rfc/dccspec.html
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+// Parse a CTCP message into a DCC message. A DCC message is a CTCP message of
+// the form:
+// DCC <type> <argument> <address> <port> [<size>]
+function DCCMessage(aMessage, aAccount) {
+ let message = aMessage;
+ let params = message.ctcp.param.split(" ");
+ if (params.length < 4) {
+ aAccount.ERROR("Not enough DCC parameters:\n" + JSON.stringify(aMessage));
+ return null;
+ }
+
+ try {
+ // Address, port and size should be treated as unsigned long, unsigned short
+ // and unsigned long, respectively. The protocol is designed to handle
+ // further arguments, if necessary.
+ message.ctcp.dcc = {
+ type: params[0],
+ argument: params[1],
+ address: Number(params[2]),
+ port: Number(params[3]),
+ size: params.length == 5 ? Number(params[4]) : null,
+ furtherArguments: params.length > 5 ? params.slice(5) : [],
+ };
+ } catch (e) {
+ aAccount.ERROR(
+ "Error parsing DCC parameters:\n" + JSON.stringify(aMessage)
+ );
+ return null;
+ }
+
+ return message;
+}
+
+// This is the DCC handler for CTCP, it will call each DCC handler.
+export var ctcpDCC = {
+ name: "DCC",
+ // Slightly above default CTCP priority.
+ priority: ircHandlerPriorities.HIGH_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ // Handle a DCC message by parsing the message and executing any handlers.
+ DCC(message, ircHandlers) {
+ // If there are no DCC handlers, then don't parse the DCC message.
+ if (!ircHandlers.hasDCCHandlers) {
+ return false;
+ }
+
+ // Parse the message and attempt to handle it.
+ return ircHandlers.handleDCCMessage(this, DCCMessage(message, this));
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircEchoMessage.sys.mjs b/comm/chat/protocols/irc/ircEchoMessage.sys.mjs
new file mode 100644
index 0000000000..24a27be902
--- /dev/null
+++ b/comm/chat/protocols/irc/ircEchoMessage.sys.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements the echo-message capability for IRC.
+ * https://ircv3.net/specs/extensions/echo-message-3.2
+ *
+ * When enabled, displaying of a sent messages is disabled (until it is received
+ * by the server and sent back to the sender). This helps to ensure the ordering
+ * of messages is consistent for all participants in a channel and also helps
+ * signify whether a message was properly sent to a channel during disconnect.
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+export var capEchoMessage = {
+ name: "echo-message CAP",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ "echo-message": function (aMessage) {
+ if (
+ aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW"
+ ) {
+ this.addCAP("echo-message");
+ this.sendMessage("CAP", ["REQ", "echo-message"]);
+ } else if (
+ aMessage.cap.subcommand === "ACK" ||
+ aMessage.cap.subcommand === "NAK"
+ ) {
+ this.removeCAP("echo-message");
+ } else {
+ return false;
+ }
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs b/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs
new file mode 100644
index 0000000000..68d48d51b8
--- /dev/null
+++ b/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export const ircHandlerPriorities = {
+ // Some constant priorities.
+ get LOW_PRIORITY() {
+ return -100;
+ },
+ get DEFAULT_PRIORITY() {
+ return 0;
+ },
+ get HIGH_PRIORITY() {
+ return 100;
+ },
+};
diff --git a/comm/chat/protocols/irc/ircHandlers.sys.mjs b/comm/chat/protocols/irc/ircHandlers.sys.mjs
new file mode 100644
index 0000000000..c461d158db
--- /dev/null
+++ b/comm/chat/protocols/irc/ircHandlers.sys.mjs
@@ -0,0 +1,306 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ircBase } from "resource:///modules/ircBase.sys.mjs";
+import {
+ ircISUPPORT,
+ isupportBase,
+} from "resource:///modules/ircISUPPORT.sys.mjs";
+import { ircCAP, capNotify } from "resource:///modules/ircCAP.sys.mjs";
+import { ircCTCP, ctcpBase } from "resource:///modules/ircCTCP.sys.mjs";
+import {
+ ircServices,
+ servicesBase,
+} from "resource:///modules/ircServices.sys.mjs";
+import { ctcpDCC } from "resource:///modules/ircDCC.sys.mjs";
+import { capEchoMessage } from "resource:///modules/ircEchoMessage.sys.mjs";
+import {
+ isupportNAMESX,
+ capMultiPrefix,
+} from "resource:///modules/ircMultiPrefix.sys.mjs";
+import { ircNonStandard } from "resource:///modules/ircNonStandard.sys.mjs";
+import {
+ ircWATCH,
+ isupportWATCH,
+ ircMONITOR,
+ isupportMONITOR,
+} from "resource:///modules/ircWatchMonitor.sys.mjs";
+import { ircSASL, capSASL } from "resource:///modules/ircSASL.sys.mjs";
+import {
+ capServerTime,
+ tagServerTime,
+} from "resource:///modules/ircServerTime.sys.mjs";
+
+export var ircHandlers = {
+ /*
+ * Object to hold the IRC handlers, each handler is an object that implements:
+ * name The display name of the handler.
+ * priority The priority of the handler (0 is default, positive is
+ * higher priority)
+ * isEnabled A function where 'this' is bound to the account object. This
+ * should reflect whether this handler should be used for this
+ * account.
+ * commands An object of commands, each command is a function which
+ * accepts a message object and has 'this' bound to the account
+ * object. It should return whether the message was successfully
+ * handler or not.
+ */
+ _ircHandlers: [
+ // High priority
+ ircCTCP,
+ ircServices,
+ // Default priority + 10
+ ircCAP,
+ ircISUPPORT,
+ ircWATCH,
+ ircMONITOR,
+ // Default priority + 1
+ ircNonStandard,
+ // Default priority
+ ircSASL,
+ ircBase,
+ ],
+ // Object to hold the ISUPPORT handlers, expects the same fields as
+ // _ircHandlers.
+ _isupportHandlers: [
+ // Default priority + 10
+ isupportNAMESX,
+ isupportWATCH,
+ isupportMONITOR,
+ // Default priority
+ isupportBase,
+ ],
+ // Object to hold the Client Capabilities handlers, expects the same fields as
+ // _ircHandlers.
+ _capHandlers: [
+ // High priority
+ capMultiPrefix,
+ // Default priority
+ capNotify,
+ capEchoMessage,
+ capSASL,
+ capServerTime,
+ ],
+ // Object to hold the CTCP handlers, expects the same fields as _ircHandlers.
+ _ctcpHandlers: [
+ // High priority + 10
+ ctcpDCC,
+ // Default priority
+ ctcpBase,
+ ],
+ // Object to hold the DCC handlers, expects the same fields as _ircHandlers.
+ _dccHandlers: [],
+ // Object to hold the Services handlers, expects the same fields as
+ // _ircHandlers.
+ _servicesHandlers: [servicesBase],
+ // Object to hold irc message tag handlers, expects the same fields as
+ // _ircHandlers.
+ _tagHandlers: [tagServerTime],
+
+ _registerHandler(aArray, aHandler) {
+ // Protect ourselves from adding broken handlers.
+ if (!("commands" in aHandler)) {
+ console.error(
+ new Error(
+ 'IRC handlers must have a "commands" property: ' + aHandler.name
+ )
+ );
+ return false;
+ }
+ if (!("isEnabled" in aHandler)) {
+ console.error(
+ new Error(
+ 'IRC handlers must have a "isEnabled" property: ' + aHandler.name
+ )
+ );
+ return false;
+ }
+
+ aArray.push(aHandler);
+ aArray.sort((a, b) => b.priority - a.priority);
+ return true;
+ },
+
+ _unregisterHandler(aArray, aHandler) {
+ return aArray.filter(h => h.name != aHandler.name);
+ },
+
+ registerHandler(aHandler) {
+ return this._registerHandler(this._ircHandlers, aHandler);
+ },
+ unregisterHandler(aHandler) {
+ this._ircHandlers = this._unregisterHandler(this._ircHandlers, aHandler);
+ },
+
+ registerISUPPORTHandler(aHandler) {
+ return this._registerHandler(this._isupportHandlers, aHandler);
+ },
+ unregisterISUPPORTHandler(aHandler) {
+ this._isupportHandlers = this._unregisterHandler(
+ this._isupportHandlers,
+ aHandler
+ );
+ },
+
+ registerCAPHandler(aHandler) {
+ return this._registerHandler(this._capHandlers, aHandler);
+ },
+ unregisterCAPHandler(aHandler) {
+ this._capHandlers = this._unregisterHandler(this._capHandlers, aHandler);
+ },
+
+ registerCTCPHandler(aHandler) {
+ return this._registerHandler(this._ctcpHandlers, aHandler);
+ },
+ unregisterCTCPHandler(aHandler) {
+ this._ctcpHandlers = this._unregisterHandler(this._ctcpHandlers, aHandler);
+ },
+
+ registerDCCHandler(aHandler) {
+ return this._registerHandler(this._dccHandlers, aHandler);
+ },
+ unregisterDCCHandler(aHandler) {
+ this._dccHandlers = this._unregisterHandler(this._dccHandlers, aHandler);
+ },
+
+ registerServicesHandler(aHandler) {
+ return this._registerHandler(this._servicesHandlers, aHandler);
+ },
+ unregisterServicesHandler(aHandler) {
+ this._servicesHandlers = this._unregisterHandler(
+ this._servicesHandlers,
+ aHandler
+ );
+ },
+
+ registerTagHandler(aHandler) {
+ return this._registerHandler(this._tagHandlers, aHandler);
+ },
+ unregisterTagHandler(aHandler) {
+ this._tagHandlers = this._unregisterHandler(this._tagHandlers, aHandler);
+ },
+
+ // Handle a message based on a set of handlers.
+ _handleMessage(aHandlers, aAccount, aMessage, aCommand) {
+ // Loop over each handler and run the command until one handles the message.
+ for (let handler of aHandlers) {
+ try {
+ // Attempt to execute the command, by checking if the handler has the
+ // command.
+ // Parse the command with the JavaScript account object as "this".
+ if (
+ handler.isEnabled.call(aAccount) &&
+ aCommand in handler.commands &&
+ handler.commands[aCommand].call(aAccount, aMessage, ircHandlers)
+ ) {
+ return true;
+ }
+ } catch (e) {
+ // We want to catch an error here because one of our handlers are
+ // broken, if we don't catch the error, the whole IRC plug-in will die.
+ aAccount.ERROR(
+ "Error running command " +
+ aCommand +
+ " with handler " +
+ handler.name +
+ ":\n" +
+ JSON.stringify(aMessage),
+ e
+ );
+ }
+ }
+
+ return false;
+ },
+
+ handleMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._ircHandlers,
+ aAccount,
+ aMessage,
+ aMessage.command.toUpperCase()
+ );
+ },
+
+ handleISUPPORTMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._isupportHandlers,
+ aAccount,
+ aMessage,
+ aMessage.isupport.parameter
+ );
+ },
+
+ handleCAPMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._capHandlers,
+ aAccount,
+ aMessage,
+ aMessage.cap.parameter
+ );
+ },
+
+ // aMessage is a CTCP Message, which inherits from an IRC Message.
+ handleCTCPMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._ctcpHandlers,
+ aAccount,
+ aMessage,
+ aMessage.ctcp.command
+ );
+ },
+
+ // aMessage is a DCC Message, which inherits from a CTCP Message.
+ handleDCCMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._dccHandlers,
+ aAccount,
+ aMessage,
+ aMessage.ctcp.dcc.type
+ );
+ },
+
+ // aMessage is a Services Message.
+ handleServicesMessage(aAccount, aMessage) {
+ return this._handleMessage(
+ this._servicesHandlers,
+ aAccount,
+ aMessage,
+ aMessage.serviceName
+ );
+ },
+
+ // aMessage is a Tag Message.
+ handleTag(aAccount, aMessage) {
+ return this._handleMessage(
+ this._tagHandlers,
+ aAccount,
+ aMessage,
+ aMessage.tagName
+ );
+ },
+
+ // Checking if handlers exist.
+ get hasHandlers() {
+ return this._ircHandlers.length > 0;
+ },
+ get hasISUPPORTHandlers() {
+ return this._isupportHandlers.length > 0;
+ },
+ get hasCAPHandlers() {
+ return this._capHandlers.length > 0;
+ },
+ get hasCTCPHandlers() {
+ return this._ctcpHandlers.length > 0;
+ },
+ get hasDCCHandlers() {
+ return this._dccHandlers.length > 0;
+ },
+ get hasServicesHandlers() {
+ return this._servicesHandlers.length > 0;
+ },
+ get hasTagHandlers() {
+ return this._tagHandlers.length > 0;
+ },
+};
diff --git a/comm/chat/protocols/irc/ircISUPPORT.sys.mjs b/comm/chat/protocols/irc/ircISUPPORT.sys.mjs
new file mode 100644
index 0000000000..9d2ce29afb
--- /dev/null
+++ b/comm/chat/protocols/irc/ircISUPPORT.sys.mjs
@@ -0,0 +1,246 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements the ISUPPORT parameters for the 005 numeric to allow a server
+ * to notify a client of what capabilities it supports.
+ * The 005 numeric
+ * http://www.irc.org/tech_docs/005.html
+ * RFC Drafts: IRC RPL_ISUPPORT Numeric Definition
+ * https://tools.ietf.org/html/draft-brocklesby-irc-isupport-03
+ * https://tools.ietf.org/html/draft-hardy-irc-isupport-00
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+/*
+ * Parses an ircMessage into an ISUPPORT message for each token of the form:
+ * <parameter>=<value> or -<value>
+ * The isupport field is added to the message and it has the following fields:
+ * parameter What is being configured by this ISUPPORT token.
+ * useDefault Whether this parameter should be reset to the default value, as
+ * defined by the RFC.
+ * value The new value for the parameter.
+ */
+function isupportMessage(aMessage) {
+ // Separate the ISUPPORT parameters.
+ let tokens = aMessage.params.slice(1, -1);
+
+ let message = aMessage;
+ message.isupport = {};
+
+ return tokens.map(function (aToken) {
+ let newMessage = JSON.parse(JSON.stringify(message));
+ newMessage.isupport.useDefault = aToken[0] == "-";
+ let token = (
+ newMessage.isupport.useDefault ? aToken.slice(1) : aToken
+ ).split("=");
+ newMessage.isupport.parameter = token[0];
+ newMessage.isupport.value = token[1] || null;
+ return newMessage;
+ });
+}
+
+export var ircISUPPORT = {
+ name: "ISUPPORT",
+ // Slightly above default RFC 2812 priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ // RPL_ISUPPORT
+ // [-]<parameter>[=<value>] :are supported by this server
+ "005": function (message, ircHandlers) {
+ let messages = isupportMessage(message);
+
+ messages = messages.filter(
+ aMessage => !ircHandlers.handleISUPPORTMessage(this, aMessage)
+ );
+ if (messages.length) {
+ // Display the list of unhandled ISUPPORT messages.
+ let unhandledMessages = messages
+ .map(aMsg => aMsg.isupport.parameter)
+ .join(" ");
+ this.LOG(
+ "Unhandled ISUPPORT messages: " +
+ unhandledMessages +
+ "\nRaw message: " +
+ message.rawMessage
+ );
+ }
+
+ return true;
+ },
+ },
+};
+
+function setSimpleNumber(aAccount, aField, aMessage, aDefaultValue) {
+ let value = aMessage.isupport.value ? Number(aMessage.isupport.value) : null;
+ aAccount[aField] = value && !isNaN(value) ? value : aDefaultValue;
+ return true;
+}
+
+// Generates an expression to search for the ASCII range of a-b.
+function generateNormalize(a, b) {
+ return new RegExp(
+ "[\\x" + a.toString(16) + "-\\x" + b.toString(16) + "]",
+ "g"
+ );
+}
+
+export var isupportBase = {
+ name: "ISUPPORT",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ CASEMAPPING(aMessage) {
+ // CASEMAPPING=<mapping>
+ // Allows the server to specify which method it uses to compare equality
+ // of case-insensitive strings.
+
+ // By default, use rfc1459 type case mapping.
+ let value = aMessage.isupport.useDefault
+ ? "rfc1493"
+ : aMessage.isupport.value;
+
+ // Set the normalize function of the account to use the proper case
+ // mapping.
+ if (value == "ascii") {
+ // The ASCII characters 97 to 122 (decimal) are the lower-case
+ // characters of ASCII 65 to 90 (decimal).
+ this.normalizeExpression = generateNormalize(65, 90);
+ } else if (value == "rfc1493") {
+ // The ASCII characters 97 to 126 (decimal) are the lower-case
+ // characters of ASCII 65 to 94 (decimal).
+ this.normalizeExpression = generateNormalize(65, 94);
+ } else if (value == "strict-rfc1459") {
+ // The ASCII characters 97 to 125 (decimal) are the lower-case
+ // characters of ASCII 65 to 93 (decimal).
+ this.normalizeExpression = generateNormalize(65, 93);
+ }
+ return true;
+ },
+ CHANLIMIT(aMessage) {
+ // CHANLIMIT=<prefix>:<number>[,<prefix>:<number>]*
+ // Note that each <prefix> can actually contain multiple prefixes, this
+ // means the sum of those prefixes is given.
+ this.maxChannels = {};
+
+ let pairs = aMessage.isupport.value.split(",");
+ for (let pair of pairs) {
+ let [prefix, num] = pair.split(":");
+ this.maxChannels[prefix] = num;
+ }
+ return true;
+ },
+ CHANMODES: aMessage => false,
+ CHANNELLEN(aMessage) {
+ // CHANNELLEN=<number>
+ // Default is from RFC 1493.
+ return setSimpleNumber(this, "maxChannelLength", aMessage, 200);
+ },
+ CHANTYPES(aMessage) {
+ // CHANTYPES=[<channel prefix>]*
+ let value = aMessage.isupport.useDefault ? "#&" : aMessage.isupport.value;
+ this.channelPrefixes = value.split("");
+ return true;
+ },
+ EXCEPTS: aMessage => false,
+ IDCHAN: aMessage => false,
+ INVEX: aMessage => false,
+ KICKLEN(aMessage) {
+ // KICKLEN=<number>
+ // Default value is Infinity.
+ return setSimpleNumber(this, "maxKickLength", aMessage, Infinity);
+ },
+ MAXLIST: aMessage => false,
+ MODES: aMessage => false,
+ NETWORK: aMessage => false,
+ NICKLEN(aMessage) {
+ // NICKLEN=<number>
+ // Default value is from RFC 1493.
+ return setSimpleNumber(this, "maxNicknameLength", aMessage, 9);
+ },
+ PREFIX(aMessage) {
+ // PREFIX=[(<mode character>*)<prefix>*]
+ let value = aMessage.isupport.useDefault
+ ? "(ov)@+"
+ : aMessage.isupport.value;
+
+ this.userPrefixToModeMap = {};
+ // A null value specifier indicates that no prefixes are supported.
+ if (!value.length) {
+ return true;
+ }
+
+ let matches = /\(([a-z]*)\)(.*)/i.exec(value);
+ if (!matches) {
+ // The pattern doesn't match.
+ this.WARN("Invalid PREFIX value: " + value);
+ return false;
+ }
+ if (matches[1].length != matches[2].length) {
+ this.WARN(
+ "Invalid PREFIX value, does not provide one-to-one mapping:" + value
+ );
+ return false;
+ }
+
+ for (let i = 0; i < matches[2].length; i++) {
+ this.userPrefixToModeMap[matches[2][i]] = matches[1][i];
+ }
+ return true;
+ },
+ // SAFELIST allows the client to request the server buffer LIST responses to
+ // avoid flooding the client. This is not an issue for us, so just ignore
+ // it.
+ SAFELIST: aMessage => true,
+ // SECURELIST tells us that the server won't send LIST data directly after
+ // connection. Unfortunately, the exact time the client has to wait is
+ // configurable, so we can't do anything with this information.
+ SECURELIST: aMessage => true,
+ STATUSMSG: aMessage => false,
+ STD(aMessage) {
+ // This was never updated as the RFC was never formalized.
+ if (aMessage.isupport.value != "rfcnnnn") {
+ this.WARN("Unknown ISUPPORT numeric form: " + aMessage.isupport.value);
+ }
+ return true;
+ },
+ TARGMAX(aMessage) {
+ // TARGMAX=<command>:<max targets>[,<command>:<max targets>]*
+ if (aMessage.isupport.useDefault) {
+ this.maxTargets = 1;
+ return true;
+ }
+
+ this.maxTargets = {};
+ let commands = aMessage.isupport.value.split(",");
+ for (let i = 0; i < commands.length; i++) {
+ let [command, limitStr] = commands[i].split("=");
+ let limit = limitStr ? Number(limit) : Infinity;
+ if (isNaN(limit)) {
+ this.WARN("Invalid maximum number of targets: " + limitStr);
+ continue;
+ }
+ this.maxTargets[command] = limit;
+ }
+ return true;
+ },
+ TOPICLEN(aMessage) {
+ // TOPICLEN=<number>
+ // Default value is Infinity.
+ return setSimpleNumber(this, "maxTopicLength", aMessage, Infinity);
+ },
+
+ // The following are considered "obsolete" by the RFC, but are still in use.
+ CHARSET: aMessage => false,
+ MAXBANS: aMessage => false,
+ MAXCHANNELS: aMessage => false,
+ MAXTARGETS(aMessage) {
+ return setSimpleNumber(this, "maxTargets", aMessage, 1);
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs b/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs
new file mode 100644
index 0000000000..abf9727981
--- /dev/null
+++ b/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This contains an implementation of the multi-prefix IRC extension. This fixes
+ * a protocol level bug where the following can happen:
+ * foo MODE +h
+ * foo MODE +o
+ * bar JOINs the channel (and receives @foo)
+ * foo MODE -o
+ * foo knows that it has mode +h, but bar does not know foo has +h set.
+ *
+ * https://docs.inspircd.org/2/modules/namesx/
+ * https://ircv3.net/specs/extensions/multi-prefix-3.1
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+export var isupportNAMESX = {
+ name: "ISUPPORT NAMESX",
+ // Slightly above default ISUPPORT priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ NAMESX(aMessage) {
+ this.sendMessage("PROTOCTL", "NAMESX");
+ return true;
+ },
+ },
+};
+
+export var capMultiPrefix = {
+ name: "CAP multi-prefix",
+ // Slightly above default ISUPPORT priority.
+ priority: ircHandlerPriorities.HIGH_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ "multi-prefix": function (aMessage) {
+ // Request to use multi-prefix if it is supported.
+ if (
+ aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW"
+ ) {
+ this.addCAP("multi-prefix");
+ this.sendMessage("CAP", ["REQ", "multi-prefix"]);
+ } else if (
+ aMessage.cap.subcommand === "ACK" ||
+ aMessage.cap.subcommand === "NAK"
+ ) {
+ this.removeCAP("multi-prefix");
+ } else {
+ return false;
+ }
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircNonStandard.sys.mjs b/comm/chat/protocols/irc/ircNonStandard.sys.mjs
new file mode 100644
index 0000000000..aeb373feb9
--- /dev/null
+++ b/comm/chat/protocols/irc/ircNonStandard.sys.mjs
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * There are a variety of non-standard extensions to IRC that are implemented by
+ * different servers. This implementation is based on a combination of
+ * documentation and reverse engineering. Each handler must include a comment
+ * listing the known servers that support this extension.
+ *
+ * Resources for these commands include:
+ * https://github.com/atheme/charybdis/blob/master/include/numeric.h
+ * https://github.com/unrealircd/unrealircd/blob/unreal42/include/numeric.h
+ */
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+import {
+ conversationErrorMessage,
+ kListRefreshInterval,
+} from "resource:///modules/ircUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+export var ircNonStandard = {
+ name: "Non-Standard IRC Extensions",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 1,
+ isEnabled: () => true,
+
+ commands: {
+ NOTICE(aMessage) {
+ // NOTICE <msgtarget> <text>
+
+ if (
+ aMessage.params[1].startsWith("*** You cannot list within the first")
+ ) {
+ // SECURELIST: "You cannot list within the first N seconds of connecting.
+ // Please try again later." This NOTICE will be followed by a 321/323
+ // pair, but no list data.
+ // We fake the last LIST time so that we will retry LIST the next time
+ // the user requires it after the interval specified.
+ const kMinute = 60000;
+ let waitTime = aMessage.params[1].split(" ")[7] * 1000 || kMinute;
+ this._lastListTime = Date.now() + waitTime - kListRefreshInterval;
+ return true;
+ }
+
+ // If the user is connected, fallback to normal processing, everything
+ // past this points deals with NOTICE messages that occur before 001 is
+ // received.
+ if (this.connected) {
+ return false;
+ }
+
+ let target = aMessage.params[0].toLowerCase();
+
+ // If we receive a ZNC error message requesting a password, the
+ // serverPassword preference was not set by the user. Attempt to log into
+ // ZNC using the account password.
+ if (
+ target == "auth" &&
+ aMessage.params[1].startsWith("*** You need to send your password.")
+ ) {
+ if (this.imAccount.password) {
+ // Send the password now, if it is available.
+ this.shouldAuthenticate = false;
+ this.sendMessage(
+ "PASS",
+ this.imAccount.password,
+ "PASS <password not logged>"
+ );
+ } else {
+ // Otherwise, put the account in an error state.
+ this.gotDisconnected(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.passwordRequired")
+ );
+ }
+
+ // All done for ZNC.
+ return true;
+ }
+
+ // Some servers, e.g. irc.umich.edu, use NOTICE during connection
+ // negotiation to give directions to users, these MUST be shown to the
+ // user. If the message starts with ***, we assume it is probably an AUTH
+ // message, which falls through to normal NOTICE processing.
+ // Note that if the user's nick is auth this COULD be a notice directed at
+ // them. For reference: moznet sends Auth (previously sent AUTH), freenode
+ // sends *.
+ let isAuth = target == "auth" && this._nickname.toLowerCase() != "auth";
+ if (!aMessage.params[1].startsWith("***") && !isAuth) {
+ this.getConversation(aMessage.origin).writeMessage(
+ aMessage.origin,
+ aMessage.params[1],
+ {
+ incoming: true,
+ tags: aMessage.tags,
+ }
+ );
+ return true;
+ }
+
+ return false;
+ },
+
+ "042": function (aMessage) {
+ // RPL_YOURID (IRCnet)
+ // <nick> <id> :your unique ID
+ return true;
+ },
+
+ 307(aMessage) {
+ // TODO RPL_SUSERHOST (AustHex)
+ // TODO RPL_USERIP (Undernet)
+ // <user ips>
+
+ // RPL_WHOISREGNICK (Unreal & Bahamut)
+ // <nick> :is a registered nick
+ if (aMessage.params.length == 3) {
+ return this.setWhois(aMessage.params[1], { registered: true });
+ }
+
+ return false;
+ },
+
+ 317(aMessage) {
+ // RPL_WHOISIDLE (Unreal & Charybdis)
+ // <nick> <integer> <integer> :seconds idle, signon time
+ // This is a non-standard extension to RPL_WHOISIDLE which includes the
+ // sign-on time.
+ if (aMessage.params.length == 5) {
+ this.setWhois(aMessage.params[1], { signonTime: aMessage.params[3] });
+ }
+
+ return false;
+ },
+
+ 328(aMessage) {
+ // RPL_CHANNEL_URL (Bahamut & Austhex)
+ // <channel> :<URL>
+ return true;
+ },
+
+ 329(aMessage) {
+ // RPL_CREATIONTIME (Bahamut & Unreal)
+ // <channel> <creation time>
+ return true;
+ },
+
+ 330(aMessage) {
+ // TODO RPL_WHOWAS_TIME
+
+ // RPL_WHOISACCOUNT (Charybdis, ircu & Quakenet)
+ // <nick> <authname> :is logged in as
+ if (aMessage.params.length == 4) {
+ let [, nick, authname] = aMessage.params;
+ // If the authname differs from the nickname, add it to the WHOIS
+ // information; otherwise, ignore it.
+ if (this.normalize(nick) != this.normalize(authname)) {
+ this.setWhois(nick, { registeredAs: authname });
+ }
+ }
+ return true;
+ },
+
+ 335(aMessage) {
+ // RPL_WHOISBOT (Unreal)
+ // <nick> :is a \002Bot\002 on <network>
+ return this.setWhois(aMessage.params[1], { bot: true });
+ },
+
+ 338(aMessage) {
+ // RPL_CHANPASSOK
+ // RPL_WHOISACTUALLY (ircu, Bahamut, Charybdis)
+ // <nick> <user> <ip> :actually using host
+ return true;
+ },
+
+ 378(aMessage) {
+ // RPL_WHOISHOST (Unreal & Charybdis)
+ // <nick> :is connecting from <host> <ip>
+ let [host, ip] = aMessage.params[2].split(" ").slice(-2);
+ return this.setWhois(aMessage.params[1], { host, ip });
+ },
+
+ 379(aMessage) {
+ // RPL_WHOISMODES (Unreal, Inspircd)
+ // <nick> :is using modes <modes>
+ // Sent in response to a WHOIS on the user.
+ return true;
+ },
+
+ 396(aMessage) {
+ // RPL_HOSTHIDDEN (Charybdis, Hybrid, ircu, etc.)
+ // RPL_VISIBLEHOST (Plexus)
+ // RPL_YOURDISPLAYEDHOST (Inspircd)
+ // <host> :is now your hidden host
+
+ // This is the host that will be sent to other users.
+ this.prefix = "!" + aMessage.user + "@" + aMessage.params[1];
+ return true;
+ },
+
+ 464(aMessage) {
+ // :Password required
+ // If we receive a ZNC error message requesting a password, eat it since
+ // a NOTICE AUTH will follow causing us to send the password. This numeric
+ // is, unfortunately, also sent if you give a wrong password. The
+ // parameter in that case is "Invalid Password".
+ return (
+ aMessage.origin == "irc.znc.in" &&
+ aMessage.params[1] == "Password required"
+ );
+ },
+
+ 470(aMessage) {
+ // Channel forward (Unreal, inspircd)
+ // <requested channel> <redirect channel>: You may not join this channel,
+ // so you are automatically being transferred to the redirect channel.
+ // Join redirect channel so when the automatic join happens, we are
+ // not surprised.
+ this.joinChat(this.getChatRoomDefaultFieldValues(aMessage.params[2]));
+ // Mark requested channel as left and add a system message.
+ return conversationErrorMessage(
+ this,
+ aMessage,
+ "error.channelForward",
+ true,
+ false
+ );
+ },
+
+ 499(aMessage) {
+ // ERR_CHANOWNPRIVNEEDED (Unreal)
+ // <channel> :You're not the channel owner (status +q is needed)
+ return conversationErrorMessage(this, aMessage, "error.notChannelOwner");
+ },
+
+ 671(aMessage) {
+ // RPL_WHOISSECURE (Unreal & Charybdis)
+ // <nick> :is using a Secure connection
+ return this.setWhois(aMessage.params[1], { secure: true });
+ },
+
+ 998(aMessage) {
+ // irc.umich.edu shows an ASCII captcha that must be typed in by the user.
+ this.getConversation(aMessage.origin).writeMessage(
+ aMessage.origin,
+ aMessage.params[1],
+ {
+ incoming: true,
+ noFormat: true,
+ }
+ );
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircSASL.sys.mjs b/comm/chat/protocols/irc/ircSASL.sys.mjs
new file mode 100644
index 0000000000..0708d0180a
--- /dev/null
+++ b/comm/chat/protocols/irc/ircSASL.sys.mjs
@@ -0,0 +1,179 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements SASL for IRC.
+ * https://raw.github.com/atheme/atheme/master/doc/SASL
+ * https://ircv3.net/specs/extensions/sasl-3.2
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+export var ircSASL = {
+ name: "SASL AUTHENTICATE",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled() {
+ return this._activeCAPs.has("sasl");
+ },
+
+ commands: {
+ AUTHENTICATE(aMessage) {
+ // Expect an empty response, if something different is received abort.
+ if (aMessage.params[0] != "+") {
+ this.sendMessage("AUTHENTICATE", "*");
+ this.WARN(
+ "Aborting SASL authentication, unexpected message " +
+ "received:\n" +
+ aMessage.rawMessage
+ );
+ return true;
+ }
+
+ // An authentication identity, authorization identity and password are
+ // used, separated by null.
+ let data = [
+ this._requestedNickname,
+ this._requestedNickname,
+ this.imAccount.password,
+ ].join("\0");
+ // btoa for Unicode, see https://developer.mozilla.org/en-US/docs/DOM/window.btoa
+ let base64Data = btoa(unescape(encodeURIComponent(data)));
+ this.sendMessage(
+ "AUTHENTICATE",
+ base64Data,
+ "AUTHENTICATE <base64 encoded nick, user and password not logged>"
+ );
+ return true;
+ },
+
+ 900(aMessage) {
+ // RPL_LOGGEDIN
+ // <nick>!<ident>@<host> <account> :You are now logged in as <user>
+ // Now logged in ("whether by SASL or otherwise").
+ this.isAuthenticated = true;
+ return true;
+ },
+
+ 901(aMessage) {
+ // RPL_LOGGEDOUT
+ // The user's account name is unset (whether by SASL or otherwise).
+ this.isAuthenticated = false;
+ return true;
+ },
+
+ 902(aMessage) {
+ // ERR_NICKLOCKED
+ // Authentication failed because the account is currently locked out,
+ // held, or otherwise administratively made unavailable.
+ this.WARN(
+ "You must use a nick assigned to you. SASL authentication failed."
+ );
+ this.removeCAP("sasl");
+ return true;
+ },
+
+ 903(aMessage) {
+ // RPL_SASLSUCCESS
+ // Authentication was successful.
+ this.isAuthenticated = true;
+ this.LOG("SASL authentication successful.");
+ // We may receive this again while already connected if the user manually
+ // identifies with Nickserv.
+ if (!this.connected) {
+ this.removeCAP("sasl");
+ }
+ return true;
+ },
+
+ 904(aMessage) {
+ // ERR_SASLFAIL
+ // Sent when the SASL authentication fails because of invalid credentials
+ // or other errors not explicitly mentioned by other numerics.
+ this.WARN("Authentication with SASL failed.");
+ this.removeCAP("sasl");
+ return true;
+ },
+
+ 905(aMessage) {
+ // ERR_SASLTOOLONG
+ // Sent when credentials are valid, but the SASL authentication fails
+ // because the client-sent `AUTHENTICATE` command was too long.
+ this.ERROR("SASL: AUTHENTICATE command was too long.");
+ this.removeCAP("sasl");
+ return true;
+ },
+
+ 906(aMessage) {
+ // ERR_SASLABORTED
+ // The client completed registration before SASL authentication completed,
+ // or because we sent `AUTHENTICATE` with `*` as the parameter.
+ //
+ // Freenode sends 906 in addition to 904, ignore 906 in this case.
+ if (this._requestedCAPs.has("sasl")) {
+ this.ERROR(
+ "Registration completed before SASL authentication completed."
+ );
+ this.removeCAP("sasl");
+ }
+ return true;
+ },
+
+ 907(aMessage) {
+ // ERR_SASLALREADY
+ // Response if client attempts to AUTHENTICATE after successful
+ // authentication.
+ this.ERROR("Attempting SASL authentication twice?!");
+ this.removeCAP("sasl");
+ return true;
+ },
+
+ 908(aMessage) {
+ // RPL_SASLMECHS
+ // <nick> <mechanisms> :are available SASL mechanisms
+ // List of SASL mechanisms supported by the server (or network, services).
+ // The numeric contains a comma-separated list of mechanisms.
+ return false;
+ },
+ },
+};
+
+export var capSASL = {
+ name: "SASL CAP",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ sasl(aMessage) {
+ // Return early if we are already authenticated (can happen due to cap-notify)
+ if (this.isAuthenticated) {
+ return true;
+ }
+
+ if (
+ (aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW") &&
+ this.imAccount.password
+ ) {
+ if (aMessage.cap.value) {
+ const mechanisms = aMessage.cap.value.split(",");
+ // We only support the plain authentication mechanism for now, abort if it's not available.
+ if (!mechanisms.includes("PLAIN")) {
+ return true;
+ }
+ }
+ // If it supports SASL, let the server know we're requiring SASL.
+ this.addCAP("sasl");
+ this.sendMessage("CAP", ["REQ", "sasl"]);
+ } else if (aMessage.cap.subcommand === "ACK") {
+ // The server acknowledges our choice to use SASL, send the first
+ // message.
+ this.sendMessage("AUTHENTICATE", "PLAIN");
+ } else if (aMessage.cap.subcommand === "NAK") {
+ this.removeCAP("sasl");
+ }
+
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircServerTime.sys.mjs b/comm/chat/protocols/irc/ircServerTime.sys.mjs
new file mode 100644
index 0000000000..14ce2436f3
--- /dev/null
+++ b/comm/chat/protocols/irc/ircServerTime.sys.mjs
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements server-time for IRC.
+ * https://ircv3.net/specs/extensions/server-time-3.2
+ */
+
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+function handleServerTimeTag(aMsg) {
+ if (aMsg.tagValue) {
+ // Normalize leap seconds to the next second before it.
+ const time = aMsg.tagValue.replace(/60.\d{3}(?=Z$)/, "59.999");
+ aMsg.message.time = Math.floor(Date.parse(time) / 1000);
+ aMsg.message.delayed = true;
+ }
+}
+
+export var tagServerTime = {
+ name: "server-time Tags",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled() {
+ return (
+ this._activeCAPs.has("server-time") ||
+ this._activeCAPs.has("znc.in/server-time-iso")
+ );
+ },
+
+ commands: {
+ time: handleServerTimeTag,
+ "znc.in/server-time-iso": handleServerTimeTag,
+ },
+};
+
+export var capServerTime = {
+ name: "server-time CAP",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ "server-time": function (aMessage) {
+ if (
+ aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW"
+ ) {
+ this.addCAP("server-time");
+ this.sendMessage("CAP", ["REQ", "server-time"]);
+ } else if (
+ aMessage.cap.subcommand === "ACK" ||
+ aMessage.cap.subcommand === "NAK"
+ ) {
+ this.removeCAP("server-time");
+ } else {
+ return false;
+ }
+ return true;
+ },
+ "znc.in/server-time-iso": function (aMessage) {
+ // Only request legacy server time CAP if the standard one is not available.
+ if (
+ (aMessage.cap.subcommand === "LS" ||
+ aMessage.cap.subcommand === "NEW") &&
+ !this._availableCAPs.has("server-time")
+ ) {
+ this.addCAP("znc.in/server-time-iso");
+ this.sendMessage("CAP", ["REQ", "znc.in/server-time-iso"]);
+ } else if (
+ aMessage.cap.subcommand === "ACK" ||
+ aMessage.cap.subcommand === "NAK"
+ ) {
+ this.removeCAP("znc.in/server-time-iso");
+ } else {
+ return false;
+ }
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircServices.sys.mjs b/comm/chat/protocols/irc/ircServices.sys.mjs
new file mode 100644
index 0000000000..4f39bda237
--- /dev/null
+++ b/comm/chat/protocols/irc/ircServices.sys.mjs
@@ -0,0 +1,317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This attempts to handle dealing with IRC services, which are a diverse set of
+ * programs to automate and add features to IRCd. Often these services are seen
+ * with the names NickServ, ChanServ, OperServ and MemoServ; but other services
+ * do exist and are in use.
+ *
+ * Since the "protocol" behind services is really just text-based, human
+ * readable messages, attempt to parse them, but always fall back to just
+ * showing the message to the user if we're unsure what to do.
+ *
+ * Anope
+ * https://www.anope.org/docgen/1.8/
+ */
+
+import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+/*
+ * If a service is found, an extra field (serviceName) is added with the
+ * "generic" service name (e.g. a bot which performs NickServ like functionality
+ * will be mapped to NickServ).
+ */
+function ServiceMessage(aAccount, aMessage) {
+ // This should be a property of the account or configurable somehow, it maps
+ // from server specific service names to our generic service names (e.g. if
+ // irc.foo.net has a service called bar, which acts as a NickServ, we would
+ // map "bar": "NickServ"). Note that the keys of this map should be
+ // normalized.
+ let nicknameToServiceName = {
+ chanserv: "ChanServ",
+ infoserv: "InfoServ",
+ nickserv: "NickServ",
+ saslserv: "SaslServ",
+ "freenode-connect": "freenode-connect",
+ };
+
+ let nickname = aAccount.normalize(aMessage.origin);
+ if (nicknameToServiceName.hasOwnProperty(nickname)) {
+ aMessage.serviceName = nicknameToServiceName[nickname];
+ }
+
+ return aMessage;
+}
+
+export var ircServices = {
+ name: "IRC Services",
+ priority: ircHandlerPriorities.HIGH_PRIORITY,
+ isEnabled: () => true,
+ sendIdentify(aAccount) {
+ if (
+ aAccount.imAccount.password &&
+ aAccount.shouldAuthenticate &&
+ !aAccount.isAuthenticated
+ ) {
+ aAccount.sendMessage(
+ "IDENTIFY",
+ aAccount.imAccount.password,
+ "IDENTIFY <password not logged>"
+ );
+ }
+ },
+
+ commands: {
+ // If we automatically reply to a NOTICE message this does not abide by RFC
+ // 2812. Oh well.
+ NOTICE(ircMessage, ircHandlers) {
+ if (!ircHandlers.hasServicesHandlers) {
+ return false;
+ }
+
+ let message = ServiceMessage(this, ircMessage);
+
+ // If no service was found, return early.
+ if (!message.hasOwnProperty("serviceName")) {
+ return false;
+ }
+
+ // If the name is recognized as a service name, add the service name field
+ // and run it through the handlers.
+ return ircHandlers.handleServicesMessage(this, message);
+ },
+
+ NICK(aMessage) {
+ let newNick = aMessage.params[0];
+ // We only auto-authenticate for the account nickname.
+ if (this.normalize(newNick) != this.normalize(this._accountNickname)) {
+ return false;
+ }
+
+ // If we're not identified already, try to identify.
+ if (!this.isAuthenticated) {
+ ircServices.sendIdentify(this);
+ }
+
+ // We always want the RFC 2812 handler to handle NICK, so return false.
+ return false;
+ },
+
+ "001": function (aMessage) {
+ // RPL_WELCOME
+ // If SASL authentication failed, attempt IDENTIFY.
+ ircServices.sendIdentify(this);
+
+ // We always want the RFC 2812 handler to handle 001, so return false.
+ return false;
+ },
+
+ 421(aMessage) {
+ // ERR_UNKNOWNCOMMAND
+ // <command> :Unknown command
+ // IDENTIFY failed, try NICKSERV IDENTIFY.
+ if (
+ aMessage.params[1] == "IDENTIFY" &&
+ this.imAccount.password &&
+ this.shouldAuthenticate &&
+ !this.isAuthenticated
+ ) {
+ this.sendMessage(
+ "NICKSERV",
+ ["IDENTIFY", this.imAccount.password],
+ "NICKSERV IDENTIFY <password not logged>"
+ );
+ return true;
+ }
+ if (aMessage.params[1] == "NICKSERV") {
+ this.WARN("NICKSERV command does not exist.");
+ return true;
+ }
+ return false;
+ },
+ },
+};
+
+export var servicesBase = {
+ name: "IRC Services",
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY,
+ isEnabled: () => true,
+
+ commands: {
+ ChanServ(aMessage) {
+ // [<channel name>] <message>
+ let channel = aMessage.params[1].split(" ", 1)[0];
+ if (!channel || channel[0] != "[" || channel.slice(-1)[0] != "]") {
+ return false;
+ }
+
+ // Remove the [ and ].
+ channel = channel.slice(1, -1);
+ // If it isn't a channel or doesn't exist, return early.
+ if (!this.isMUCName(channel) || !this.conversations.has(channel)) {
+ return false;
+ }
+
+ // Otherwise, display the message in that conversation.
+ let params = { incoming: true };
+ if (aMessage.command == "NOTICE") {
+ params.notification = true;
+ }
+
+ // The message starts after the channel name, plus [, ] and a space.
+ let message = aMessage.params[1].slice(channel.length + 3);
+ this.getConversation(channel).writeMessage(
+ aMessage.origin,
+ message,
+ params
+ );
+ return true;
+ },
+
+ InfoServ(aMessage) {
+ let text = aMessage.params[1];
+
+ // Show the message of the day in the server tab.
+ if (text == "*** \u0002Message(s) of the Day\u0002 ***") {
+ this._infoServMotd = [text];
+ return true;
+ } else if (text == "*** \u0002End of Message(s) of the Day\u0002 ***") {
+ if (this._showServerTab && this._infoServMotd) {
+ this._infoServMotd.push(text);
+ this.getConversation(aMessage.origin).writeMessage(
+ aMessage.origin,
+ this._infoServMotd.join("\n"),
+ {
+ incoming: true,
+ }
+ );
+ delete this._infoServMotd;
+ }
+ return true;
+ } else if (this.hasOwnProperty("_infoServMotd")) {
+ this._infoServMotd.push(text);
+ return true;
+ }
+
+ return false;
+ },
+
+ NickServ(message, ircHandlers) {
+ // Since we feed the messages back through the system at the end of the
+ // timeout when waiting for a log-in, we need to NOT try to handle them
+ // here and let them fall through to the default handler.
+ if (this.isHandlingQueuedMessages) {
+ return false;
+ }
+
+ let text = message.params[1];
+
+ // If we have a queue of messages, we're waiting for authentication.
+ if (this.nickservMessageQueue) {
+ if (
+ text == "Password accepted - you are now recognized." || // Anope.
+ text.startsWith("You are now identified for \x02")
+ ) {
+ // Atheme.
+ // Password successfully accepted by NickServ, don't display the
+ // queued messages.
+ this.LOG("Successfully authenticated with NickServ.");
+ this.isAuthenticated = true;
+ clearTimeout(this.nickservAuthTimeout);
+ delete this.nickservAuthTimeout;
+ delete this.nickservMessageQueue;
+ } else {
+ // Queue any other messages that occur during the timeout so they
+ // appear in the proper order.
+ this.nickservMessageQueue.push(message);
+ }
+ return true;
+ }
+
+ // NickServ wants us to identify.
+ if (
+ text == "This nick is owned by someone else. Please choose another." || // Anope.
+ text == "This nickname is registered and protected. If it is your" || // Anope (SECURE enabled).
+ text ==
+ "This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify <password>\x02."
+ ) {
+ // Atheme.
+ this.LOG("Authentication requested by NickServ.");
+
+ // Wait one second before showing the message to the user (giving the
+ // the server time to process the log-in).
+ this.nickservMessageQueue = [message];
+ this.nickservAuthTimeout = setTimeout(
+ function () {
+ this.isHandlingQueuedMessages = true;
+ this.nickservMessageQueue.every(aMessage =>
+ ircHandlers.handleMessage(this, aMessage)
+ );
+ delete this.isHandlingQueuedMessages;
+ delete this.nickservMessageQueue;
+ }.bind(this),
+ 10000
+ );
+ return true;
+ }
+
+ if (
+ !this.isAuthenticated &&
+ (text == "You are already identified." || // Anope.
+ text.startsWith("You are already logged in as \x02"))
+ ) {
+ // Atheme.
+ // Do not show the message if caused by the automatic reauthentication.
+ this.isAuthenticated = true;
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Ignore useless messages from SaslServ (unless showing of server messages
+ * is enabled).
+ *
+ * @param {object} aMessage The IRC message object.
+ * @returns {boolean} True if the message was handled, false if it should be
+ * processed by another handler.
+ */
+ SaslServ(aMessage) {
+ // If the user would like to see server messages, fall through to the
+ // standard handler.
+ if (this._showServerTab) {
+ return false;
+ }
+
+ // Only ignore the message notifying of last login.
+ let text = aMessage.params[1];
+ return text.startsWith("Last login from: ");
+ },
+
+ /*
+ * freenode sends some annoying messages on start-up from a freenode-connect
+ * bot. Only show these if the user wants to see server messages. See bug
+ * 1521761.
+ */
+ "freenode-connect": function (aMessage) {
+ // If the user would like to see server messages, fall through to the
+ // standard handler.
+ if (this._showServerTab) {
+ return false;
+ }
+
+ // Only ignore the message notifying of scanning (and include additional
+ // checking of the hostname).
+ return (
+ aMessage.host.startsWith("freenode/utility-bot/") &&
+ aMessage.params[1].includes(
+ "connections will be scanned for vulnerabilities"
+ )
+ );
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/ircUtils.sys.mjs b/comm/chat/protocols/irc/ircUtils.sys.mjs
new file mode 100644
index 0000000000..190ed8f830
--- /dev/null
+++ b/comm/chat/protocols/irc/ircUtils.sys.mjs
@@ -0,0 +1,303 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/irc.properties")
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTXT => cs.scanTXT(aTXT, cs.kEntities);
+});
+
+// The timespan after which we consider LIST roomInfo to be stale.
+export var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours.
+
+/*
+ * The supported formatting control characters, as described in
+ * http://www.invlogic.com/irc/ctcp.html#3.11
+ * If a string is given, it will replace the control character; if null is
+ * given, the current HTML tag stack will be closed; if a function is given,
+ * it expects two parameters:
+ * aStack The ordered list of open HTML tags.
+ * aInput The current input string.
+ * There are three output values returned in an array:
+ * The new ordered list of open HTML tags.
+ * The new text output to append.
+ * The number of characters (from the start of the input string) that the
+ * function handled.
+ */
+var CTCP_TAGS = {
+ "\x02": "b", // \002, ^B, Bold
+ "\x16": "i", // \026, ^V, Reverse or Inverse (Italics)
+ "\x1D": "i", // \035, ^], Italics (mIRC)
+ "\x1F": "u", // \037, ^_, Underline
+ "\x03": mIRCColoring, // \003, ^C, Coloring
+ "\x0F": null, // \017, ^O, Clear all formatting
+};
+
+// Generate an expression that will search for any of the control characters.
+var CTCP_TAGS_EXP = new RegExp("[" + Object.keys(CTCP_TAGS).join("") + "]");
+
+// Remove all CTCP formatting characters.
+export function ctcpFormatToText(aString) {
+ let next,
+ input = aString,
+ output = "",
+ length;
+
+ while ((next = CTCP_TAGS_EXP.exec(input))) {
+ if (next.index > 0) {
+ output += input.substr(0, next.index);
+ }
+ // We assume one character will be stripped.
+ length = 1;
+ let tag = CTCP_TAGS[input[next.index]];
+ // If the tag is a function, calculate how many characters are handled.
+ if (typeof tag == "function") {
+ [, , length] = tag([], input.substr(next.index));
+ }
+
+ // Avoid infinite loops.
+ length = Math.max(1, length);
+ // Skip to after the last match.
+ input = input.substr(next.index + length);
+ }
+ // Append the unmatched bits before returning the output.
+ return output + input;
+}
+
+function openStack(aStack) {
+ return aStack.map(aTag => "<" + aTag + ">").join("");
+}
+
+// Close the tags in the opposite order they were opened.
+function closeStack(aStack) {
+ return aStack
+ .reverse()
+ .map(aTag => "</" + aTag.split(" ", 1) + ">")
+ .join("");
+}
+
+/**
+ * Convert a string from CTCP escaped formatting to HTML markup.
+ *
+ * @param aString the string with CTCP formatting to parse
+ * @returns The HTML output string
+ */
+export function ctcpFormatToHTML(aString) {
+ let next,
+ stack = [],
+ input = lazy.TXTToHTML(aString),
+ output = "",
+ newOutput,
+ length;
+
+ while ((next = CTCP_TAGS_EXP.exec(input))) {
+ if (next.index > 0) {
+ output += input.substr(0, next.index);
+ }
+ length = 1;
+ let tag = CTCP_TAGS[input[next.index]];
+ if (tag === null) {
+ // Clear all formatting.
+ output += closeStack(stack);
+ stack = [];
+ } else if (typeof tag == "function") {
+ [stack, newOutput, length] = tag(stack, input.substr(next.index));
+ output += newOutput;
+ } else {
+ let offset = stack.indexOf(tag);
+ if (offset == -1) {
+ // Tag not found; open new tag.
+ output += "<" + tag + ">";
+ stack.push(tag);
+ } else {
+ // Tag found; close existing tag (and all tags after it).
+ output += closeStack(stack.slice(offset));
+ // Reopen the tags that came after it.
+ output += openStack(stack.slice(offset + 1));
+ // Remove the tag from the stack.
+ stack.splice(offset, 1);
+ }
+ }
+
+ // Avoid infinite loops.
+ length = Math.max(1, length);
+ // Skip to after the last match.
+ input = input.substr(next.index + length);
+ }
+ // Return unmatched bits and close any open tags at the end.
+ return output + input + closeStack(stack);
+}
+
+// mIRC colors are defined at http://www.mirc.com/colors.html.
+// This expression matches \003<one or two digits>[,<one or two digits>].
+// eslint-disable-next-line no-control-regex
+var M_IRC_COLORS_EXP = /^\x03(?:(\d\d?)(?:,(\d\d?))?)?/;
+var M_IRC_COLOR_MAP = {
+ 0: "white",
+ 1: "black",
+ 2: "navy", // blue (navy)
+ 3: "green",
+ 4: "red",
+ 5: "maroon", // brown (maroon)
+ 6: "purple",
+ 7: "orange", // orange (olive)
+ 8: "yellow",
+ 9: "lime", // light green (lime)
+ 10: "teal", // teal (a green/blue cyan)
+ 11: "aqua", // light cyan (cyan) (aqua)
+ 12: "blue", // light blue (royal)",
+ 13: "fuchsia", // pink (light purple) (fuchsia)
+ 14: "grey",
+ 15: "silver", // light grey (silver)
+ 99: "transparent",
+};
+
+function mIRCColoring(aStack, aInput) {
+ function getColor(aKey) {
+ let key = aKey;
+ // Single digit numbers can (must?) be prefixed by a zero.
+ if (key.length == 2 && key[0] == "0") {
+ key = key[1];
+ }
+
+ if (M_IRC_COLOR_MAP.hasOwnProperty(key)) {
+ return M_IRC_COLOR_MAP[key];
+ }
+
+ return null;
+ }
+
+ let matches,
+ stack = aStack,
+ input = aInput,
+ output = "",
+ length = 1;
+
+ if ((matches = M_IRC_COLORS_EXP.exec(input))) {
+ let format = ["font"];
+
+ // Only \003 was found with no formatting digits after it, close the
+ // first open font tag.
+ if (!matches[1]) {
+ // Find the first font tag.
+ let offset = stack.map(aTag => aTag.indexOf("font") === 0).indexOf(true);
+
+ // Close all tags from the first font tag on.
+ output = closeStack(stack.slice(offset));
+ // Remove the font tags from the stack.
+ stack = stack.filter(aTag => aTag.indexOf("font"));
+ // Reopen the other tags.
+ output += openStack(stack.slice(offset));
+ } else {
+ // Otherwise we have a match and are setting new colors.
+ // The foreground color.
+ let color = getColor(matches[1]);
+ if (color) {
+ format.push('color="' + color + '"');
+ }
+
+ // The background color.
+ if (matches[2]) {
+ let color = getColor(matches[2]);
+ if (color) {
+ format.push('background="' + color + '"');
+ }
+ }
+
+ if (format.length > 1) {
+ let tag = format.join(" ");
+ output = "<" + tag + ">";
+ stack.push(tag);
+ length = matches[0].length;
+ }
+ }
+ }
+
+ return [stack, output, length];
+}
+
+// Print an error message into a conversation, optionally mark the conversation
+// as not joined and/or not rejoinable.
+export function conversationErrorMessage(
+ aAccount,
+ aMessage,
+ aError,
+ aJoinFailed = false,
+ aRejoinable = true
+) {
+ let conv = aAccount.getConversation(aMessage.params[1]);
+ conv.writeMessage(
+ aMessage.origin,
+ lazy._(aError, aMessage.params[1], aMessage.params[2] || undefined),
+ {
+ error: true,
+ system: true,
+ }
+ );
+ delete conv._pendingMessage;
+
+ // Channels have a couple extra things that can be done to them.
+ if (aAccount.isMUCName(aMessage.params[1])) {
+ // If a value for joining is explicitly given, mark it.
+ if (aJoinFailed) {
+ conv.joining = false;
+ }
+ // If the conversation cannot be rejoined automatically, delete
+ // chatRoomFields.
+ if (!aRejoinable) {
+ delete conv.chatRoomFields;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Display a PRIVMSG or NOTICE in a conversation.
+ *
+ * @param {ircAccount} aAccount - The current account.
+ * @param {ircMessage} aMessage - The IRC message to display, provides the IRC
+ * tags, conversation name, and sender.
+ * @param {object} aExtraParams - (Extra) parameters to pass to ircConversation.writeMessage.
+ * @param {string|null} aText - The text to display, defaults to the second parameter
+ * on aMessage.
+ * @returns {boolean} True if the message was sent successfully.
+ */
+export function displayMessage(aAccount, aMessage, aExtraParams, aText) {
+ let params = { tags: aMessage.tags, ...aExtraParams };
+ // If the the message is from our nick, it is outgoing to the conversation it
+ // is targeting. Otherwise, the message is incoming, but could be for a
+ // private message or a channel.
+ //
+ // Note that the only time it is expected to receive a message from us is if
+ // the echo-message capability is enabled.
+ let convName;
+ if (
+ aAccount.normalizeNick(aMessage.origin) ==
+ aAccount.normalizeNick(aAccount._nickname)
+ ) {
+ params.outgoing = true;
+ // The conversation name is who it is being sent to.
+ convName = aMessage.params[0];
+ } else {
+ params.incoming = true;
+ // If the target is a MUC name, use the target as the conversation name.
+ // Otherwise, this is a private message: use the sender as the conversation
+ // name.
+ convName = aAccount.isMUCName(aMessage.params[0])
+ ? aMessage.params[0]
+ : aMessage.origin;
+ }
+ aAccount
+ .getConversation(convName)
+ .writeMessage(aMessage.origin, aText || aMessage.params[1], params);
+ return true;
+}
diff --git a/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs b/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs
new file mode 100644
index 0000000000..c7bdb2bf7b
--- /dev/null
+++ b/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs
@@ -0,0 +1,467 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This implements the WATCH and MONITOR commands: ways to more efficiently
+ * (compared to ISON) keep track of a user's status.
+ *
+ * MONITOR (supported by Charybdis)
+ * https://github.com/atheme/charybdis/blob/master/doc/monitor.txt
+ * WATCH (supported by Bahamut and UnrealIRCd)
+ * http://www.stack.nl/~jilles/cgi-bin/hgwebdir.cgi/irc-documentation-jilles/raw-file/tip/reference/draft-meglio-irc-watch-00.txt
+ */
+
+import { clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs";
+
+function setStatus(aAccount, aNick, aStatus) {
+ if (!aAccount.watchEnabled && !aAccount.monitorEnabled) {
+ return false;
+ }
+
+ if (aStatus == "AWAY") {
+ // We need to request the away message.
+ aAccount.requestCurrentWhois(aNick);
+ } else {
+ // Clear the WHOIS information.
+ aAccount.removeBuddyInfo(aNick);
+ }
+
+ let buddy = aAccount.buddies.get(aNick);
+ if (!buddy) {
+ return false;
+ }
+ buddy.setStatus(Ci.imIStatusInfo["STATUS_" + aStatus], "");
+ return true;
+}
+
+function trackBuddyWatch(aNicks) {
+ // aNicks is an array when WATCH is initialized, and a single nick
+ // in all later calls.
+ if (!Array.isArray(aNicks)) {
+ // We update the trackQueue if an individual nick is being added,
+ // so the nick will also be monitored after a reconnect.
+ Object.getPrototypeOf(this).trackBuddy.call(this, aNicks);
+ aNicks = [aNicks];
+ }
+
+ let nicks = aNicks.map(aNick => "+" + aNick);
+ if (!nicks.length) {
+ return;
+ }
+
+ let newWatchLength = this.watchLength + nicks.length;
+ if (newWatchLength > this.maxWatchLength) {
+ this.WARN(
+ "Attempting to WATCH " +
+ newWatchLength +
+ " nicks; maximum size is " +
+ this.maxWatchLength +
+ "."
+ );
+ // TODO We should trim the list and add the extra users to an ISON queue,
+ // but that's not currently implemented, so just hope the server doesn't
+ // enforce it's own limit.
+ }
+ this.watchLength = newWatchLength;
+
+ // Watch away as well as online.
+ let params = [];
+ if (this.watchAwayEnabled) {
+ params.push("A");
+ }
+ let maxLength =
+ this.maxMessageLength -
+ 2 -
+ this.countBytes(this.buildMessage("WATCH", params));
+ for (let nick of nicks) {
+ if (this.countBytes(params + " " + nick) >= maxLength) {
+ // If the message would be too long, first send this message.
+ this.sendMessage("WATCH", params);
+ // Reset for the next message.
+ params = [];
+ if (this.watchAwayEnabled) {
+ params.push("A");
+ }
+ }
+ params.push(nick);
+ }
+ this.sendMessage("WATCH", params);
+}
+function untrackBuddyWatch(aNick) {
+ --this.watchLength;
+ this.sendMessage("WATCH", "-" + aNick);
+ Object.getPrototypeOf(this).untrackBuddy.call(this, aNick);
+}
+
+export var isupportWATCH = {
+ name: "WATCH",
+ // Slightly above default ISUPPORT priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ WATCH(aMessage) {
+ if (!aMessage.isupport.useDefault) {
+ this.maxWatchLength = 128;
+ } else {
+ let size = parseInt(aMessage.isupport.value, 10);
+ if (isNaN(size)) {
+ return false;
+ }
+ this.maxWatchLength = size;
+ }
+
+ this.watchEnabled = true;
+
+ // Clear our watchlist in case there is garbage in it.
+ this.sendMessage("WATCH", "C");
+ this.watchLength = 0;
+
+ // Kill the ISON polling loop.
+ clearTimeout(this._isOnTimer);
+
+ return true;
+ },
+
+ WATCHOPTS(aMessage) {
+ const watchOptToOption = {
+ H: "watchMasksEnabled",
+ A: "watchAwayEnabled",
+ };
+
+ // For each option, mark it as supported.
+ aMessage.isupport.value.split("").forEach(function (aWatchOpt) {
+ if (watchOptToOption.hasOwnProperty(aWatchOpt)) {
+ this[watchOptToOption[aWatchOpt]] = true;
+ }
+ }, this);
+
+ return true;
+ },
+ },
+};
+
+export var ircWATCH = {
+ name: "WATCH",
+ // Slightly above default IRC priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ // Use WATCH if it is supported.
+ isEnabled() {
+ return !!this.watchEnabled;
+ },
+
+ commands: {
+ 251(aMessage) {
+ // RPL_LUSERCLIENT
+ // ":There are <integer> users and <integer> services on <integer> servers"
+ // Assume that this will always be sent after the 005 handler on
+ // connection registration. If WATCH is enabled, then set the new function
+ // to keep track of nicks and send the messages to watch the nicks.
+
+ // Ensure that any new buddies are set to be watched, and removed buddies
+ // are no longer watched.
+ this.trackBuddy = trackBuddyWatch;
+ this.untrackBuddy = untrackBuddyWatch;
+
+ // Build the watchlist from the current list of nicks.
+ this.trackBuddy(this.trackQueue);
+
+ // Fall through to other handlers since we're only using this as an entry
+ // point and not actually handling the message.
+ return false;
+ },
+
+ 301(aMessage) {
+ // RPL_AWAY
+ // <nick> :<away message>
+ // Set the received away message.
+ let buddy = this.buddies.get(aMessage.params[1]);
+ if (buddy) {
+ buddy.setStatus(Ci.imIStatusInfo.STATUS_AWAY, aMessage.params[2]);
+ }
+
+ // Fall through to the other implementations after setting the status
+ // message.
+ return false;
+ },
+
+ 303(aMessage) {
+ // RPL_ISON
+ // :*1<nick> *( " " <nick> )
+ // We don't want ircBase to interfere with us, so override the ISON
+ // handler to do nothing.
+ return true;
+ },
+
+ 512(aMessage) {
+ // ERR_TOOMANYWATCH
+ // Maximum size for WATCH-list is <watchlimit> entries
+ this.ERROR(
+ "Maximum size for WATCH list exceeded (" + this.watchLength + ")."
+ );
+ return true;
+ },
+
+ 597(aMessage) {
+ // RPL_REAWAY
+ // <nickname> <username> <hostname> <awaysince> :<away reason>
+ return setStatus(this, aMessage.params[1], "AWAY");
+ },
+
+ 598(aMessage) {
+ // RPL_GONEAWAY
+ // <nickname> <username> <hostname> <awaysince> :<away reason>
+ // We use a negative index as inspircd versions < 2.0.18 don't send
+ // the user's nick as the first parameter (see bug 1078223).
+ return setStatus(
+ this,
+ aMessage.params[aMessage.params.length - 5],
+ "AWAY"
+ );
+ },
+
+ 599(aMessage) {
+ // RPL_NOTAWAY
+ // <nickname> <username> <hostname> <awaysince> :is no longer away
+ // We use a negative index as inspircd versions < 2.0.18 don't send
+ // the user's nick as the first parameter (see bug 1078223).
+ return setStatus(
+ this,
+ aMessage.params[aMessage.params.length - 5],
+ "AVAILABLE"
+ );
+ },
+
+ 600(aMessage) {
+ // RPL_LOGON
+ // <nickname> <username> <hostname> <signontime> :logged on
+ return setStatus(this, aMessage.params[1], "AVAILABLE");
+ },
+
+ 601(aMessage) {
+ // RPL_LOGOFF
+ // <nickname> <username> <hostname> <lastnickchange> :logged off
+ return setStatus(this, aMessage.params[1], "OFFLINE");
+ },
+
+ 602(aMessage) {
+ // RPL_WATCHOFF
+ // <nickname> <username> <hostname> <lastnickchange> :stopped watching
+ return true;
+ },
+
+ 603(aMessage) {
+ // RPL_WATCHSTAT
+ // You have <entrycount> and are on <onlistcount> WATCH entries
+ // TODO I don't think we really need to care about this.
+ return false;
+ },
+
+ 604(aMessage) {
+ // RPL_NOWON
+ // <nickname> <username> <hostname> <lastnickchange> :is online
+ return setStatus(this, aMessage.params[1], "AVAILABLE");
+ },
+
+ 605(aMessage) {
+ // RPL_NOWOFF
+ // <nickname> <username> <hostname> <lastnickchange> :is offline
+ return setStatus(this, aMessage.params[1], "OFFLINE");
+ },
+
+ 606(aMessage) {
+ // RPL_WATCHLIST
+ // <entrylist>
+ // TODO
+ return false;
+ },
+
+ 607(aMessage) {
+ // RPL_ENDOFWATCHLIST
+ // End of WATCH <parameter>
+ // TODO
+ return false;
+ },
+
+ 608(aMessage) {
+ // RPL_CLEARWATCH
+ // Your WATCH list is now empty
+ // Note that this is optional for servers to send, so ignore it.
+ return true;
+ },
+
+ 609(aMessage) {
+ // RPL_NOWISAWAY
+ // <nickname> <username> <hostname> <awaysince> :<away reason>
+ return setStatus(this, aMessage.params[1], "AWAY");
+ },
+ },
+};
+
+export var isupportMONITOR = {
+ name: "MONITOR",
+ // Slightly above default ISUPPORT priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ isEnabled: () => true,
+
+ commands: {
+ MONITOR(aMessage) {
+ if (!aMessage.isupport.useDefault) {
+ this.maxMonitorLength = Infinity;
+ } else {
+ let size = parseInt(aMessage.isupport.value, 10);
+ if (isNaN(size)) {
+ return false;
+ }
+ this.maxMonitorLength = size;
+ }
+
+ this.monitorEnabled = true;
+
+ // Clear our monitor list in case there is garbage in it.
+ this.sendMessage("MONITOR", "C");
+ this.monitorLength = 0;
+
+ // Kill the ISON polling loop.
+ clearTimeout(this._isOnTimer);
+
+ return true;
+ },
+ },
+};
+
+function trackBuddyMonitor(aNicks) {
+ // aNicks is an array when MONITOR is initialized, and a single nick
+ // in all later calls.
+ if (!Array.isArray(aNicks)) {
+ // We update the trackQueue if an individual nick is being added,
+ // so the nick will also be monitored after a reconnect.
+ Object.getPrototypeOf(this).trackBuddy.call(this, aNicks);
+ aNicks = [aNicks];
+ }
+
+ let nicks = aNicks;
+ if (!nicks.length) {
+ return;
+ }
+
+ let newMonitorLength = this.monitorLength + nicks.length;
+ if (newMonitorLength > this.maxMonitorLength) {
+ this.WARN(
+ "Attempting to MONITOR " +
+ newMonitorLength +
+ " nicks; maximum size is " +
+ this.maxMonitorLength +
+ "."
+ );
+ // TODO We should trim the list and add the extra users to an ISON queue,
+ // but that's not currently implemented, so just hope the server doesn't
+ // enforce it's own limit.
+ }
+ this.monitorLength = newMonitorLength;
+
+ let params = [];
+ let maxLength =
+ this.maxMessageLength -
+ 2 -
+ this.countBytes(this.buildMessage("MONITOR", "+"));
+ for (let nick of nicks) {
+ if (this.countBytes(params + " " + nick) >= maxLength) {
+ // If the message would be too long, first send this message.
+ this.sendMessage("MONITOR", ["+", params.join(",")]);
+ // Reset for the next message.
+ params = [];
+ }
+ params.push(nick);
+ }
+ this.sendMessage("MONITOR", ["+", params.join(",")]);
+}
+function untrackBuddyMonitor(aNick) {
+ --this.monitorLength;
+ this.sendMessage("MONITOR", ["-", aNick]);
+ Object.getPrototypeOf(this).untrackBuddy.call(this, aNick);
+}
+
+export var ircMONITOR = {
+ name: "MONITOR",
+ // Slightly above default IRC priority.
+ priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10,
+ // Use MONITOR only if MONITOR is enabled and WATCH is not enabled, as WATCH
+ // supports more features.
+ isEnabled() {
+ return this.monitorEnabled && !this.watchEnabled;
+ },
+
+ commands: {
+ 251(aMessage) {
+ // RPL_LUSERCLIENT
+ // ":There are <integer> users and <integer> services on <integer> servers"
+ // Assume that this will always be sent after the 005 handler on
+ // connection registration. If MONITOR is enabled, then set the new
+ // function to keep track of nicks and send the messages to watch the
+ // nicks.
+
+ // Ensure that any new buddies are set to be watched, and removed buddies
+ // are no longer watched.
+ this.trackBuddy = trackBuddyMonitor;
+ this.untrackBuddy = untrackBuddyMonitor;
+
+ // Build the watchlist from the current list of nicks.
+ this.trackBuddy(this.trackQueue);
+
+ // Fall through to other handlers since we're only using this as an entry
+ // point and not actually handling the message.
+ return false;
+ },
+
+ 303(aMessage) {
+ // RPL_ISON
+ // :*1<nick> *( " " <nick> )
+ // We don't want ircBase to interfere with us, so override the ISON
+ // handler to do nothing if we're using MONITOR.
+ return true;
+ },
+
+ 730(aMessage) {
+ // RPL_MONONLINE
+ // :<server> 730 <nick> :nick!user@host[,nick!user@host]*
+ // Mark each nick as online.
+ return aMessage.params[1]
+ .split(",")
+ .map(aNick => setStatus(this, aNick.split("!", 1)[0], "AVAILABLE"))
+ .every(aResult => aResult);
+ },
+
+ 731(aMessage) {
+ // RPL_MONOFFLINE
+ // :<server> 731 <nick> :nick[,nick1]*
+ return aMessage.params[1]
+ .split(",")
+ .map(aNick => setStatus(this, aNick, "OFFLINE"))
+ .every(aResult => aResult);
+ },
+
+ 732(aMessage) {
+ // RPL_MONLIST
+ // :<server> 732 <nick> :nick[,nick1]*
+ return false;
+ },
+
+ 733(aMessage) {
+ // RPL_ENDOFMONLIST
+ // :<server> 733 <nick> :End of MONITOR list
+ return false;
+ },
+
+ 734(aMessage) {
+ // ERR_MONLISTFULL
+ // :<server> 734 <nick> <limit> <nicks> :Monitor list is full.
+ this.ERROR(
+ "Maximum size for MONITOR list exceeded (" + this.params[1] + ")."
+ );
+ return true;
+ },
+ },
+};
diff --git a/comm/chat/protocols/irc/jar.mn b/comm/chat/protocols/irc/jar.mn
new file mode 100644
index 0000000000..4ef677131e
--- /dev/null
+++ b/comm/chat/protocols/irc/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-irc classic/1.0 %skin/classic/prpl/irc/
+ skin/classic/prpl/irc/icon32.png (icons/prpl-irc-32.png)
+ skin/classic/prpl/irc/icon48.png (icons/prpl-irc-48.png)
+ skin/classic/prpl/irc/icon.png (icons/prpl-irc.png)
diff --git a/comm/chat/protocols/irc/moz.build b/comm/chat/protocols/irc/moz.build
new file mode 100644
index 0000000000..8e72a57f4e
--- /dev/null
+++ b/comm/chat/protocols/irc/moz.build
@@ -0,0 +1,33 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "irc.sys.mjs",
+ "ircAccount.sys.mjs",
+ "ircBase.sys.mjs",
+ "ircCAP.sys.mjs",
+ "ircCommands.sys.mjs",
+ "ircCTCP.sys.mjs",
+ "ircDCC.sys.mjs",
+ "ircEchoMessage.sys.mjs",
+ "ircHandlerPriorities.sys.mjs",
+ "ircHandlers.sys.mjs",
+ "ircISUPPORT.sys.mjs",
+ "ircMultiPrefix.sys.mjs",
+ "ircNonStandard.sys.mjs",
+ "ircSASL.sys.mjs",
+ "ircServerTime.sys.mjs",
+ "ircServices.sys.mjs",
+ "ircUtils.sys.mjs",
+ "ircWatchMonitor.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/irc/test/test_ctcpColoring.js b/comm/chat/protocols/irc/test/test_ctcpColoring.js
new file mode 100644
index 0000000000..2875bdff36
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ctcpColoring.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ctcpFormatToText, ctcpFormatToHTML } = ChromeUtils.importESModule(
+ "resource:///modules/ircUtils.sys.mjs"
+);
+
+var input = [
+ // From http://www.mirc.com/colors.html
+ "\x035,12colored text and background\x03",
+ "\x035colored text\x03",
+ "\x033colored text \x035,2more colored text and background\x03",
+ "\x033,5colored text and background \x038other colored text but same background\x03",
+ "\x033,5colored text and background \x038,7other colored text and different background\x03",
+
+ // Based on above, but more complicated.
+ "\x02\x035,12colored \x1Ftext and background\x03. You sure about this?",
+
+ // Implied by above.
+ "So a \x03,8 attribute is not valid and thus ignored.",
+
+ // Try some of the above with two digits.
+ "\x0303,5colored text and background \x0308other colored text but same background\x03",
+ "\x0303,05colored text and background \x038,7other colored text and different background\x03",
+];
+
+function run_test() {
+ add_test(test_mIRCColoring);
+ add_test(test_ctcpFormatToText);
+
+ run_next_test();
+}
+
+function test_mIRCColoring() {
+ let expectedOutput = [
+ '<font color="maroon" background="blue">colored text and background</font>',
+ '<font color="maroon">colored text</font>',
+ '<font color="green">colored text <font color="maroon" background="navy">more colored text and background</font></font>',
+ '<font color="green" background="maroon">colored text and background <font color="yellow">other colored text but same background</font></font>',
+ '<font color="green" background="maroon">colored text and background <font color="yellow" background="orange">other colored text and different background</font></font>',
+ '<b><font color="maroon" background="blue">colored <u>text and background</u></font><u>. You sure about this?</u></b>',
+ "So a ,8 attribute is not valid and thus ignored.",
+ '<font color="green" background="maroon">colored text and background <font color="yellow">other colored text but same background</font></font>',
+ '<font color="green" background="maroon">colored text and background <font color="yellow" background="orange">other colored text and different background</font></font>',
+ ];
+
+ for (let i = 0; i < input.length; i++) {
+ equal(expectedOutput[i], ctcpFormatToHTML(input[i]));
+ }
+
+ run_next_test();
+}
+
+function test_ctcpFormatToText() {
+ let expectedOutput = [
+ "colored text and background",
+ "colored text",
+ "colored text more colored text and background",
+ "colored text and background other colored text but same background",
+ "colored text and background other colored text and different background",
+ "colored text and background. You sure about this?",
+ "So a ,8 attribute is not valid and thus ignored.",
+ "colored text and background other colored text but same background",
+ "colored text and background other colored text and different background",
+ ];
+
+ for (let i = 0; i < input.length; i++) {
+ equal(expectedOutput[i], ctcpFormatToText(input[i]));
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_ctcpDequote.js b/comm/chat/protocols/irc/test/test_ctcpDequote.js
new file mode 100644
index 0000000000..1a1e7fcc9d
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ctcpDequote.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { CTCPMessage } = ChromeUtils.importESModule(
+ "resource:///modules/ircCTCP.sys.mjs"
+);
+
+var input = [
+ "ACTION",
+ "ACTION test",
+ "ACTION \x5Ctest",
+ "ACTION te\x5Cst",
+ "ACTION test\x5C",
+ "ACTION \x5C\x5Ctest",
+ "ACTION te\x5C\x5Cst",
+ "ACTION test\x5C\x5C",
+ "ACTION \x5C\x5C\x5Ctest",
+ "ACTION te\x5C\x5C\x5Cst",
+ "ACTION test\x5C\x5C\x5C",
+ "ACTION \x5Catest",
+ "ACTION te\x5Cast",
+ "ACTION test\x5Ca",
+ "ACTION \x5C\x5C\x5Catest",
+ "ACTION \x5C\x5Catest",
+];
+
+var expectedOutputParam = [
+ "",
+ "test",
+ "test",
+ "test",
+ "test",
+ "\x5Ctest",
+ "te\x5Cst",
+ "test\x5C",
+ "\x5Ctest",
+ "te\x5Cst",
+ "test\x5C",
+ "\x01test",
+ "te\x01st",
+ "test\x01",
+ "\x5C\x01test",
+ "\x5Catest",
+];
+
+function run_test() {
+ let output = input.map(aStr => CTCPMessage({}, aStr));
+ // Ensure both arrays have the same length.
+ equal(expectedOutputParam.length, output.length);
+ // Ensure the values in the arrays are equal.
+ for (let i = 0; i < output.length; ++i) {
+ equal(expectedOutputParam[i], output[i].ctcp.param);
+ equal("ACTION", output[i].ctcp.command);
+ }
+}
diff --git a/comm/chat/protocols/irc/test/test_ctcpFormatting.js b/comm/chat/protocols/irc/test/test_ctcpFormatting.js
new file mode 100644
index 0000000000..022b194d4c
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ctcpFormatting.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ctcpFormatToText, ctcpFormatToHTML } = ChromeUtils.importESModule(
+ "resource:///modules/ircUtils.sys.mjs"
+);
+
+// TODO add a test for special JS characters (|, etc...)
+
+var input = [
+ "The quick brown fox \x02jumps\x02 over the lazy dog.",
+ "The quick brown fox \x02jumps\x0F over the lazy dog.",
+ "The quick brown \x16fox jumps\x16 over the lazy dog.",
+ "The quick brown \x16fox jumps\x0F over the lazy dog.",
+ "The quick \x1Fbrown fox jumps over the lazy\x1F dog.",
+ "The quick \x1Fbrown fox jumps over the lazy\x0F dog.",
+ "The quick \x1Fbrown fox \x02jumps over the lazy\x1F dog.",
+ "The quick \x1Fbrown fox \x02jumps\x1F over the lazy\x02 dog.",
+ "The quick \x1Fbrown \x16fox \x02jumps\x1F over\x16 the lazy\x02 dog.",
+ "The quick \x1Fbrown \x16fox \x02jumps\x0F over \x16the lazy \x02dog.",
+];
+
+function run_test() {
+ add_test(test_ctcpFormatToHTML);
+ add_test(test_ctcpFormatToText);
+
+ run_next_test();
+}
+
+function test_ctcpFormatToHTML() {
+ let expectedOutput = [
+ "The quick brown fox <b>jumps</b> over the lazy dog.",
+ "The quick brown fox <b>jumps</b> over the lazy dog.",
+ "The quick brown <i>fox jumps</i> over the lazy dog.",
+ "The quick brown <i>fox jumps</i> over the lazy dog.",
+ "The quick <u>brown fox jumps over the lazy</u> dog.",
+ "The quick <u>brown fox jumps over the lazy</u> dog.",
+ "The quick <u>brown fox <b>jumps over the lazy</b></u><b> dog.</b>",
+ "The quick <u>brown fox <b>jumps</b></u><b> over the lazy</b> dog.",
+ "The quick <u>brown <i>fox <b>jumps</b></i></u><i><b> over</b></i><b> the lazy</b> dog.",
+ "The quick <u>brown <i>fox <b>jumps</b></i></u> over <i>the lazy <b>dog.</b></i>",
+ ];
+
+ for (let i = 0; i < input.length; i++) {
+ equal(expectedOutput[i], ctcpFormatToHTML(input[i]));
+ }
+
+ run_next_test();
+}
+
+function test_ctcpFormatToText() {
+ let expectedOutput = "The quick brown fox jumps over the lazy dog.";
+
+ for (let i = 0; i < input.length; ++i) {
+ equal(expectedOutput, ctcpFormatToText(input[i]));
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_ctcpQuote.js b/comm/chat/protocols/irc/test/test_ctcpQuote.js
new file mode 100644
index 0000000000..0c919236b9
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ctcpQuote.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ircAccount } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+var input = [
+ undefined,
+ "test",
+ "\\test",
+ "te\\st",
+ "test\\",
+ "\\\\test",
+ "te\\\\st",
+ "test\\\\",
+ "\\\\\\test",
+ "te\\\\\\st",
+ "test\\\\\\",
+ "\x01test",
+ "te\x01st",
+ "test\x01",
+ "\\\\\x01test",
+ "\\\\atest",
+];
+
+var expectedOutputParams = [
+ "ACTION",
+ "ACTION test",
+ "ACTION \\\\test",
+ "ACTION te\\\\st",
+ "ACTION test\\\\",
+ "ACTION \\\\\\\\test",
+ "ACTION te\\\\\\\\st",
+ "ACTION test\\\\\\\\",
+ "ACTION \\\\\\\\\\\\test",
+ "ACTION te\\\\\\\\\\\\st",
+ "ACTION test\\\\\\\\\\\\",
+ "ACTION \\atest",
+ "ACTION te\\ast",
+ "ACTION test\\a",
+ "ACTION \\\\\\\\\\atest",
+ "ACTION \\\\\\\\atest",
+];
+
+var outputParams = [];
+
+ircAccount.prototype.sendMessage = function (aCommand, aParams) {
+ equal("PRIVMSG", aCommand);
+ outputParams.push(aParams[1]);
+};
+
+function run_test() {
+ input.map(aStr =>
+ ircAccount.prototype.sendCTCPMessage("", false, "ACTION", aStr)
+ );
+
+ // Ensure both arrays have the same length.
+ equal(expectedOutputParams.length, outputParams.length);
+ // Ensure the values in the arrays are equal.
+ for (let i = 0; i < outputParams.length; ++i) {
+ equal("\x01" + expectedOutputParams[i] + "\x01", outputParams[i]);
+ }
+}
diff --git a/comm/chat/protocols/irc/test/test_ircCAP.js b/comm/chat/protocols/irc/test/test_ircCAP.js
new file mode 100644
index 0000000000..a79a926efc
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircCAP.js
@@ -0,0 +1,236 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { capMessage } = ChromeUtils.importESModule(
+ "resource:///modules/ircCAP.sys.mjs"
+);
+
+var testData = [
+ // A normal LS from the server.
+ [
+ ["*", "LS", "multi-prefix sasl userhost-in-names"],
+ [
+ {
+ subcommand: "LS",
+ parameter: "multi-prefix",
+ },
+ {
+ subcommand: "LS",
+ parameter: "sasl",
+ },
+ {
+ subcommand: "LS",
+ parameter: "userhost-in-names",
+ },
+ ],
+ ],
+
+ // LS with both valid and invalid vendor specific capabilities.
+ [
+ [
+ "*",
+ "LS",
+ "sasl server-time znc.in/server-time-iso znc.in/playback palaverapp.com",
+ ],
+ [
+ {
+ subcommand: "LS",
+ parameter: "sasl",
+ },
+ {
+ subcommand: "LS",
+ parameter: "server-time",
+ },
+ // Valid vendor prefixes (of the form <domain name>/<capability>).
+ {
+ subcommand: "LS",
+ parameter: "znc.in/server-time-iso",
+ },
+ {
+ subcommand: "LS",
+ parameter: "znc.in/playback",
+ },
+ // Invalid vendor prefix, but we should treat it as an opaque identifier.
+ {
+ subcommand: "LS",
+ parameter: "palaverapp.com",
+ },
+ ],
+ ],
+
+ // Some implementations include one less parameter.
+ [
+ ["LS", "sasl"],
+ [
+ {
+ subcommand: "LS",
+ parameter: "sasl",
+ },
+ ],
+ ],
+
+ // Modifier tests, ensure the modified is stripped from the capaibility and is
+ // parsed correctly.
+ [
+ ["LS", "-disable =sticky ~ack"],
+ [
+ {
+ subcommand: "LS",
+ parameter: "disable",
+ modifier: "-",
+ disable: true,
+ },
+ {
+ subcommand: "LS",
+ parameter: "sticky",
+ modifier: "=",
+ sticky: true,
+ },
+ {
+ subcommand: "LS",
+ parameter: "ack",
+ modifier: "~",
+ ack: true,
+ },
+ ],
+ ],
+
+ // IRC v3.2 multi-line LS response
+ [
+ ["*", "LS", "*", "sasl"],
+ ["*", "LS", "server-time"],
+ [
+ {
+ subcommand: "LS",
+ parameter: "sasl",
+ },
+ {
+ subcommand: "LS",
+ parameter: "server-time",
+ },
+ ],
+ ],
+
+ // IRC v3.2 multi-line LIST response
+ [
+ ["*", "LIST", "*", "sasl"],
+ ["*", "LIST", "server-time"],
+ [
+ {
+ subcommand: "LIST",
+ parameter: "sasl",
+ },
+ {
+ subcommand: "LIST",
+ parameter: "server-time",
+ },
+ ],
+ ],
+
+ // IRC v3.2 cap value
+ [
+ ["*", "LS", "multi-prefix sasl=EXTERNAL sts=port=6697"],
+ [
+ {
+ subcommand: "LS",
+ parameter: "multi-prefix",
+ },
+ {
+ subcommand: "LS",
+ parameter: "sasl",
+ value: "EXTERNAL",
+ },
+ {
+ subcommand: "LS",
+ parameter: "sts",
+ value: "port=6697",
+ },
+ ],
+ ],
+
+ // cap-notify new cap
+ [
+ ["*", "NEW", "batch"],
+ [
+ {
+ subcommand: "NEW",
+ parameter: "batch",
+ },
+ ],
+ ],
+
+ // cap-notify delete cap
+ [
+ ["*", "DEL", "multi-prefix"],
+ [
+ {
+ subcommand: "DEL",
+ parameter: "multi-prefix",
+ },
+ ],
+ ],
+];
+
+function run_test() {
+ add_test(testCapMessages);
+
+ run_next_test();
+}
+
+/*
+ * Test round tripping parsing and then rebuilding the messages from RFC 2812.
+ */
+function testCapMessages() {
+ for (let data of testData) {
+ // Generate an ircMessage to send into capMessage.
+ let i = 0;
+ let message;
+ let outputs;
+ const account = {
+ _queuedCAPs: [],
+ };
+
+ // Generate an ircMessage to send into capMessage.
+ while (typeof data[i][0] == "string") {
+ message = {
+ params: data[i],
+ };
+
+ // Create the CAP message.
+ outputs = capMessage(message, account);
+ ++i;
+ }
+
+ // The original message should get a cap object added with the subcommand
+ // set.
+ ok(message.cap);
+ equal(message.cap.subcommand, data[i][0].subcommand);
+
+ // We only care about the "cap" part of each return message.
+ outputs = outputs.map(o => o.cap);
+
+ // Ensure the expected output is an array.
+ let expectedCaps = data[i];
+ if (!Array.isArray(expectedCaps)) {
+ expectedCaps = [expectedCaps];
+ }
+
+ // Add defaults to the expected output.
+ for (let expectedCap of expectedCaps) {
+ // By default there's no modifier.
+ if (!("modifier" in expectedCap)) {
+ expectedCap.modifier = undefined;
+ }
+ for (let param of ["disable", "sticky", "ack"]) {
+ if (!(param in expectedCap)) {
+ expectedCap[param] = false;
+ }
+ }
+ }
+
+ // Ensure each item in the arrays are equal.
+ deepEqual(outputs, expectedCaps);
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_ircChannel.js b/comm/chat/protocols/irc/test/test_ircChannel.js
new file mode 100644
index 0000000000..eb8b04dcc7
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircChannel.js
@@ -0,0 +1,187 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ircChannel } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+function waitForTopic(target, targetTopic) {
+ return new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ if (topic === targetTopic) {
+ resolve({ subject, data });
+ target.removeObserver(observer);
+ }
+ },
+ };
+ target.addObserver(observer);
+ });
+}
+
+function getChannel(account) {
+ const channelStub = {
+ _observers: [],
+ _name: "#test",
+ _account: {
+ _currentServerName: "test",
+ imAccount: {
+ statusInfo: {},
+ },
+ _nickname: "user",
+ _activeCAPs: new Set(),
+ ...account,
+ },
+ };
+ Object.setPrototypeOf(channelStub, ircChannel.prototype);
+ return channelStub;
+}
+
+add_task(async function test_dispatchMessage_normal() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ equal(type, "PRIVMSG");
+ deepEqual(data, ["#test", "foo"]);
+ didSend = true;
+ return true;
+ },
+ });
+ const newText = waitForTopic(channelStub, "new-text");
+ channelStub.dispatchMessage("foo");
+ ok(didSend);
+ const { subject: sentMessage } = await newText;
+ equal(sentMessage.message, "foo");
+ ok(sentMessage.outgoing);
+ ok(!sentMessage.notification);
+ equal(sentMessage.who, "user");
+});
+
+add_task(async function test_dispatchMessage_empty() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ ok(false, "Should not send empty message");
+ didSend = true;
+ return true;
+ },
+ });
+ channelStub.writeMessage = () => {
+ ok(false, "Should not display empty unsent message");
+ didSend = true;
+ };
+ ircChannel.prototype.dispatchMessage.call(channelStub, "");
+ ok(!didSend);
+});
+
+add_task(async function test_dispatchMessage_echoed() {
+ let didSend = false;
+ let didWrite = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ equal(type, "PRIVMSG");
+ deepEqual(data, ["#test", "foo"]);
+ didSend = true;
+ return true;
+ },
+ });
+ channelStub._account._activeCAPs.add("echo-message");
+ channelStub.writeMessage = () => {
+ ok(false, "Should not write message when echo is on");
+ didWrite = true;
+ };
+ ircChannel.prototype.dispatchMessage.call(channelStub, "foo");
+ ok(didSend);
+ ok(!didWrite);
+});
+
+add_task(async function test_dispatchMessage_error() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ equal(type, "PRIVMSG");
+ deepEqual(data, ["#test", "foo"]);
+ didSend = true;
+ return false;
+ },
+ });
+ const newText = waitForTopic(channelStub, "new-text");
+ ircChannel.prototype.dispatchMessage.call(channelStub, "foo");
+ ok(didSend);
+ const { subject: writtenMessage } = await newText;
+ ok(writtenMessage.error);
+ ok(writtenMessage.system);
+ equal(writtenMessage.who, "test");
+});
+
+add_task(async function test_dispatchMessage_action() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ ok(false, "Action should not be sent as normal message");
+ return false;
+ },
+ sendCTCPMessage(target, isNotice, command, params) {
+ equal(target, "#test");
+ ok(!isNotice);
+ equal(command, "ACTION");
+ equal(params, "foo");
+ didSend = true;
+ return true;
+ },
+ });
+ const newText = waitForTopic(channelStub, "new-text");
+ ircChannel.prototype.dispatchMessage.call(channelStub, "foo", true);
+ ok(didSend);
+ const { subject: sentMessage } = await newText;
+ equal(sentMessage.message, "foo");
+ ok(sentMessage.outgoing);
+ ok(!sentMessage.notification);
+ ok(sentMessage.action);
+ equal(sentMessage.who, "user");
+});
+
+add_task(async function test_dispatchMessage_actionError() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ ok(false, "Action should not be sent as normal message");
+ return false;
+ },
+ sendCTCPMessage(target, isNotice, command, params) {
+ equal(target, "#test");
+ ok(!isNotice);
+ equal(command, "ACTION");
+ equal(params, "foo");
+ didSend = true;
+ return false;
+ },
+ });
+ const newText = waitForTopic(channelStub, "new-text");
+ ircChannel.prototype.dispatchMessage.call(channelStub, "foo", true);
+ ok(didSend, "Message was sent");
+ const { subject: sentMessage } = await newText;
+ ok(sentMessage.error, "Shown message is error");
+ ok(sentMessage.system, "Shown message is from system");
+ equal(sentMessage.who, "test");
+});
+
+add_task(async function test_dispatchMessage_notice() {
+ let didSend = false;
+ const channelStub = getChannel({
+ sendMessage(type, data) {
+ equal(type, "NOTICE");
+ deepEqual(data, ["#test", "foo"]);
+ didSend = true;
+ return true;
+ },
+ });
+ const newText = waitForTopic(channelStub, "new-text");
+ ircChannel.prototype.dispatchMessage.call(channelStub, "foo", false, true);
+ ok(didSend);
+ const { subject: sentMessage } = await newText;
+ equal(sentMessage.message, "foo");
+ ok(sentMessage.outgoing);
+ ok(sentMessage.notification);
+ equal(sentMessage.who, "user");
+});
diff --git a/comm/chat/protocols/irc/test/test_ircCommands.js b/comm/chat/protocols/irc/test/test_ircCommands.js
new file mode 100644
index 0000000000..4bd6ab2954
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircCommands.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { commands } = ChromeUtils.importESModule(
+ "resource:///modules/ircCommands.sys.mjs"
+);
+var { ircProtocol } = ChromeUtils.importESModule(
+ "resource:///modules/irc.sys.mjs"
+);
+var { ircAccount, ircConversation } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+// Ensure the commands have been initialized.
+IMServices.conversations.initConversations();
+
+var fakeProto = {
+ id: "fake-proto",
+ usernameSplits: ircProtocol.prototype.usernameSplits,
+ splitUsername: ircProtocol.prototype.splitUsername,
+};
+
+function run_test() {
+ add_test(testUserModeCommand);
+ add_test(testModeCommand);
+ run_next_test();
+}
+
+// Test the /mode command.
+function testModeCommand() {
+ const testChannelCommands = [
+ {
+ msg: "",
+ channel: "#instantbird",
+ expectedMessage: "MODE #instantbird",
+ },
+ {
+ msg: "#instantbird",
+ channel: "#instantbird",
+ expectedMessage: "MODE #instantbird",
+ },
+ {
+ msg: "-s",
+ channel: "#Fins",
+ expectedMessage: "MODE #Fins -s",
+ },
+ {
+ msg: "#introduction +is",
+ channel: "#introduction",
+ expectedMessage: "MODE #introduction +is",
+ },
+ {
+ msg: "-s",
+ channel: "&Gills",
+ expectedMessage: "MODE &Gills -s",
+ },
+ {
+ msg: "#Gamers +o KennyS",
+ channel: "#Gamers",
+ expectedMessage: "MODE #Gamers +o KennyS",
+ },
+ {
+ msg: "+o lisp",
+ channel: "&IB",
+ expectedMessage: "MODE &IB +o lisp",
+ },
+ {
+ msg: "+b nick!abc@server",
+ channel: "#Alphabet",
+ expectedMessage: "MODE #Alphabet +b nick!abc@server",
+ },
+ {
+ msg: "+b nick",
+ channel: "#Alphabet",
+ expectedMessage: "MODE #Alphabet +b nick",
+ },
+ {
+ msg: "#instantbird +b nick!abc@server",
+ channel: "#instantbird",
+ expectedMessage: "MODE #instantbird +b nick!abc@server",
+ },
+ {
+ msg: "+v Wiz",
+ channel: "#TheMatrix",
+ expectedMessage: "MODE #TheMatrix +v Wiz",
+ },
+ {
+ msg: "+k passcode",
+ channel: "#TheMatrix",
+ expectedMessage: "MODE #TheMatrix +k passcode",
+ },
+ {
+ msg: "#Mafia +k keyword",
+ channel: "#Mafia",
+ expectedMessage: "MODE #Mafia +k keyword",
+ },
+ {
+ msg: "#introduction +l 100",
+ channel: "#introduction",
+ expectedMessage: "MODE #introduction +l 100",
+ },
+ {
+ msg: "+l 100",
+ channel: "#introduction",
+ expectedMessage: "MODE #introduction +l 100",
+ },
+ ];
+
+ const testUserCommands = [
+ {
+ msg: "nickolas +x",
+ expectedMessage: "MODE nickolas +x",
+ },
+ {
+ msg: "matrixisreal -x",
+ expectedMessage: "MODE matrixisreal -x",
+ },
+ {
+ msg: "matrixisreal_19 +oWp",
+ expectedMessage: "MODE matrixisreal_19 +oWp",
+ },
+ {
+ msg: "nick",
+ expectedMessage: "MODE nick",
+ },
+ ];
+
+ let account = new ircAccount(fakeProto, {
+ name: "defaultnick@instantbird.org",
+ });
+
+ // check if the message being sent is same as expected message.
+ account.sendRawMessage = aMessage => {
+ equal(aMessage, account._expectedMessage);
+ };
+
+ const command = _getRunCommand("mode");
+
+ // First test Channel Commands.
+ for (let test of testChannelCommands) {
+ let conv = new ircConversation(account, test.channel);
+ account._expectedMessage = test.expectedMessage;
+ command(test.msg, conv);
+ }
+
+ // Now test the User Commands.
+ let conv = new ircConversation(account, "dummyConversation");
+ account._nickname = "test_nick";
+ for (let test of testUserCommands) {
+ account._expectedMessage = test.expectedMessage;
+ command(test.msg, conv);
+ }
+
+ run_next_test();
+}
+
+// Test the /umode command.
+function testUserModeCommand() {
+ const testData = [
+ {
+ msg: "+x",
+ expectedMessage: "MODE test_nick +x",
+ },
+ {
+ msg: "-x",
+ expectedMessage: "MODE test_nick -x",
+ },
+ {
+ msg: "-pa",
+ expectedMessage: "MODE test_nick -pa",
+ },
+ {
+ msg: "+oWp",
+ expectedMessage: "MODE test_nick +oWp",
+ },
+ {
+ msg: "",
+ expectedMessage: "MODE test_nick",
+ },
+ ];
+
+ let account = new ircAccount(fakeProto, {
+ name: "test_nick@instantbird.org",
+ });
+ account._nickname = "test_nick";
+ let conv = new ircConversation(account, "newconv");
+
+ // check if the message being sent is same as expected message.
+ account.sendRawMessage = aMessage => {
+ equal(aMessage, account._expectedMessage);
+ };
+
+ const command = _getRunCommand("umode");
+
+ // change the nick and runUserModeCommand for each test
+ for (let test of testData) {
+ account._expectedMessage = test.expectedMessage;
+ command(test.msg, conv);
+ }
+
+ run_next_test();
+}
+
+// Fetch the run() of a named command.
+function _getRunCommand(aCommandName) {
+ for (let command of commands) {
+ if (command.name == aCommandName) {
+ return command.run;
+ }
+ }
+
+ // Fail if no command was found.
+ ok(false, "Could not find the '" + aCommandName + "' command.");
+ return null; // Shut-up eslint.
+}
diff --git a/comm/chat/protocols/irc/test/test_ircMessage.js b/comm/chat/protocols/irc/test/test_ircMessage.js
new file mode 100644
index 0000000000..4420856c84
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircMessage.js
@@ -0,0 +1,336 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ircAccount, ircMessage } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+var testData = [
+ // First off, let's test the messages from RFC 2812.
+ "PASS secretpasswordhere",
+ "NICK Wiz",
+ ":WiZ!jto@tolsun.oulu.fi NICK Kilroy",
+ "USER guest 0 * :Ronnie Reagan",
+ "USER guest 8 * :Ronnie Reagan",
+ "OPER foo bar",
+ "MODE WiZ -w",
+ "MODE Angel +i",
+ "MODE WiZ -o",
+ "SERVICE dict * *.fr 0 0 :French Dictionary",
+ "QUIT :Gone to have lunch",
+ ":syrk!kalt@millennium.stealth.net QUIT :Gone to have lunch",
+ "SQUIT tolsun.oulu.fi :Bad Link ?",
+ ":Trillian SQUIT cm22.eng.umd.edu :Server out of control",
+ "JOIN #foobar",
+ "JOIN &foo fubar",
+ "JOIN #foo,&bar fubar",
+ "JOIN #foo,#bar fubar,foobar",
+ "JOIN #foo,#bar",
+ "JOIN 0",
+ ":WiZ!jto@tolsun.oulu.fi JOIN #Twilight_zone",
+ "PART #twilight_zone",
+ "PART #oz-ops,&group5",
+ ":WiZ!jto@tolsun.oulu.fi PART #playzone :I lost",
+ "MODE #Finnish +imI *!*@*.fi",
+ "MODE #Finnish +o Kilroy",
+ "MODE #Finnish +v Wiz",
+ "MODE #Fins -s",
+ "MODE #42 +k oulu",
+ "MODE #42 -k oulu",
+ "MODE #eu-opers +l 10",
+ ":WiZ!jto@tolsun.oulu.fi MODE #eu-opers -l",
+ "MODE &oulu +b",
+ "MODE &oulu +b *!*@*",
+ "MODE &oulu +b *!*@*.edu +e *!*@*.bu.edu",
+ "MODE #bu +be *!*@*.edu *!*@*.bu.edu",
+ "MODE #meditation e",
+ "MODE #meditation I",
+ "MODE !12345ircd O",
+ ":WiZ!jto@tolsun.oulu.fi TOPIC #test :New topic",
+ "TOPIC #test :another topic",
+ "TOPIC #test :",
+ "TOPIC #test",
+ "NAMES #twilight_zone,#42",
+ "NAMES",
+ "LIST",
+ "LIST #twilight_zone,#42",
+ ":Angel!wings@irc.org INVITE Wiz #Dust",
+ "INVITE Wiz #Twilight_Zone",
+ "KICK &Melbourne Matthew",
+ "KICK #Finnish John :Speaking English",
+ ":WiZ!jto@tolsun.oulu.fi KICK #Finnish John",
+ ":Angel!wings@irc.org PRIVMSG Wiz :Are you receiving this message ?",
+ "PRIVMSG Angel :yes I'm receiving it !",
+ "PRIVMSG jto@tolsun.oulu.fi :Hello !",
+ "PRIVMSG kalt%millennium.stealth.net@irc.stealth.net :Are you a frog?",
+ "PRIVMSG kalt%millennium.stealth.net :Do you like cheese?",
+ "PRIVMSG Wiz!jto@tolsun.oulu.fi :Hello !",
+ "PRIVMSG $*.fi :Server tolsun.oulu.fi rebooting.",
+ "PRIVMSG #*.edu :NSFNet is undergoing work, expect interruptions",
+ "VERSION tolsun.oulu.fi",
+ "STATS m",
+ "LINKS *.au",
+ "LINKS *.edu *.bu.edu",
+ "TIME tolsun.oulu.fi",
+ "CONNECT tolsun.oulu.fi 6667",
+ "TRACE *.oulu.fi",
+ "ADMIN tolsun.oulu.fi",
+ "ADMIN syrk",
+ "INFO csd.bu.edu",
+ "INFO Angel",
+ "SQUERY irchelp :HELP privmsg",
+ "SQUERY dict@irc.fr :fr2en blaireau",
+ "WHO *.fi",
+ "WHO jto* o",
+ "WHOIS wiz",
+ "WHOIS eff.org trillian",
+ "WHOWAS Wiz",
+ "WHOWAS Mermaid 9",
+ "WHOWAS Trillian 1 *.edu",
+ "PING tolsun.oulu.fi",
+ "PING WiZ tolsun.oulu.fi",
+ // Below fails, we don't use the (unnecessary) colon.
+ // "PING :irc.funet.fi",
+ "PONG csd.bu.edu tolsun.oulu.fi",
+ "ERROR :Server *.fi already exists",
+ "NOTICE WiZ :ERROR from csd.bu.edu -- Server *.fi already exists",
+ "AWAY :Gone to lunch. Back in 5",
+ "REHASH",
+ "DIE",
+ "RESTART",
+ "SUMMON jto",
+ "SUMMON jto tolsun.oulu.fi",
+ "USERS eff.org",
+ ":csd.bu.edu WALLOPS :Connect '*.uiuc.edu 6667' from Joshua",
+ "USERHOST Wiz Michael syrk",
+ // Below fails, we don't use the (unnecessary) colon.
+ // ":ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net",
+ "ISON phone trillian WiZ jarlek Avalon Angel Monstah syrk",
+
+ // Now for the torture test, specially crafted messages that might be
+ // "difficult" to handle.
+ "PRIVMSG foo ::)", // Test sending a colon as the first character.
+ "PRIVMSG foo :This is a test.", // Test sending a space.
+ "PRIVMSG foo :", // Empty last parameter.
+ "PRIVMSG foo :This is :a test.", // A "second" last parameter.
+];
+
+function run_test() {
+ add_test(testRFC2812Messages);
+ add_test(testBrokenUnrealMessages);
+ add_test(testNewLinesInMessages);
+ add_test(testLocalhost);
+ add_test(testTags);
+
+ run_next_test();
+}
+
+/*
+ * Test round tripping parsing and then rebuilding the messages from RFC 2812.
+ */
+function testRFC2812Messages() {
+ for (let expectedStringMessage of testData) {
+ // Pass in an empty default origin in order to check this below.
+ let message = ircMessage(expectedStringMessage, "");
+
+ let stringMessage = ircAccount.prototype.buildMessage(
+ message.command,
+ message.params
+ );
+
+ // Let's do a little dance here...we don't rebuild the "source" of the
+ // message (the server does that), so when comparing our output message, we
+ // need to avoid comparing to that part.
+ if (message.origin) {
+ expectedStringMessage = expectedStringMessage.slice(
+ expectedStringMessage.indexOf(" ") + 1
+ );
+ }
+
+ equal(stringMessage, expectedStringMessage);
+ }
+
+ run_next_test();
+}
+
+// Unreal sends a couple of broken messages, see ircMessage in irc.jsm for a
+// description of what's wrong.
+function testBrokenUnrealMessages() {
+ let messages = {
+ // Two spaces after command.
+ ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters": {
+ rawMessage:
+ ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters",
+ command: "432",
+ params: ["", "#momo", "Erroneous Nickname: Illegal characters"],
+ origin: "gravel.mozilla.org",
+ user: undefined,
+ host: undefined,
+ source: "",
+ tags: new Map(),
+ },
+ // An extraneous space at the end.
+ ":gravel.mozilla.org MODE #tckk +n ": {
+ rawMessage: ":gravel.mozilla.org MODE #tckk +n ",
+ command: "MODE",
+ params: ["#tckk", "+n"],
+ origin: "gravel.mozilla.org",
+ user: undefined,
+ host: undefined,
+ source: "",
+ tags: new Map(),
+ },
+ // Two extraneous spaces at the end.
+ ":services.esper.net MODE #foo-bar +o foobar ": {
+ rawMessage: ":services.esper.net MODE #foo-bar +o foobar ",
+ command: "MODE",
+ params: ["#foo-bar", "+o", "foobar"],
+ origin: "services.esper.net",
+ user: undefined,
+ host: undefined,
+ source: "",
+ tags: new Map(),
+ },
+ };
+
+ for (let messageStr in messages) {
+ deepEqual(messages[messageStr], ircMessage(messageStr, ""));
+ }
+
+ run_next_test();
+}
+
+// After unescaping we can end up with line breaks inside of IRC messages. Test
+// this edge case specifically.
+function testNewLinesInMessages() {
+ let messages = {
+ ":test!Instantbir@host PRIVMSG #instantbird :First line\nSecond line": {
+ rawMessage:
+ ":test!Instantbir@host PRIVMSG #instantbird :First line\nSecond line",
+ command: "PRIVMSG",
+ params: ["#instantbird", "First line\nSecond line"],
+ origin: "test",
+ user: "Instantbir",
+ host: "host",
+ tags: new Map(),
+ source: "Instantbir@host",
+ },
+ ":test!Instantbir@host PRIVMSG #instantbird :First line\r\nSecond line": {
+ rawMessage:
+ ":test!Instantbir@host PRIVMSG #instantbird :First line\r\nSecond line",
+ command: "PRIVMSG",
+ params: ["#instantbird", "First line\r\nSecond line"],
+ origin: "test",
+ user: "Instantbir",
+ host: "host",
+ tags: new Map(),
+ source: "Instantbir@host",
+ },
+ };
+
+ for (let messageStr in messages) {
+ deepEqual(messages[messageStr], ircMessage(messageStr));
+ }
+
+ run_next_test();
+}
+
+// Sometimes it is a bit hard to tell whether a prefix is a nickname or a
+// servername. Generally this happens when connecting to localhost or a local
+// hostname and is likely seen with bouncers.
+function testLocalhost() {
+ let messages = {
+ ":localhost 001 clokep :Welcome to the BitlBee gateway, clokep": {
+ rawMessage:
+ ":localhost 001 clokep :Welcome to the BitlBee gateway, clokep",
+ command: "001",
+ params: ["clokep", "Welcome to the BitlBee gateway, clokep"],
+ origin: "localhost",
+ user: undefined,
+ host: undefined,
+ tags: new Map(),
+ source: "",
+ },
+ };
+
+ for (let messageStr in messages) {
+ deepEqual(messages[messageStr], ircMessage(messageStr));
+ }
+
+ run_next_test();
+}
+
+function testTags() {
+ let messages = {
+ "@aaa=bBb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello": {
+ rawMessage:
+ "@aaa=bBb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello",
+ command: "PRIVMSG",
+ params: ["me", "Hello"],
+ origin: "nick",
+ user: "ident",
+ host: "host.com",
+ tags: new Map([
+ ["aaa", "bBb"],
+ ["ccc", undefined],
+ ["example.com/ddd", "eee"],
+ ]),
+ source: "ident@host.com",
+ },
+ "@xn--e1afmkfd.org/foo :nick@host.com PRIVMSG him :Test": {
+ rawMessage: "@xn--e1afmkfd.org/foo :nick@host.com PRIVMSG him :Test",
+ command: "PRIVMSG",
+ params: ["him", "Test"],
+ origin: "nick",
+ // Note that this is a bug, it should be undefined for user and host.com
+ // for host/source.
+ user: "host.com",
+ host: undefined,
+ tags: new Map([["xn--e1afmkfd.org/foo", undefined]]),
+ source: "host.com@undefined",
+ },
+ "@aaa=\\\\n\\:\\n\\r\\s :nick@host.com PRIVMSG it :Yes": {
+ rawMessage: "@aaa=\\\\n\\:\\n\\r\\s :nick@host.com PRIVMSG it :Yes",
+ command: "PRIVMSG",
+ params: ["it", "Yes"],
+ origin: "nick",
+ // Note that this is a bug, it should be undefined for user and host.com
+ // for host/source.
+ user: "host.com",
+ host: undefined,
+ tags: new Map([["aaa", "\\n;\n\r "]]),
+ source: "host.com@undefined",
+ },
+ "@c;h=;a=b :quux ab cd": {
+ rawMessage: "@c;h=;a=b :quux ab cd",
+ command: "ab",
+ params: ["cd"],
+ origin: "quux",
+ user: undefined,
+ host: undefined,
+ tags: new Map([
+ ["c", undefined],
+ ["h", ""],
+ ["a", "b"],
+ ]),
+ source: "",
+ },
+ "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan": {
+ rawMessage:
+ "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan",
+ command: "JOIN",
+ params: ["#chan"],
+ origin: "John",
+ user: "~john",
+ host: "1.2.3.4",
+ tags: new Map([["time", "2012-06-30T23:59:60.419Z"]]),
+ source: "~john@1.2.3.4",
+ },
+ };
+
+ for (let messageStr in messages) {
+ deepEqual(messages[messageStr], ircMessage(messageStr, ""));
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_ircNonStandard.js b/comm/chat/protocols/irc/test/test_ircNonStandard.js
new file mode 100644
index 0000000000..bcc445e661
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircNonStandard.js
@@ -0,0 +1,209 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ircMessage } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+const { ircNonStandard } = ChromeUtils.importESModule(
+ "resource:///modules/ircNonStandard.sys.mjs"
+);
+
+// The function that is under test here.
+var NOTICE = ircNonStandard.commands.NOTICE;
+
+function FakeConversation() {}
+FakeConversation.prototype = {
+ writeMessage(aSender, aTarget, aOpts) {},
+};
+
+function FakeAccount(aPassword) {
+ this.imAccount = {
+ password: aPassword,
+ };
+ this.buffer = [];
+ this.convs = [];
+}
+FakeAccount.prototype = {
+ connected: false,
+ shouldAuthenticate: undefined,
+ _nickname: "nick", // Can be anything except "auth" for most tests.
+ sendMessage(aCommand, aParams) {
+ this.buffer.push([aCommand, aParams]);
+ },
+ gotDisconnected(aReason, aMsg) {
+ this.connected = false;
+ },
+ getConversation(aName) {
+ this.convs.push(aName);
+ return new FakeConversation();
+ },
+};
+
+function run_test() {
+ add_test(testSecureList);
+ add_test(testZncAuth);
+ add_test(testUMich);
+ add_test(testAuthNick);
+ add_test(testIgnoredNotices);
+
+ run_next_test();
+}
+
+/*
+ * Test that SECURELIST properly sets the timer such that another LIST call can
+ * happen soon. See bug 1082501.
+ */
+function testSecureList() {
+ const kSecureListMsg =
+ ":fripp.mozilla.org NOTICE aleth-build :*** You cannot list within the first 60 seconds of connecting. Please try again later.";
+
+ let message = ircMessage(kSecureListMsg, "");
+ let account = new FakeAccount();
+ account.connected = true;
+ let result = NOTICE.call(account, message);
+
+ // Yes, it was handled.
+ ok(result);
+
+ // Undo the expected calculation, this should be near 0.
+ let value = account._lastListTime - Date.now() - 60000 + 12 * 60 * 60 * 1000;
+ // Give some wiggle room.
+ less(Math.abs(value), 5 * 1000);
+
+ run_next_test();
+}
+
+/*
+ * ZNC allows a client to send PASS after connection has occurred if it has not
+ * yet been provided. See bug 955244, bug 1197584.
+ */
+function testZncAuth() {
+ const kZncMsgs = [
+ ":irc.znc.in NOTICE AUTH :*** You need to send your password. Try /quote PASS <username>:<password>",
+ ":irc.znc.in NOTICE AUTH :*** You need to send your password. Configure your client to send a server password.",
+ ];
+
+ for (let msg of kZncMsgs) {
+ let message = ircMessage(msg, "");
+ // No provided password.
+ let account = new FakeAccount();
+ let result = NOTICE.call(account, message);
+
+ // Yes, it was handled.
+ Assert.ok(result);
+
+ // No sent data and parameters should be unchanged.
+ equal(account.buffer.length, 0);
+ equal(account.shouldAuthenticate, undefined);
+
+ // With a password.
+ account = new FakeAccount("password");
+ result = NOTICE.call(account, message);
+
+ // Yes, it was handled.
+ ok(result);
+
+ // Check if the proper message was sent.
+ let sent = account.buffer[0];
+ equal(sent[0], "PASS");
+ equal(sent[1], "password");
+ equal(account.buffer.length, 1);
+
+ // Don't try to authenticate with NickServ.
+ equal(account.shouldAuthenticate, false);
+
+ // Finally, check if the message is wrong.
+ account = new FakeAccount("password");
+ message.params[1] = "Test";
+ result = NOTICE.call(account, message);
+
+ // This would be handled as a normal NOTICE.
+ equal(result, false);
+ }
+
+ run_next_test();
+}
+
+/*
+ * irc.umich.edu sends a lot of garbage and has a non-standard captcha. See bug
+ * 954350.
+ */
+function testUMich() {
+ // The above should not print out.
+ const kMsgs = [
+ "NOTICE AUTH :*** Processing connection to irc.umich.edu",
+ "NOTICE AUTH :*** Looking up your hostname...",
+ "NOTICE AUTH :*** Checking Ident",
+ "NOTICE AUTH :*** Found your hostname",
+ "NOTICE AUTH :*** No Ident response",
+ ];
+
+ const kFinalMsg =
+ ':irc.umich.edu NOTICE clokep :To complete your connection to this server, type "/QUOTE PONG :cookie", where cookie is the following ascii.';
+
+ let account = new FakeAccount();
+ for (let msg of kMsgs) {
+ let message = ircMessage(msg, "");
+ let result = NOTICE.call(account, message);
+
+ // These initial notices are not handled (i.e. they'll be subject to
+ // _showServerTab).
+ equal(result, false);
+ }
+
+ // And finally the last one should be printed out, always. It contains the
+ // directions of what to do next.
+ let message = ircMessage(kFinalMsg, "");
+ let result = NOTICE.call(account, message);
+ ok(result);
+ equal(account.convs.length, 1);
+ equal(account.convs[0], "irc.umich.edu");
+
+ run_next_test();
+}
+
+/*
+ * Test an edge-case of the user having the nickname of auth. See bug 1083768.
+ */
+function testAuthNick() {
+ const kMsg =
+ ':irc.umich.edu NOTICE AUTH :To complete your connection to this server, type "/QUOTE PONG :cookie", where cookie is the following ascii.';
+
+ let account = new FakeAccount();
+ account._nickname = "AUTH";
+
+ let message = ircMessage(kMsg, "");
+ let result = NOTICE.call(account, message);
+
+ // Since it is ambiguous if it was an authentication message or a message
+ // directed at the user, print it out.
+ ok(result);
+
+ run_next_test();
+}
+
+/*
+ * We ignore some messages that are annoying to the user and offer little value.
+ * "Ignore" in this context means subject to the normal NOTICE processing.
+ */
+function testIgnoredNotices() {
+ const kMsgs = [
+ // moznet sends a welcome message which is useless.
+ ":levin.mozilla.org NOTICE Auth :Welcome to \u0002Mozilla\u0002!",
+ // Some servers (oftc) send a NOTICE that isn't an auth, but notifies about
+ // the connection. See bug 1182735.
+ ":beauty.oftc.net NOTICE myusername :*** Connected securely via UNKNOWN AES128-SHA-128",
+ ];
+
+ for (let msg of kMsgs) {
+ let account = new FakeAccount();
+
+ let message = ircMessage(msg, "");
+ let result = NOTICE.call(account, message);
+
+ // This message should *NOT* be shown.
+ equal(result, false);
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_ircProtocol.js b/comm/chat/protocols/irc/test/test_ircProtocol.js
new file mode 100644
index 0000000000..f4394b4115
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircProtocol.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ircProtocol } = ChromeUtils.importESModule(
+ "resource:///modules/irc.sys.mjs"
+);
+
+add_task(function test_splitUsername() {
+ const bareUsername = "foobar";
+ const bareSplit = ircProtocol.prototype.splitUsername(bareUsername);
+ deepEqual(bareSplit, []);
+
+ const fullAccountName = "foobar@example.com";
+ const fullSplit = ircProtocol.prototype.splitUsername(fullAccountName);
+ deepEqual(fullSplit, ["foobar", "example.com"]);
+
+ const extraAt = "foo@bar@example.com";
+ const extraSplit = ircProtocol.prototype.splitUsername(extraAt);
+ deepEqual(extraSplit, ["foo@bar", "example.com"]);
+});
diff --git a/comm/chat/protocols/irc/test/test_ircServerTime.js b/comm/chat/protocols/irc/test/test_ircServerTime.js
new file mode 100644
index 0000000000..9f91ab7432
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_ircServerTime.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { tagServerTime } = ChromeUtils.importESModule(
+ "resource:///modules/ircServerTime.sys.mjs"
+);
+var { ircMessage } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+function getTags(aRawMsg) {
+ const { tags } = ircMessage(aRawMsg, "does.not@matter");
+
+ return tags;
+}
+
+function run_test() {
+ add_test(specMessages);
+
+ run_next_test();
+}
+
+function specMessages() {
+ const kMessages = [
+ {
+ tags: getTags(
+ "@time=2011-10-19T16:40:51.620Z :Angel!angel@example.com PRIVMSG #test :Hello"
+ ),
+ who: "Angel!angel@example.com",
+ get originalMessage() {
+ return "Hello";
+ },
+ message: "Hello",
+ incoming: true,
+ },
+ {
+ tags: getTags(
+ "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan"
+ ),
+ who: "John!~john@1.2.3.4",
+ message: "John joined #chan",
+ get originalMessage() {
+ return "John joined #chan";
+ },
+ system: true,
+ incoming: true,
+ },
+ {
+ tags: getTags(
+ "@znc.in/server-time-iso=2016-11-13T19:20:45.284Z :John!~john@1.2.3.4 JOIN #chan"
+ ),
+ who: "John!~john@1.2.3.4",
+ message: "John joined #chan",
+ get originalMessage() {
+ return "John joined #chan";
+ },
+ system: true,
+ incoming: true,
+ },
+ {
+ tags: getTags("@time= :empty!Empty@host.local JOIN #test"),
+ who: "empty!Empty@localhost",
+ message: "Empty joined #test",
+ get originalMessage() {
+ return "Empty joined #test";
+ },
+ system: true,
+ incoming: true,
+ },
+ {
+ tags: getTags("NoTags!notags@1.2.3.4 PART #test"),
+ who: "NoTags!notags@1.2.3.4",
+ message: "NoTags left #test",
+ get originalMessage() {
+ return "NoTags left #test";
+ },
+ system: true,
+ incoming: true,
+ },
+ ];
+
+ const kExpectedTimes = [
+ Math.floor(Date.parse(kMessages[0].tags.get("time")) / 1000),
+ Math.floor(Date.parse("2012-06-30T23:59:59.999Z") / 1000),
+ Math.floor(
+ Date.parse(kMessages[2].tags.get("znc.in/server-time-iso")) / 1000
+ ),
+ undefined,
+ undefined,
+ ];
+
+ for (let m in kMessages) {
+ const msg = kMessages[m];
+ const isZNC = kMessages[m].tags.has("znc.in/server-time-iso");
+ const tag = isZNC ? "znc.in/server-time-iso" : "time";
+ const tagMessage = {
+ message: Object.assign({}, msg),
+ tagName: tag,
+ tagValue: msg.tags.get(tag),
+ };
+ tagServerTime.commands[tag](tagMessage);
+
+ // Ensuring that the expected properties and their values as given in
+ // kMessages are still the same after the handler.
+ for (let i in msg) {
+ equal(
+ tagMessage.message[i],
+ msg[i],
+ "Property '" + i + "' was not modified"
+ );
+ }
+ // The time should only be adjusted when we expect a valid server-time tag.
+ equal(
+ "time" in tagMessage.message,
+ kExpectedTimes[m] !== undefined,
+ "Message time was set when expected"
+ );
+
+ if (kExpectedTimes[m] !== undefined) {
+ ok(tagMessage.message.delayed, "Delayed flag was set");
+ equal(
+ kExpectedTimes[m],
+ tagMessage.message.time,
+ "Time was parsed properly"
+ );
+ }
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_sendBufferedCommand.js b/comm/chat/protocols/irc/test/test_sendBufferedCommand.js
new file mode 100644
index 0000000000..5558979db3
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_sendBufferedCommand.js
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ircAccount } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+var { clearTimeout, setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+function FakeAccount() {
+ this._commandBuffers = new Map();
+ this.callbacks = [];
+}
+FakeAccount.prototype = {
+ __proto__: ircAccount.prototype,
+ maxMessageLength: 60,
+ callbacks: [],
+ sendMessage(aCommand, aParams) {
+ this.callbacks.shift()(aCommand, aParams);
+ },
+};
+
+var account = new FakeAccount();
+
+function run_test() {
+ test_parameterCollect();
+ test_maxLength();
+ run_next_test();
+}
+
+function test_parameterCollect() {
+ // Individual tests, data consisting of [channel, key] pairs.
+ let tests = [
+ {
+ data: [["one"], ["one"]], // also tests deduplication
+ result: "JOIN one",
+ },
+ {
+ data: [["one", ""]], // explicit empty password string
+ result: "JOIN one",
+ },
+ {
+ data: [["one"], ["two"], ["three"]],
+ result: "JOIN one,two,three",
+ },
+ {
+ data: [["one"], ["two", "password"], ["three"]],
+ result: "JOIN two,one,three password",
+ },
+ {
+ data: [
+ ["one"],
+ ["two", "password"],
+ ["three"],
+ ["four", "anotherpassword"],
+ ],
+ result: "JOIN two,four,one,three password,anotherpassword",
+ },
+ ];
+
+ for (let test of tests) {
+ let timeout;
+ // Destructure test to local variables so each function
+ // generated here gets the correct value in its scope.
+ let { data, result } = test;
+ account.callbacks.push((aCommand, aParams) => {
+ let msg = account.buildMessage(aCommand, aParams);
+ equal(msg, result, "Test buffering of parameters");
+ clearTimeout(timeout);
+ account._lastCommandSendTime = 0;
+ run_next_test();
+ });
+ add_test(() => {
+ // This timeout lets the test fail more quickly if
+ // some of the callbacks we added don't get called.
+ // Not strictly speaking necessary.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timeout = setTimeout(() => {
+ ok(false, "test_parameterCollect failed after timeout.");
+ run_next_test();
+ }, 2000);
+ for (let [channel, key] of data) {
+ account.sendBufferedCommand("JOIN", channel, key);
+ }
+ });
+ }
+
+ // Test this still works when adding commands on different ticks of
+ // the event loop.
+ account._lastCommandSendTime = 0;
+ for (let test of tests) {
+ let timeout;
+ let { data, result } = test;
+ account.callbacks.push((aCommand, aParams) => {
+ let msg = account.buildMessage(aCommand, aParams);
+ equal(msg, result, "Test buffering with setTimeout");
+ clearTimeout(timeout);
+ run_next_test();
+ });
+ add_test(() => {
+ // This timeout lets the test fail more quickly if
+ // some of the callbacks we added don't get called.
+ // Not strictly speaking necessary.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timeout = setTimeout(() => {
+ ok(false, "test_parameterCollect failed after timeout.");
+ run_next_test();
+ }, 2000);
+ let delay = 0;
+ for (let params of data) {
+ let [channel, key] = params;
+ delay += 200;
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ account.sendBufferedCommand("JOIN", channel, key);
+ }, delay);
+ }
+ });
+ }
+}
+
+function test_maxLength() {
+ let tests = [
+ {
+ data: [
+ ["applecustard"],
+ ["pearpie"],
+ ["strawberryfield"],
+ ["blueberrypancake"],
+ ["mangojuice"],
+ ["raspberryberet"],
+ ["pineapplesoup"],
+ ["limejelly"],
+ ["lemonsorbet"],
+ ],
+ results: [
+ "JOIN applecustard,pearpie,strawberryfield,blueberrypancake",
+ "JOIN mangojuice,raspberryberet,pineapplesoup,limejelly",
+ "JOIN lemonsorbet",
+ ],
+ },
+ {
+ data: [
+ ["applecustard"],
+ ["pearpie"],
+ ["strawberryfield", "password1"],
+ ["blueberrypancake"],
+ ["mangojuice"],
+ ["raspberryberet"],
+ ["pineapplesoup"],
+ ["limejelly", "password2"],
+ ["lemonsorbet"],
+ ],
+ results: [
+ "JOIN strawberryfield,applecustard,pearpie password1",
+ "JOIN blueberrypancake,mangojuice,raspberryberet",
+ "JOIN limejelly,pineapplesoup,lemonsorbet password2",
+ ],
+ },
+ ];
+
+ account._lastCommandSendTime = 0;
+ for (let test of tests) {
+ let timeout;
+ // Destructure test to local variables so each function
+ // generated here gets the correct value in its scope.
+ let { data, results } = test;
+ for (let r of results) {
+ let result = r;
+ account.callbacks.push((aCommand, aParams) => {
+ let msg = account.buildMessage(aCommand, aParams);
+ equal(msg, result, "Test maximum message length constraint");
+ // After all results are checked, run the next test.
+ if (result == results[results.length - 1]) {
+ clearTimeout(timeout);
+ run_next_test();
+ }
+ });
+ }
+ add_test(() => {
+ // This timeout lets the test fail more quickly if
+ // some of the callbacks we added don't get called.
+ // Not strictly speaking necessary.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timeout = setTimeout(() => {
+ ok(false, "test_maxLength failed after timeout.");
+ run_next_test();
+ }, 2000);
+ for (let [channel, key] of data) {
+ account.sendBufferedCommand("JOIN", channel, key);
+ }
+ });
+ }
+}
diff --git a/comm/chat/protocols/irc/test/test_setMode.js b/comm/chat/protocols/irc/test/test_setMode.js
new file mode 100644
index 0000000000..9a329beaa5
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_setMode.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { ircAccount, ircChannel } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+IMServices.conversations.initConversations();
+
+function FakeAccount() {
+ this.normalizeNick = ircAccount.prototype.normalizeNick.bind(this);
+}
+FakeAccount.prototype = {
+ __proto__: ircAccount.prototype,
+ setWhois: (n, f) => true,
+ ERROR: do_throw,
+};
+
+function run_test() {
+ add_test(test_topicSettable);
+ add_test(test_topicSettableJoinAsOp);
+
+ run_next_test();
+}
+
+// Test joining a channel, then being set as op.
+function test_topicSettable() {
+ let channel = new ircChannel(new FakeAccount(), "#test", "nick");
+ // We're not in the room yet, so the topic is NOT editable.
+ equal(channel.topicSettable, false);
+
+ // Join the room.
+ channel.getParticipant("nick");
+ // The topic should be editable.
+ equal(channel.topicSettable, true);
+
+ // Receive the channel mode.
+ channel.setMode("+t", [], "ChanServ");
+ // Mode +t means that you need status to set the mode.
+ equal(channel.topicSettable, false);
+
+ // Receive a user mode.
+ channel.setMode("+o", ["nick"], "ChanServ");
+ // Nick is now an op and can set the topic!
+ equal(channel.topicSettable, true);
+
+ run_next_test();
+}
+
+// Test when you join as an op (as opposed to being set to op after joining).
+function test_topicSettableJoinAsOp() {
+ let channel = new ircChannel(new FakeAccount(), "#test", "nick");
+ // We're not in the room yet, so the topic is NOT editable.
+ equal(channel.topicSettable, false);
+
+ // Join the room as an op.
+ channel.getParticipant("@nick");
+ // The topic should be editable.
+ equal(channel.topicSettable, true);
+
+ // Receive the channel mode.
+ channel.setMode("+t", [], "ChanServ");
+ // The topic should still be editable.
+ equal(channel.topicSettable, true);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/test_splitLongMessages.js b/comm/chat/protocols/irc/test/test_splitLongMessages.js
new file mode 100644
index 0000000000..b507d4ec99
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_splitLongMessages.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { GenericIRCConversation, ircAccount } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+var messages = {
+ // Exactly 51 characters.
+ "This is a test.": ["This is a test."],
+ // Too long.
+ "This is a message that is too long.": [
+ "This is a",
+ "message that is",
+ "too long.",
+ ],
+ // Too short.
+ "Short msg.": ["Short msg."],
+ "Thismessagecan'tbecut.": ["Thismessagecan'", "tbecut."],
+};
+
+function run_test() {
+ for (let message in messages) {
+ let msg = { message };
+ let generatedMsgs = GenericIRCConversation.prepareForSending.call(
+ {
+ __proto__: GenericIRCConversation,
+ name: "target",
+ _account: {
+ __proto__: ircAccount.prototype,
+ _nickname: "sender",
+ prefix: "!user@host",
+ maxMessageLength: 51, // For convenience.
+ },
+ },
+ msg
+ );
+
+ // The expected messages as defined above.
+ let expectedMsgs = messages[message];
+ // Ensure the arrays are equal.
+ deepEqual(generatedMsgs, expectedMsgs);
+ }
+}
diff --git a/comm/chat/protocols/irc/test/test_tryNewNick.js b/comm/chat/protocols/irc/test/test_tryNewNick.js
new file mode 100644
index 0000000000..dbd2692d4c
--- /dev/null
+++ b/comm/chat/protocols/irc/test/test_tryNewNick.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { ircProtocol } = ChromeUtils.importESModule(
+ "resource:///modules/irc.sys.mjs"
+);
+var { ircAccount } = ChromeUtils.importESModule(
+ "resource:///modules/ircAccount.sys.mjs"
+);
+
+var fakeProto = {
+ id: "fake-proto",
+ options: { alternateNicks: "" },
+ _getOptionDefault(aOption) {
+ return this.options[aOption];
+ },
+ usernameSplits: ircProtocol.prototype.usernameSplits,
+ splitUsername: ircProtocol.prototype.splitUsername,
+};
+
+function test_tryNewNick() {
+ const testData = {
+ clokep: "clokep1",
+ clokep1: "clokep2",
+ clokep10: "clokep11",
+ clokep0: "clokep1",
+ clokep01: "clokep02",
+ clokep09: "clokep10",
+
+ // Now put a number in the "first part".
+ clo1kep: "clo1kep1",
+ clo1kep1: "clo1kep2",
+ clo1kep10: "clo1kep11",
+ clo1kep0: "clo1kep1",
+ clo1kep01: "clo1kep02",
+ clo1kep09: "clo1kep10",
+ };
+
+ let account = new ircAccount(fakeProto, {
+ name: "clokep@instantbird.org",
+ });
+ account.LOG = function (aStr) {};
+ account.normalize = aStr => aStr;
+
+ for (let currentNick in testData) {
+ account._sentNickname = currentNick;
+ account.sendMessage = (aCommand, aNewNick) =>
+ equal(aNewNick, testData[currentNick]);
+
+ account.tryNewNick(currentNick);
+ }
+
+ run_next_test();
+}
+
+// This tests a bunch of cases near the max length by maintaining the state
+// through a series of test nicks.
+function test_maxLength() {
+ let testData = [
+ // First try adding a digit, as normal.
+ ["abcdefghi", "abcdefghi1"],
+ // The "received" nick back will now be the same though, so it was too long.
+ ["abcdefghi", "abcdefgh1"],
+ // And just ensure we're iterating properly.
+ ["abcdefgh1", "abcdefgh2"],
+ ["abcdefgh2", "abcdefgh3"],
+ ["abcdefgh3", "abcdefgh4"],
+ ["abcdefgh4", "abcdefgh5"],
+ ["abcdefgh5", "abcdefgh6"],
+ ["abcdefgh6", "abcdefgh7"],
+ ["abcdefgh7", "abcdefgh8"],
+ ["abcdefgh8", "abcdefgh9"],
+ ["abcdefgh9", "abcdefgh10"],
+ ["abcdefgh1", "abcdefg10"],
+ ["abcdefg10", "abcdefg11"],
+ ["abcdefg99", "abcdefg100"],
+ ["abcdefg10", "abcdef100"],
+ ["a99999999", "a100000000"],
+ ["a10000000", "a00000000"],
+ ];
+
+ let account = new ircAccount(fakeProto, {
+ name: "clokep@instantbird.org",
+ });
+ account.LOG = function (aStr) {};
+ account._sentNickname = "abcdefghi";
+ account.normalize = aStr => aStr;
+
+ for (let currentNick of testData) {
+ account.sendMessage = (aCommand, aNewNick) =>
+ equal(aNewNick, currentNick[1]);
+
+ account.tryNewNick(currentNick[0]);
+ }
+
+ run_next_test();
+}
+
+function test_altNicks() {
+ const altNicks = ["clokep_", "clokep|"];
+ const testData = {
+ // Test account nick.
+ clokep: [altNicks, "clokep_"],
+ // Test first element in list.
+ clokep_: [altNicks, "clokep|"],
+ // Test last element in list.
+ "clokep|": [altNicks, "clokep|1"],
+ // Test element not in list with number at end.
+ clokep1: [altNicks, "clokep2"],
+
+ // Test messy alternatives.
+ "clokep[": [" clokep ,\n clokep111,,,\tclokep[, clokep_", "clokep_"],
+ };
+
+ let account = new ircAccount(fakeProto, {
+ name: "clokep@instantbird.org",
+ });
+ account.LOG = function (aStr) {};
+ account.normalize = aStr => aStr;
+
+ for (let currentNick in testData) {
+ // Only one pref is touched in here, override the default to return
+ // what this test needs.
+ account.getString = function (aStr) {
+ let data = testData[currentNick][0];
+ if (Array.isArray(data)) {
+ return data.join(",");
+ }
+ return data;
+ };
+ account._sentNickname = currentNick;
+
+ account.sendMessage = (aCommand, aNewNick) =>
+ equal(aNewNick, testData[currentNick][1]);
+
+ account.tryNewNick(currentNick);
+ }
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(test_tryNewNick);
+ add_test(test_maxLength);
+ add_test(test_altNicks);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/irc/test/xpcshell.ini b/comm/chat/protocols/irc/test/xpcshell.ini
new file mode 100644
index 0000000000..1f2e8bf907
--- /dev/null
+++ b/comm/chat/protocols/irc/test/xpcshell.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+head =
+tail =
+
+[test_ctcpFormatting.js]
+[test_ctcpColoring.js]
+[test_ctcpDequote.js]
+[test_ctcpQuote.js]
+[test_ircCAP.js]
+[test_ircChannel.js]
+[test_ircCommands.js]
+[test_ircMessage.js]
+[test_ircNonStandard.js]
+[test_ircServerTime.js]
+[test_sendBufferedCommand.js]
+[test_setMode.js]
+[test_splitLongMessages.js]
+[test_tryNewNick.js]
diff --git a/comm/chat/protocols/jsTest/components.conf b/comm/chat/protocols/jsTest/components.conf
new file mode 100644
index 0000000000..ae73392491
--- /dev/null
+++ b/comm/chat/protocols/jsTest/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{a0774c5a-4aea-458b-9fbc-8d3cbf1a4630}',
+ 'contract_ids': ['@mozilla.org/chat/jstest;1'],
+ 'esModule': 'resource:///modules/jsTestProtocol.sys.mjs',
+ 'constructor': 'JSTestProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-jstest'},
+ },
+]
diff --git a/comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs b/comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs
new file mode 100644
index 0000000000..52f749ca92
--- /dev/null
+++ b/comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericConvIMPrototype,
+ GenericProtocolPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+function Conversation(aAccount) {
+ this._init(aAccount);
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ _disconnected: false,
+ _setDisconnected() {
+ this._disconnected = true;
+ },
+ close() {
+ if (!this._disconnected) {
+ this.account.disconnect(true);
+ }
+ },
+ dispatchMessage(aMsg) {
+ if (this._disconnected) {
+ this.writeMessage(
+ "jstest",
+ "This message could not be sent because the conversation is no longer active: " +
+ aMsg,
+ { system: true, error: true }
+ );
+ return;
+ }
+
+ this.writeMessage("You", aMsg, { outgoing: true });
+ this.writeMessage("/dev/null", "Thanks! I appreciate your attention.", {
+ incoming: true,
+ autoResponse: true,
+ });
+ },
+
+ get name() {
+ return "/dev/null";
+ },
+};
+
+function Account(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+}
+Account.prototype = {
+ __proto__: GenericAccountPrototype,
+ connect() {
+ this.reportConnecting();
+ // do something here
+ this.reportConnected();
+ setTimeout(
+ function () {
+ this._conv = new Conversation(this);
+ this._conv.writeMessage("jstest", "You are now talking to /dev/null", {
+ system: true,
+ });
+ }.bind(this),
+ 0
+ );
+ },
+ _conv: null,
+ disconnect(aSilent) {
+ this.reportDisconnecting(Ci.prplIAccount.NO_ERROR, "");
+ if (!aSilent) {
+ this._conv.writeMessage("jstest", "You have disconnected.", {
+ system: true,
+ });
+ }
+ if (this._conv) {
+ this._conv._setDisconnected();
+ delete this._conv;
+ }
+ this.reportDisconnected();
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ channel: { label: "_Channel Field", required: true },
+ channelDefault: { label: "_Field with default", default: "Default Value" },
+ password: {
+ label: "_Password Field",
+ default: "",
+ isPassword: true,
+ required: false,
+ },
+ sampleIntField: {
+ label: "_Int Field",
+ default: 4,
+ min: 0,
+ max: 10,
+ required: true,
+ },
+ },
+
+ // Nothing to do.
+ unInit() {},
+ remove() {},
+};
+
+export function JSTestProtocol() {}
+JSTestProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get id() {
+ return "prpl-jstest";
+ },
+ get normalizedName() {
+ return "jstest";
+ },
+ get name() {
+ return "JS Test";
+ },
+ options: {
+ text: { label: "Text option", default: "foo" },
+ bool: { label: "Boolean option", default: true },
+ int: { label: "Integer option", default: 42 },
+ list: {
+ label: "Select option",
+ default: "option2",
+ listValues: {
+ option1: "First option",
+ option2: "Default option",
+ option3: "Other option",
+ },
+ },
+ },
+ usernameSplits: [
+ {
+ label: "Server",
+ separator: "@",
+ defaultValue: "default.server",
+ },
+ ],
+ getAccount(aImAccount) {
+ return new Account(this, aImAccount);
+ },
+};
diff --git a/comm/chat/protocols/jsTest/moz.build b/comm/chat/protocols/jsTest/moz.build
new file mode 100644
index 0000000000..87f7a792ed
--- /dev/null
+++ b/comm/chat/protocols/jsTest/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+if CONFIG["MOZ_DEBUG"]:
+ EXTRA_JS_MODULES += [
+ "jsTestProtocol.sys.mjs",
+ ]
+
+ XPCOM_MANIFESTS += [
+ "components.conf",
+ ]
diff --git a/comm/chat/protocols/matrix/components.conf b/comm/chat/protocols/matrix/components.conf
new file mode 100644
index 0000000000..8c934e4d0e
--- /dev/null
+++ b/comm/chat/protocols/matrix/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{e9653ac6-a671-11e6-bf84-60a44c717042}',
+ 'contract_ids': ['@mozilla.org/chat/matrix;1'],
+ 'esModule': 'resource:///modules/matrix.sys.mjs',
+ 'constructor': 'MatrixProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-matrix'},
+ },
+]
diff --git a/comm/chat/protocols/matrix/icons/README b/comm/chat/protocols/matrix/icons/README
new file mode 100644
index 0000000000..312ed548a0
--- /dev/null
+++ b/comm/chat/protocols/matrix/icons/README
@@ -0,0 +1,5 @@
+These icons have been generated from
+
+https://github.com/matrix-org/matrix.to/blob/master/img/matrix-logo.svg
+
+and are licensed under the Apache License Version 2.
diff --git a/comm/chat/protocols/matrix/icons/prpl-matrix-32.png b/comm/chat/protocols/matrix/icons/prpl-matrix-32.png
new file mode 100644
index 0000000000..09c8f4b531
--- /dev/null
+++ b/comm/chat/protocols/matrix/icons/prpl-matrix-32.png
Binary files differ
diff --git a/comm/chat/protocols/matrix/icons/prpl-matrix-48.png b/comm/chat/protocols/matrix/icons/prpl-matrix-48.png
new file mode 100644
index 0000000000..9f5b387ad8
--- /dev/null
+++ b/comm/chat/protocols/matrix/icons/prpl-matrix-48.png
Binary files differ
diff --git a/comm/chat/protocols/matrix/icons/prpl-matrix.png b/comm/chat/protocols/matrix/icons/prpl-matrix.png
new file mode 100644
index 0000000000..396258753d
--- /dev/null
+++ b/comm/chat/protocols/matrix/icons/prpl-matrix.png
Binary files differ
diff --git a/comm/chat/protocols/matrix/jar.mn b/comm/chat/protocols/matrix/jar.mn
new file mode 100644
index 0000000000..fb81d0d210
--- /dev/null
+++ b/comm/chat/protocols/matrix/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-matrix classic/1.0 %skin/classic/prpl/matrix/
+ skin/classic/prpl/matrix/icon32.png (icons/prpl-matrix-32.png)
+ skin/classic/prpl/matrix/icon48.png (icons/prpl-matrix-48.png)
+ skin/classic/prpl/matrix/icon.png (icons/prpl-matrix.png)
diff --git a/comm/chat/protocols/matrix/lib/@matrix-org/olm/LICENSE b/comm/chat/protocols/matrix/lib/@matrix-org/olm/LICENSE
new file mode 100644
index 0000000000..f433b1a53f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/@matrix-org/olm/LICENSE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.js b/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.js
new file mode 100644
index 0000000000..14347d0092
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.js
@@ -0,0 +1,163 @@
+// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
+// @source: https://gitlab.matrix.org/matrix-org/olm/-/tree/3.2.14
+
+var Olm = (function() {
+var olm_exports = {};
+var onInitSuccess;
+var onInitFail;
+
+var Module = (() => {
+ var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+ if (typeof __filename !== 'undefined') _scriptDir = _scriptDir || __filename;
+ return (
+function(Module) {
+ Module = Module || {};
+
+
+var a;a||(a=typeof Module !== 'undefined' ? Module : {});var aa,ca;a.ready=new Promise(function(b,c){aa=b;ca=c});var g;if("undefined"!==typeof window)g=function(b){window.crypto.getRandomValues(b)};else if(module.exports){var da=require("crypto");g=function(b){var c=da.randomBytes(b.length);b.set(c)};process=global.process}else throw Error("Cannot find global to attach library to");
+if("undefined"!==typeof OLM_OPTIONS)for(var ea in OLM_OPTIONS)OLM_OPTIONS.hasOwnProperty(ea)&&(a[ea]=OLM_OPTIONS[ea]);a.onRuntimeInitialized=function(){h=a._olm_error();olm_exports.PRIVATE_KEY_LENGTH=a._olm_pk_private_key_length();onInitSuccess&&onInitSuccess()};a.onAbort=function(b){onInitFail&&onInitFail(b)};
+var fa=Object.assign({},a),ha="object"==typeof window,l="function"==typeof importScripts,ia="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,m="",ja,ka,la,fs,ma,na;
+if(ia)m=l?require("path").dirname(m)+"/":__dirname+"/",na=()=>{ma||(fs=require("fs"),ma=require("path"))},ja=function(b,c){na();b=ma.normalize(b);return fs.readFileSync(b,c?void 0:"utf8")},la=b=>{b=ja(b,!0);b.buffer||(b=new Uint8Array(b));return b},ka=(b,c,d)=>{na();b=ma.normalize(b);fs.readFile(b,function(e,f){e?d(e):c(f.buffer)})},1<process.argv.length&&process.argv[1].replace(/\\/g,"/"),process.argv.slice(2),process.on("uncaughtException",function(b){throw b;}),process.on("unhandledRejection",
+function(b){throw b;}),a.inspect=function(){return"[Emscripten Module object]"};else if(ha||l)l?m=self.location.href:"undefined"!=typeof document&&document.currentScript&&(m=document.currentScript.src),_scriptDir&&(m=_scriptDir),0!==m.indexOf("blob:")?m=m.substr(0,m.replace(/[?#].*/,"").lastIndexOf("/")+1):m="",ja=b=>{var c=new XMLHttpRequest;c.open("GET",b,!1);c.send(null);return c.responseText},l&&(la=b=>{var c=new XMLHttpRequest;c.open("GET",b,!1);c.responseType="arraybuffer";c.send(null);return new Uint8Array(c.response)}),
+ka=(b,c,d)=>{var e=new XMLHttpRequest;e.open("GET",b,!0);e.responseType="arraybuffer";e.onload=()=>{200==e.status||0==e.status&&e.response?c(e.response):d()};e.onerror=d;e.send(null)};a.print||console.log.bind(console);var n=a.printErr||console.warn.bind(console);Object.assign(a,fa);fa=null;var q;a.wasmBinary&&(q=a.wasmBinary);var noExitRuntime=a.noExitRuntime||!0;"object"!=typeof WebAssembly&&r("no native wasm support detected");
+var oa,pa=!1,qa="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;
+function t(b,c){if(b){var d=u,e=b+c;for(c=b;d[c]&&!(c>=e);)++c;if(16<c-b&&d.buffer&&qa)b=qa.decode(d.subarray(b,c));else{for(e="";b<c;){var f=d[b++];if(f&128){var k=d[b++]&63;if(192==(f&224))e+=String.fromCharCode((f&31)<<6|k);else{var p=d[b++]&63;f=224==(f&240)?(f&15)<<12|k<<6|p:(f&7)<<18|k<<12|p<<6|d[b++]&63;65536>f?e+=String.fromCharCode(f):(f-=65536,e+=String.fromCharCode(55296|f>>10,56320|f&1023))}}else e+=String.fromCharCode(f)}b=e}}else b="";return b}
+function v(b,c,d,e){if(!(0<e))return 0;var f=d;e=d+e-1;for(var k=0;k<b.length;++k){var p=b.charCodeAt(k);if(55296<=p&&57343>=p){var w=b.charCodeAt(++k);p=65536+((p&1023)<<10)|w&1023}if(127>=p){if(d>=e)break;c[d++]=p}else{if(2047>=p){if(d+1>=e)break;c[d++]=192|p>>6}else{if(65535>=p){if(d+2>=e)break;c[d++]=224|p>>12}else{if(d+3>=e)break;c[d++]=240|p>>18;c[d++]=128|p>>12&63}c[d++]=128|p>>6&63}c[d++]=128|p&63}}c[d]=0;return d-f}
+function x(b){for(var c=0,d=0;d<b.length;++d){var e=b.charCodeAt(d);127>=e?c++:2047>=e?c+=2:55296<=e&&57343>=e?(c+=4,++d):c+=3}return c}var ra,y,u,sa,z,ta,ua,va;function wa(){var b=oa.buffer;ra=b;a.HEAP8=y=new Int8Array(b);a.HEAP16=sa=new Int16Array(b);a.HEAP32=z=new Int32Array(b);a.HEAPU8=u=new Uint8Array(b);a.HEAPU16=new Uint16Array(b);a.HEAPU32=ta=new Uint32Array(b);a.HEAPF32=ua=new Float32Array(b);a.HEAPF64=va=new Float64Array(b)}var xa=[],za=[],Aa=[];
+function Ba(){var b=a.preRun.shift();xa.unshift(b)}var A=0,Ca=null,B=null;function r(b){if(a.onAbort)a.onAbort(b);b="Aborted("+b+")";n(b);pa=!0;b=new WebAssembly.RuntimeError(b+". Build with -sASSERTIONS for more info.");ca(b);throw b;}function Da(){return C.startsWith("data:application/octet-stream;base64,")}var C;C="olm.wasm";if(!Da()){var Ea=C;C=a.locateFile?a.locateFile(Ea,m):m+Ea}
+function Fa(){var b=C;try{if(b==C&&q)return new Uint8Array(q);if(la)return la(b);throw"both async and sync fetching of the wasm failed";}catch(c){r(c)}}
+function Ga(){if(!q&&(ha||l)){if("function"==typeof fetch&&!C.startsWith("file://"))return fetch(C,{credentials:"same-origin"}).then(function(b){if(!b.ok)throw"failed to load wasm binary file at '"+C+"'";return b.arrayBuffer()}).catch(function(){return Fa()});if(ka)return new Promise(function(b,c){ka(C,function(d){b(new Uint8Array(d))},c)})}return Promise.resolve().then(function(){return Fa()})}var Ha;function Ia(b){for(;0<b.length;)b.shift()(a)}
+function Ja(b,c="i8"){c.endsWith("*")&&(c="*");switch(c){case "i1":return y[b>>0];case "i8":return y[b>>0];case "i16":return sa[b>>1];case "i32":return z[b>>2];case "i64":return z[b>>2];case "float":return ua[b>>2];case "double":return va[b>>3];case "*":return ta[b>>2];default:r("invalid type for getValue: "+c)}return null}
+function D(b){var c="i8";c.endsWith("*")&&(c="*");switch(c){case "i1":y[b>>0]=0;break;case "i8":y[b>>0]=0;break;case "i16":sa[b>>1]=0;break;case "i32":z[b>>2]=0;break;case "i64":Ha=[0,0];z[b>>2]=Ha[0];z[b+4>>2]=Ha[1];break;case "float":ua[b>>2]=0;break;case "double":va[b>>3]=0;break;case "*":ta[b>>2]=0;break;default:r("invalid type for setValue: "+c)}}function Ka(b,c,d){for(var e=0;e<b.length;++e)y[c++>>0]=b.charCodeAt(e);d||(y[c>>0]=0)}
+function La(b,c,d){d=Array(0<d?d:x(b)+1);b=v(b,d,0,d.length);c&&(d.length=b);return d}var Ma={b:function(b,c,d){u.copyWithin(b,c,c+d)},a:function(b){var c=u.length;b>>>=0;if(2147483648<b)return!1;for(var d=1;4>=d;d*=2){var e=c*(1+.2/d);e=Math.min(e,b+100663296);var f=Math;e=Math.max(b,e);f=f.min.call(f,2147483648,e+(65536-e%65536)%65536);a:{try{oa.grow(f-ra.byteLength+65535>>>16);wa();var k=1;break a}catch(p){}k=void 0}if(k)return!0}return!1}};
+(function(){function b(f){a.asm=f.exports;oa=a.asm.c;wa();za.unshift(a.asm.d);A--;a.monitorRunDependencies&&a.monitorRunDependencies(A);0==A&&(null!==Ca&&(clearInterval(Ca),Ca=null),B&&(f=B,B=null,f()))}function c(f){b(f.instance)}function d(f){return Ga().then(function(k){return WebAssembly.instantiate(k,e)}).then(function(k){return k}).then(f,function(k){n("failed to asynchronously prepare wasm: "+k);r(k)})}var e={a:Ma};A++;a.monitorRunDependencies&&a.monitorRunDependencies(A);if(a.instantiateWasm)try{return a.instantiateWasm(e,
+b)}catch(f){return n("Module.instantiateWasm callback failed with error: "+f),!1}(function(){return q||"function"!=typeof WebAssembly.instantiateStreaming||Da()||C.startsWith("file://")||ia||"function"!=typeof fetch?d(c):fetch(C,{credentials:"same-origin"}).then(function(f){return WebAssembly.instantiateStreaming(f,e).then(c,function(k){n("wasm streaming compile failed: "+k);n("falling back to ArrayBuffer instantiation");return d(c)})})})().catch(ca);return{}})();
+a.___wasm_call_ctors=function(){return(a.___wasm_call_ctors=a.asm.d).apply(null,arguments)};a._olm_get_library_version=function(){return(a._olm_get_library_version=a.asm.f).apply(null,arguments)};a._olm_error=function(){return(a._olm_error=a.asm.g).apply(null,arguments)};a._olm_account_last_error=function(){return(a._olm_account_last_error=a.asm.h).apply(null,arguments)};a.__olm_error_to_string=function(){return(a.__olm_error_to_string=a.asm.i).apply(null,arguments)};
+a._olm_account_last_error_code=function(){return(a._olm_account_last_error_code=a.asm.j).apply(null,arguments)};a._olm_session_last_error=function(){return(a._olm_session_last_error=a.asm.k).apply(null,arguments)};a._olm_session_last_error_code=function(){return(a._olm_session_last_error_code=a.asm.l).apply(null,arguments)};a._olm_utility_last_error=function(){return(a._olm_utility_last_error=a.asm.m).apply(null,arguments)};
+a._olm_utility_last_error_code=function(){return(a._olm_utility_last_error_code=a.asm.n).apply(null,arguments)};a._olm_account_size=function(){return(a._olm_account_size=a.asm.o).apply(null,arguments)};a._olm_session_size=function(){return(a._olm_session_size=a.asm.p).apply(null,arguments)};a._olm_utility_size=function(){return(a._olm_utility_size=a.asm.q).apply(null,arguments)};a._olm_account=function(){return(a._olm_account=a.asm.r).apply(null,arguments)};
+a._olm_session=function(){return(a._olm_session=a.asm.s).apply(null,arguments)};a._olm_utility=function(){return(a._olm_utility=a.asm.t).apply(null,arguments)};a._olm_clear_account=function(){return(a._olm_clear_account=a.asm.u).apply(null,arguments)};a._olm_clear_session=function(){return(a._olm_clear_session=a.asm.v).apply(null,arguments)};a._olm_clear_utility=function(){return(a._olm_clear_utility=a.asm.w).apply(null,arguments)};
+a._olm_pickle_account_length=function(){return(a._olm_pickle_account_length=a.asm.x).apply(null,arguments)};a._olm_pickle_session_length=function(){return(a._olm_pickle_session_length=a.asm.y).apply(null,arguments)};a._olm_pickle_account=function(){return(a._olm_pickle_account=a.asm.z).apply(null,arguments)};a._olm_pickle_session=function(){return(a._olm_pickle_session=a.asm.A).apply(null,arguments)};a._olm_unpickle_account=function(){return(a._olm_unpickle_account=a.asm.B).apply(null,arguments)};
+a._olm_unpickle_session=function(){return(a._olm_unpickle_session=a.asm.C).apply(null,arguments)};a._olm_create_account_random_length=function(){return(a._olm_create_account_random_length=a.asm.D).apply(null,arguments)};a._olm_create_account=function(){return(a._olm_create_account=a.asm.E).apply(null,arguments)};a._olm_account_identity_keys_length=function(){return(a._olm_account_identity_keys_length=a.asm.F).apply(null,arguments)};
+a._olm_account_identity_keys=function(){return(a._olm_account_identity_keys=a.asm.G).apply(null,arguments)};a._olm_account_signature_length=function(){return(a._olm_account_signature_length=a.asm.H).apply(null,arguments)};a._olm_account_sign=function(){return(a._olm_account_sign=a.asm.I).apply(null,arguments)};a._olm_account_one_time_keys_length=function(){return(a._olm_account_one_time_keys_length=a.asm.J).apply(null,arguments)};
+a._olm_account_one_time_keys=function(){return(a._olm_account_one_time_keys=a.asm.K).apply(null,arguments)};a._olm_account_mark_keys_as_published=function(){return(a._olm_account_mark_keys_as_published=a.asm.L).apply(null,arguments)};a._olm_account_max_number_of_one_time_keys=function(){return(a._olm_account_max_number_of_one_time_keys=a.asm.M).apply(null,arguments)};
+a._olm_account_generate_one_time_keys_random_length=function(){return(a._olm_account_generate_one_time_keys_random_length=a.asm.N).apply(null,arguments)};a._olm_account_generate_one_time_keys=function(){return(a._olm_account_generate_one_time_keys=a.asm.O).apply(null,arguments)};a._olm_account_generate_fallback_key_random_length=function(){return(a._olm_account_generate_fallback_key_random_length=a.asm.P).apply(null,arguments)};
+a._olm_account_generate_fallback_key=function(){return(a._olm_account_generate_fallback_key=a.asm.Q).apply(null,arguments)};a._olm_account_fallback_key_length=function(){return(a._olm_account_fallback_key_length=a.asm.R).apply(null,arguments)};a._olm_account_fallback_key=function(){return(a._olm_account_fallback_key=a.asm.S).apply(null,arguments)};a._olm_account_unpublished_fallback_key_length=function(){return(a._olm_account_unpublished_fallback_key_length=a.asm.T).apply(null,arguments)};
+a._olm_account_unpublished_fallback_key=function(){return(a._olm_account_unpublished_fallback_key=a.asm.U).apply(null,arguments)};a._olm_account_forget_old_fallback_key=function(){return(a._olm_account_forget_old_fallback_key=a.asm.V).apply(null,arguments)};a._olm_create_outbound_session_random_length=function(){return(a._olm_create_outbound_session_random_length=a.asm.W).apply(null,arguments)};a._olm_create_outbound_session=function(){return(a._olm_create_outbound_session=a.asm.X).apply(null,arguments)};
+a._olm_create_inbound_session=function(){return(a._olm_create_inbound_session=a.asm.Y).apply(null,arguments)};a._olm_create_inbound_session_from=function(){return(a._olm_create_inbound_session_from=a.asm.Z).apply(null,arguments)};a._olm_session_id_length=function(){return(a._olm_session_id_length=a.asm._).apply(null,arguments)};a._olm_session_id=function(){return(a._olm_session_id=a.asm.$).apply(null,arguments)};
+a._olm_session_has_received_message=function(){return(a._olm_session_has_received_message=a.asm.aa).apply(null,arguments)};a._olm_session_describe=function(){return(a._olm_session_describe=a.asm.ba).apply(null,arguments)};a._olm_matches_inbound_session=function(){return(a._olm_matches_inbound_session=a.asm.ca).apply(null,arguments)};a._olm_matches_inbound_session_from=function(){return(a._olm_matches_inbound_session_from=a.asm.da).apply(null,arguments)};
+a._olm_remove_one_time_keys=function(){return(a._olm_remove_one_time_keys=a.asm.ea).apply(null,arguments)};a._olm_encrypt_message_type=function(){return(a._olm_encrypt_message_type=a.asm.fa).apply(null,arguments)};a._olm_encrypt_random_length=function(){return(a._olm_encrypt_random_length=a.asm.ga).apply(null,arguments)};a._olm_encrypt_message_length=function(){return(a._olm_encrypt_message_length=a.asm.ha).apply(null,arguments)};
+a._olm_encrypt=function(){return(a._olm_encrypt=a.asm.ia).apply(null,arguments)};a._olm_decrypt_max_plaintext_length=function(){return(a._olm_decrypt_max_plaintext_length=a.asm.ja).apply(null,arguments)};a._olm_decrypt=function(){return(a._olm_decrypt=a.asm.ka).apply(null,arguments)};a._olm_sha256_length=function(){return(a._olm_sha256_length=a.asm.la).apply(null,arguments)};a._olm_sha256=function(){return(a._olm_sha256=a.asm.ma).apply(null,arguments)};
+a._olm_ed25519_verify=function(){return(a._olm_ed25519_verify=a.asm.na).apply(null,arguments)};a._olm_pk_encryption_last_error=function(){return(a._olm_pk_encryption_last_error=a.asm.oa).apply(null,arguments)};a._olm_pk_encryption_last_error_code=function(){return(a._olm_pk_encryption_last_error_code=a.asm.pa).apply(null,arguments)};a._olm_pk_encryption_size=function(){return(a._olm_pk_encryption_size=a.asm.qa).apply(null,arguments)};
+a._olm_pk_encryption=function(){return(a._olm_pk_encryption=a.asm.ra).apply(null,arguments)};a._olm_clear_pk_encryption=function(){return(a._olm_clear_pk_encryption=a.asm.sa).apply(null,arguments)};a._olm_pk_encryption_set_recipient_key=function(){return(a._olm_pk_encryption_set_recipient_key=a.asm.ta).apply(null,arguments)};a._olm_pk_key_length=function(){return(a._olm_pk_key_length=a.asm.ua).apply(null,arguments)};
+a._olm_pk_ciphertext_length=function(){return(a._olm_pk_ciphertext_length=a.asm.va).apply(null,arguments)};a._olm_pk_mac_length=function(){return(a._olm_pk_mac_length=a.asm.wa).apply(null,arguments)};a._olm_pk_encrypt_random_length=function(){return(a._olm_pk_encrypt_random_length=a.asm.xa).apply(null,arguments)};a._olm_pk_encrypt=function(){return(a._olm_pk_encrypt=a.asm.ya).apply(null,arguments)};
+a._olm_pk_decryption_last_error=function(){return(a._olm_pk_decryption_last_error=a.asm.za).apply(null,arguments)};a._olm_pk_decryption_last_error_code=function(){return(a._olm_pk_decryption_last_error_code=a.asm.Aa).apply(null,arguments)};a._olm_pk_decryption_size=function(){return(a._olm_pk_decryption_size=a.asm.Ba).apply(null,arguments)};a._olm_pk_decryption=function(){return(a._olm_pk_decryption=a.asm.Ca).apply(null,arguments)};
+a._olm_clear_pk_decryption=function(){return(a._olm_clear_pk_decryption=a.asm.Da).apply(null,arguments)};a._olm_pk_private_key_length=function(){return(a._olm_pk_private_key_length=a.asm.Ea).apply(null,arguments)};a._olm_pk_generate_key_random_length=function(){return(a._olm_pk_generate_key_random_length=a.asm.Fa).apply(null,arguments)};a._olm_pk_key_from_private=function(){return(a._olm_pk_key_from_private=a.asm.Ga).apply(null,arguments)};
+a._olm_pk_generate_key=function(){return(a._olm_pk_generate_key=a.asm.Ha).apply(null,arguments)};a._olm_pickle_pk_decryption_length=function(){return(a._olm_pickle_pk_decryption_length=a.asm.Ia).apply(null,arguments)};a._olm_pickle_pk_decryption=function(){return(a._olm_pickle_pk_decryption=a.asm.Ja).apply(null,arguments)};a._olm_unpickle_pk_decryption=function(){return(a._olm_unpickle_pk_decryption=a.asm.Ka).apply(null,arguments)};
+a._olm_pk_max_plaintext_length=function(){return(a._olm_pk_max_plaintext_length=a.asm.La).apply(null,arguments)};a._olm_pk_decrypt=function(){return(a._olm_pk_decrypt=a.asm.Ma).apply(null,arguments)};a._olm_pk_get_private_key=function(){return(a._olm_pk_get_private_key=a.asm.Na).apply(null,arguments)};a._olm_pk_signing_size=function(){return(a._olm_pk_signing_size=a.asm.Oa).apply(null,arguments)};a._olm_pk_signing=function(){return(a._olm_pk_signing=a.asm.Pa).apply(null,arguments)};
+a._olm_pk_signing_last_error=function(){return(a._olm_pk_signing_last_error=a.asm.Qa).apply(null,arguments)};a._olm_pk_signing_last_error_code=function(){return(a._olm_pk_signing_last_error_code=a.asm.Ra).apply(null,arguments)};a._olm_clear_pk_signing=function(){return(a._olm_clear_pk_signing=a.asm.Sa).apply(null,arguments)};a._olm_pk_signing_seed_length=function(){return(a._olm_pk_signing_seed_length=a.asm.Ta).apply(null,arguments)};
+a._olm_pk_signing_public_key_length=function(){return(a._olm_pk_signing_public_key_length=a.asm.Ua).apply(null,arguments)};a._olm_pk_signing_key_from_seed=function(){return(a._olm_pk_signing_key_from_seed=a.asm.Va).apply(null,arguments)};a._olm_pk_signature_length=function(){return(a._olm_pk_signature_length=a.asm.Wa).apply(null,arguments)};a._olm_pk_sign=function(){return(a._olm_pk_sign=a.asm.Xa).apply(null,arguments)};
+a._olm_inbound_group_session_size=function(){return(a._olm_inbound_group_session_size=a.asm.Ya).apply(null,arguments)};a._olm_inbound_group_session=function(){return(a._olm_inbound_group_session=a.asm.Za).apply(null,arguments)};a._olm_clear_inbound_group_session=function(){return(a._olm_clear_inbound_group_session=a.asm._a).apply(null,arguments)};a._olm_inbound_group_session_last_error=function(){return(a._olm_inbound_group_session_last_error=a.asm.$a).apply(null,arguments)};
+a._olm_inbound_group_session_last_error_code=function(){return(a._olm_inbound_group_session_last_error_code=a.asm.ab).apply(null,arguments)};a._olm_init_inbound_group_session=function(){return(a._olm_init_inbound_group_session=a.asm.bb).apply(null,arguments)};a._olm_import_inbound_group_session=function(){return(a._olm_import_inbound_group_session=a.asm.cb).apply(null,arguments)};
+a._olm_pickle_inbound_group_session_length=function(){return(a._olm_pickle_inbound_group_session_length=a.asm.db).apply(null,arguments)};a._olm_pickle_inbound_group_session=function(){return(a._olm_pickle_inbound_group_session=a.asm.eb).apply(null,arguments)};a._olm_unpickle_inbound_group_session=function(){return(a._olm_unpickle_inbound_group_session=a.asm.fb).apply(null,arguments)};
+a._olm_group_decrypt_max_plaintext_length=function(){return(a._olm_group_decrypt_max_plaintext_length=a.asm.gb).apply(null,arguments)};a._olm_group_decrypt=function(){return(a._olm_group_decrypt=a.asm.hb).apply(null,arguments)};a._olm_inbound_group_session_id_length=function(){return(a._olm_inbound_group_session_id_length=a.asm.ib).apply(null,arguments)};a._olm_inbound_group_session_id=function(){return(a._olm_inbound_group_session_id=a.asm.jb).apply(null,arguments)};
+a._olm_inbound_group_session_first_known_index=function(){return(a._olm_inbound_group_session_first_known_index=a.asm.kb).apply(null,arguments)};a._olm_inbound_group_session_is_verified=function(){return(a._olm_inbound_group_session_is_verified=a.asm.lb).apply(null,arguments)};a._olm_export_inbound_group_session_length=function(){return(a._olm_export_inbound_group_session_length=a.asm.mb).apply(null,arguments)};
+a._olm_export_inbound_group_session=function(){return(a._olm_export_inbound_group_session=a.asm.nb).apply(null,arguments)};a._olm_outbound_group_session_size=function(){return(a._olm_outbound_group_session_size=a.asm.ob).apply(null,arguments)};a._olm_outbound_group_session=function(){return(a._olm_outbound_group_session=a.asm.pb).apply(null,arguments)};a._olm_clear_outbound_group_session=function(){return(a._olm_clear_outbound_group_session=a.asm.qb).apply(null,arguments)};
+a._olm_outbound_group_session_last_error=function(){return(a._olm_outbound_group_session_last_error=a.asm.rb).apply(null,arguments)};a._olm_outbound_group_session_last_error_code=function(){return(a._olm_outbound_group_session_last_error_code=a.asm.sb).apply(null,arguments)};a._olm_pickle_outbound_group_session_length=function(){return(a._olm_pickle_outbound_group_session_length=a.asm.tb).apply(null,arguments)};
+a._olm_pickle_outbound_group_session=function(){return(a._olm_pickle_outbound_group_session=a.asm.ub).apply(null,arguments)};a._olm_unpickle_outbound_group_session=function(){return(a._olm_unpickle_outbound_group_session=a.asm.vb).apply(null,arguments)};a._olm_init_outbound_group_session_random_length=function(){return(a._olm_init_outbound_group_session_random_length=a.asm.wb).apply(null,arguments)};
+a._olm_init_outbound_group_session=function(){return(a._olm_init_outbound_group_session=a.asm.xb).apply(null,arguments)};a._olm_group_encrypt_message_length=function(){return(a._olm_group_encrypt_message_length=a.asm.yb).apply(null,arguments)};a._olm_group_encrypt=function(){return(a._olm_group_encrypt=a.asm.zb).apply(null,arguments)};a._olm_outbound_group_session_id_length=function(){return(a._olm_outbound_group_session_id_length=a.asm.Ab).apply(null,arguments)};
+a._olm_outbound_group_session_id=function(){return(a._olm_outbound_group_session_id=a.asm.Bb).apply(null,arguments)};a._olm_outbound_group_session_message_index=function(){return(a._olm_outbound_group_session_message_index=a.asm.Cb).apply(null,arguments)};a._olm_outbound_group_session_key_length=function(){return(a._olm_outbound_group_session_key_length=a.asm.Db).apply(null,arguments)};a._olm_outbound_group_session_key=function(){return(a._olm_outbound_group_session_key=a.asm.Eb).apply(null,arguments)};
+a._olm_sas_last_error=function(){return(a._olm_sas_last_error=a.asm.Fb).apply(null,arguments)};a._olm_sas_last_error_code=function(){return(a._olm_sas_last_error_code=a.asm.Gb).apply(null,arguments)};a._olm_sas_size=function(){return(a._olm_sas_size=a.asm.Hb).apply(null,arguments)};a._olm_sas=function(){return(a._olm_sas=a.asm.Ib).apply(null,arguments)};a._olm_clear_sas=function(){return(a._olm_clear_sas=a.asm.Jb).apply(null,arguments)};
+a._olm_create_sas_random_length=function(){return(a._olm_create_sas_random_length=a.asm.Kb).apply(null,arguments)};a._olm_create_sas=function(){return(a._olm_create_sas=a.asm.Lb).apply(null,arguments)};a._olm_sas_pubkey_length=function(){return(a._olm_sas_pubkey_length=a.asm.Mb).apply(null,arguments)};a._olm_sas_get_pubkey=function(){return(a._olm_sas_get_pubkey=a.asm.Nb).apply(null,arguments)};a._olm_sas_set_their_key=function(){return(a._olm_sas_set_their_key=a.asm.Ob).apply(null,arguments)};
+a._olm_sas_is_their_key_set=function(){return(a._olm_sas_is_their_key_set=a.asm.Pb).apply(null,arguments)};a._olm_sas_generate_bytes=function(){return(a._olm_sas_generate_bytes=a.asm.Qb).apply(null,arguments)};a._olm_sas_mac_length=function(){return(a._olm_sas_mac_length=a.asm.Rb).apply(null,arguments)};a._olm_sas_calculate_mac_fixed_base64=function(){return(a._olm_sas_calculate_mac_fixed_base64=a.asm.Sb).apply(null,arguments)};
+a._olm_sas_calculate_mac=function(){return(a._olm_sas_calculate_mac=a.asm.Tb).apply(null,arguments)};a._olm_sas_calculate_mac_long_kdf=function(){return(a._olm_sas_calculate_mac_long_kdf=a.asm.Ub).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Vb).apply(null,arguments)};a._free=function(){return(a._free=a.asm.Wb).apply(null,arguments)};
+var Na=a.stackSave=function(){return(Na=a.stackSave=a.asm.Xb).apply(null,arguments)},Oa=a.stackRestore=function(){return(Oa=a.stackRestore=a.asm.Yb).apply(null,arguments)},Pa=a.stackAlloc=function(){return(Pa=a.stackAlloc=a.asm.Zb).apply(null,arguments)};a.intArrayFromString=La;a.writeAsciiToMemory=Ka;a.ALLOC_STACK=1;var Qa;B=function Ra(){Qa||Sa();Qa||(B=Ra)};
+function Sa(){function b(){if(!Qa&&(Qa=!0,a.calledRun=!0,!pa)){Ia(za);aa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;){var c=a.postRun.shift();Aa.unshift(c)}Ia(Aa)}}if(!(0<A)){if(a.preRun)for("function"==typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)Ba();Ia(xa);0<A||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);b()},1)):b())}}
+if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();Sa();function E(){var b=a._olm_outbound_group_session_size();this.ac=F(b);this.$b=a._olm_outbound_group_session(this.ac)}function G(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_outbound_group_session_last_error(arguments[0])),Error("OLM."+c);return c}}E.prototype.free=function(){a._olm_clear_outbound_group_session(this.$b);H(this.$b)};
+E.prototype.pickle=J(function(b){b=K(b);var c=G(a._olm_pickle_outbound_group_session_length)(this.$b),d=L(b),e=L(c+1);try{G(a._olm_pickle_outbound_group_session)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});E.prototype.unpickle=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c);try{G(a._olm_unpickle_outbound_group_session)(this.$b,d,b.length,e,c.length)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}});
+E.prototype.create=J(function(){var b=G(a._olm_init_outbound_group_session_random_length)(this.$b),c=N(b,g);try{G(a._olm_init_outbound_group_session)(this.$b,c,b)}finally{M(c,b)}});E.prototype.encrypt=function(b){try{var c=x(b);var d=G(a._olm_group_encrypt_message_length)(this.$b,c);var e=F(c+1);v(b,u,e,c+1);var f=F(d+1);G(a._olm_group_encrypt)(this.$b,e,c,f,d);D(f+d);return t(f,d)}finally{void 0!==e&&(M(e,c+1),H(e)),void 0!==f&&H(f)}};
+E.prototype.session_id=J(function(){var b=G(a._olm_outbound_group_session_id_length)(this.$b),c=L(b+1);G(a._olm_outbound_group_session_id)(this.$b,c,b);return t(c,b)});E.prototype.session_key=J(function(){var b=G(a._olm_outbound_group_session_key_length)(this.$b),c=L(b+1);G(a._olm_outbound_group_session_key)(this.$b,c,b);var d=t(c,b);M(c,b);return d});E.prototype.message_index=function(){return G(a._olm_outbound_group_session_message_index)(this.$b)};olm_exports.OutboundGroupSession=E;
+function O(){var b=a._olm_inbound_group_session_size();this.ac=F(b);this.$b=a._olm_inbound_group_session(this.ac)}function P(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_inbound_group_session_last_error(arguments[0])),Error("OLM."+c);return c}}O.prototype.free=function(){a._olm_clear_inbound_group_session(this.$b);H(this.$b)};
+O.prototype.pickle=J(function(b){b=K(b);var c=P(a._olm_pickle_inbound_group_session_length)(this.$b),d=L(b),e=L(c+1);try{P(a._olm_pickle_inbound_group_session)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});O.prototype.unpickle=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c);try{P(a._olm_unpickle_inbound_group_session)(this.$b,d,b.length,e,c.length)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}});
+O.prototype.create=J(function(b){b=K(b);var c=L(b);try{P(a._olm_init_inbound_group_session)(this.$b,c,b.length)}finally{for(M(c,b.length),c=0;c<b.length;c++)b[c]=0}});O.prototype.import_session=J(function(b){b=K(b);var c=L(b);try{P(a._olm_import_inbound_group_session)(this.$b,c,b.length)}finally{for(M(c,b.length),c=0;c<b.length;c++)b[c]=0}});
+O.prototype.decrypt=J(function(b){try{var c=F(b.length);Ka(b,c,!0);var d=P(a._olm_group_decrypt_max_plaintext_length)(this.$b,c,b.length);Ka(b,c,!0);var e=F(d+1);var f=L(4);var k=P(a._olm_group_decrypt)(this.$b,c,b.length,e,d,f);D(e+k);return{plaintext:t(e,k),message_index:Ja(f,"i32")}}finally{void 0!==c&&H(c),void 0!==e&&(M(e,k),H(e))}});
+O.prototype.session_id=J(function(){var b=P(a._olm_inbound_group_session_id_length)(this.$b),c=L(b+1);P(a._olm_inbound_group_session_id)(this.$b,c,b);return t(c,b)});O.prototype.first_known_index=J(function(){return P(a._olm_inbound_group_session_first_known_index)(this.$b)});O.prototype.export_session=J(function(b){var c=P(a._olm_export_inbound_group_session_length)(this.$b),d=L(c+1);G(a._olm_export_inbound_group_session)(this.$b,d,c,b);b=t(d,c);M(d,c);return b});
+olm_exports.InboundGroupSession=O;function Ta(){var b=a._olm_pk_encryption_size();this.ac=F(b);this.$b=a._olm_pk_encryption(this.ac)}function Q(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_pk_encryption_last_error(arguments[0])),Error("OLM."+c);return c}}Ta.prototype.free=function(){a._olm_clear_pk_encryption(this.$b);H(this.$b)};Ta.prototype.set_recipient_key=J(function(b){b=K(b);var c=L(b);Q(a._olm_pk_encryption_set_recipient_key)(this.$b,c,b.length)});
+Ta.prototype.encrypt=J(function(b){try{var c=x(b);var d=F(c+1);v(b,u,d,c+1);var e=Q(a._olm_pk_encrypt_random_length)();var f=N(e,g);var k=Q(a._olm_pk_ciphertext_length)(this.$b,c);var p=F(k+1);var w=Q(a._olm_pk_mac_length)(this.$b),ba=L(w+1);D(ba+w);var R=Q(a._olm_pk_key_length)(),I=L(R+1);D(I+R);Q(a._olm_pk_encrypt)(this.$b,d,c,p,k,ba,w,I,R,f,e);D(p+k);return{ciphertext:t(p,k),mac:t(ba,w),ephemeral:t(I,R)}}finally{void 0!==f&&M(f,e),void 0!==d&&(M(d,c+1),H(d)),void 0!==p&&H(p)}});
+function S(){var b=a._olm_pk_decryption_size();this.ac=F(b);this.$b=a._olm_pk_decryption(this.ac)}function T(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_pk_decryption_last_error(arguments[0])),Error("OLM."+c);return c}}S.prototype.free=function(){a._olm_clear_pk_decryption(this.$b);H(this.$b)};
+S.prototype.init_with_private_key=J(function(b){var c=L(b.length);a.HEAPU8.set(b,c);var d=T(a._olm_pk_key_length)(),e=L(d+1);try{T(a._olm_pk_key_from_private)(this.$b,e,d,c,b.length)}finally{M(c,b.length)}return t(e,d)});S.prototype.generate_key=J(function(){var b=T(a._olm_pk_private_key_length)(),c=N(b,g),d=T(a._olm_pk_key_length)(),e=L(d+1);try{T(a._olm_pk_key_from_private)(this.$b,e,d,c,b)}finally{M(c,b)}return t(e,d)});
+S.prototype.get_private_key=J(function(){var b=Q(a._olm_pk_private_key_length)(),c=L(b);T(a._olm_pk_get_private_key)(this.$b,c,b);var d=new Uint8Array(new Uint8Array(a.HEAPU8.buffer,c,b));M(c,b);return d});S.prototype.pickle=J(function(b){b=K(b);var c=T(a._olm_pickle_pk_decryption_length)(this.$b),d=L(b),e=L(c+1);try{T(a._olm_pickle_pk_decryption)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});
+S.prototype.unpickle=J(function(b,c){b=K(b);var d=L(b),e=K(c),f=L(e);c=T(a._olm_pk_key_length)();var k=L(c+1);try{T(a._olm_unpickle_pk_decryption)(this.$b,d,b.length,f,e.length,k,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(k,c)});
+S.prototype.decrypt=J(function(b,c,d){try{var e=x(d);var f=F(e+1);v(d,u,f,e+1);var k=K(b),p=L(k),w=K(c),ba=L(w);var R=T(a._olm_pk_max_plaintext_length)(this.$b,e);var I=F(R+1);var ya=T(a._olm_pk_decrypt)(this.$b,p,k.length,ba,w.length,f,e,I,R);D(I+ya);return t(I,ya)}finally{void 0!==I&&(M(I,ya+1),H(I)),void 0!==f&&H(f)}});function Ua(){var b=a._olm_pk_signing_size();this.ac=F(b);this.$b=a._olm_pk_signing(this.ac)}
+function Va(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_pk_signing_last_error(arguments[0])),Error("OLM."+c);return c}}Ua.prototype.free=function(){a._olm_clear_pk_signing(this.$b);H(this.$b)};Ua.prototype.init_with_seed=J(function(b){var c=L(b.length);a.HEAPU8.set(b,c);var d=Va(a._olm_pk_signing_public_key_length)(),e=L(d+1);try{Va(a._olm_pk_signing_key_from_seed)(this.$b,e,d,c,b.length)}finally{M(c,b.length)}return t(e,d)});
+Ua.prototype.generate_seed=J(function(){var b=Va(a._olm_pk_signing_seed_length)(),c=N(b,g),d=new Uint8Array(new Uint8Array(a.HEAPU8.buffer,c,b));M(c,b);return d});Ua.prototype.sign=J(function(b){try{var c=x(b);var d=F(c+1);v(b,u,d,c+1);var e=Va(a._olm_pk_signature_length)(),f=L(e+1);Va(a._olm_pk_sign)(this.$b,d,c,f,e);return t(f,e)}finally{void 0!==d&&(M(d,c+1),H(d))}});
+function U(){var b=a._olm_sas_size(),c=a._olm_create_sas_random_length(),d=N(c,g);this.ac=F(b);this.$b=a._olm_sas(this.ac);a._olm_create_sas(this.$b,d,c);M(d,c)}function V(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_sas_last_error(arguments[0])),Error("OLM."+c);return c}}U.prototype.free=function(){a._olm_clear_sas(this.$b);H(this.$b)};
+U.prototype.get_pubkey=J(function(){var b=V(a._olm_sas_pubkey_length)(this.$b),c=L(b+1);V(a._olm_sas_get_pubkey)(this.$b,c,b);return t(c,b)});U.prototype.set_their_key=J(function(b){b=K(b);var c=L(b);V(a._olm_sas_set_their_key)(this.$b,c,b.length)});U.prototype.is_their_key_set=J(function(){return V(a._olm_sas_is_their_key_set)(this.$b)?!0:!1});
+U.prototype.generate_bytes=J(function(b,c){b=K(b);var d=L(b),e=L(c);V(a._olm_sas_generate_bytes)(this.$b,d,b.length,e,c);return new Uint8Array(new Uint8Array(a.HEAPU8.buffer,e,c))});U.prototype.calculate_mac=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c),f=V(a._olm_sas_mac_length)(this.$b),k=L(f+1);V(a._olm_sas_calculate_mac)(this.$b,d,b.length,e,c.length,k,f);return t(k,f)});
+U.prototype.calculate_mac_fixed_base64=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c),f=V(a._olm_sas_mac_length)(this.$b),k=L(f+1);V(a._olm_sas_calculate_mac_fixed_base64)(this.$b,d,b.length,e,c.length,k,f);return t(k,f)});U.prototype.calculate_mac_long_kdf=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c),f=V(a._olm_sas_mac_length)(this.$b),k=L(f+1);V(a._olm_sas_calculate_mac_long_kdf)(this.$b,d,b.length,e,c.length,k,f);return t(k,f)});var F=a._malloc,H=a._free,h;
+function N(b,c){var d=Pa(b);c(new Uint8Array(a.HEAPU8.buffer,d,b));return d}function L(b){return"number"==typeof b?N(b,function(c){c.fill(0)}):N(b.length,function(c){c.set(b)})}function K(b){return b instanceof Uint8Array?b:La(b,!0)}function J(b){return function(){var c=Na();try{return b.apply(this,arguments)}finally{Oa(c)}}}function M(b,c){for(;0<c--;)a.HEAP8[b++]=0}function W(){var b=a._olm_account_size();this.ac=F(b);this.$b=a._olm_account(this.ac)}
+function X(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_account_last_error(arguments[0])),Error("OLM."+c);return c}}W.prototype.free=function(){a._olm_clear_account(this.$b);H(this.$b)};W.prototype.create=J(function(){var b=X(a._olm_create_account_random_length)(this.$b),c=N(b,g);try{X(a._olm_create_account)(this.$b,c,b)}finally{M(c,b)}});
+W.prototype.identity_keys=J(function(){var b=X(a._olm_account_identity_keys_length)(this.$b),c=L(b+1);X(a._olm_account_identity_keys)(this.$b,c,b);return t(c,b)});W.prototype.sign=J(function(b){var c=X(a._olm_account_signature_length)(this.$b);b=K(b);var d=L(b),e=L(c+1);try{X(a._olm_account_sign)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});
+W.prototype.one_time_keys=J(function(){var b=X(a._olm_account_one_time_keys_length)(this.$b),c=L(b+1);X(a._olm_account_one_time_keys)(this.$b,c,b);return t(c,b)});W.prototype.mark_keys_as_published=J(function(){X(a._olm_account_mark_keys_as_published)(this.$b)});W.prototype.max_number_of_one_time_keys=J(function(){return X(a._olm_account_max_number_of_one_time_keys)(this.$b)});
+W.prototype.generate_one_time_keys=J(function(b){var c=X(a._olm_account_generate_one_time_keys_random_length)(this.$b,b),d=N(c,g);try{X(a._olm_account_generate_one_time_keys)(this.$b,b,d,c)}finally{M(d,c)}});W.prototype.remove_one_time_keys=J(function(b){X(a._olm_remove_one_time_keys)(this.$b,b.$b)});W.prototype.generate_fallback_key=J(function(){var b=X(a._olm_account_generate_fallback_key_random_length)(this.$b),c=N(b,g);try{X(a._olm_account_generate_fallback_key)(this.$b,c,b)}finally{M(c,b)}});
+W.prototype.fallback_key=J(function(){var b=X(a._olm_account_fallback_key_length)(this.$b),c=L(b+1);X(a._olm_account_fallback_key)(this.$b,c,b);return t(c,b)});W.prototype.unpublished_fallback_key=J(function(){var b=X(a._olm_account_unpublished_fallback_key_length)(this.$b),c=L(b+1);X(a._olm_account_unpublished_fallback_key)(this.$b,c,b);return t(c,b)});W.prototype.forget_old_fallback_key=J(function(){X(a._olm_account_forget_old_fallback_key)(this.$b)});
+W.prototype.pickle=J(function(b){b=K(b);var c=X(a._olm_pickle_account_length)(this.$b),d=L(b),e=L(c+1);try{X(a._olm_pickle_account)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});W.prototype.unpickle=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c);try{X(a._olm_unpickle_account)(this.$b,d,b.length,e,c.length)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}});function Y(){var b=a._olm_session_size();this.ac=F(b);this.$b=a._olm_session(this.ac)}
+function Z(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_session_last_error(arguments[0])),Error("OLM."+c);return c}}Y.prototype.free=function(){a._olm_clear_session(this.$b);H(this.$b)};Y.prototype.pickle=J(function(b){b=K(b);var c=Z(a._olm_pickle_session_length)(this.$b),d=L(b),e=L(c+1);try{Z(a._olm_pickle_session)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});
+Y.prototype.unpickle=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c);try{Z(a._olm_unpickle_session)(this.$b,d,b.length,e,c.length)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}});Y.prototype.create_outbound=J(function(b,c,d){var e=Z(a._olm_create_outbound_session_random_length)(this.$b),f=N(e,g);c=K(c);d=K(d);var k=L(c),p=L(d);try{Z(a._olm_create_outbound_session)(this.$b,b.$b,k,c.length,p,d.length,f,e)}finally{M(f,e)}});
+Y.prototype.create_inbound=J(function(b,c){c=K(c);var d=L(c);try{Z(a._olm_create_inbound_session)(this.$b,b.$b,d,c.length)}finally{for(M(d,c.length),b=0;b<c.length;b++)c[b]=0}});Y.prototype.create_inbound_from=J(function(b,c,d){c=K(c);var e=L(c);d=K(d);var f=L(d);try{Z(a._olm_create_inbound_session_from)(this.$b,b.$b,e,c.length,f,d.length)}finally{for(M(f,d.length),b=0;b<d.length;b++)d[b]=0}});
+Y.prototype.session_id=J(function(){var b=Z(a._olm_session_id_length)(this.$b),c=L(b+1);Z(a._olm_session_id)(this.$b,c,b);return t(c,b)});Y.prototype.has_received_message=function(){return Z(a._olm_session_has_received_message)(this.$b)?!0:!1};Y.prototype.matches_inbound=J(function(b){b=K(b);var c=L(b);return Z(a._olm_matches_inbound_session)(this.$b,c,b.length)?!0:!1});
+Y.prototype.matches_inbound_from=J(function(b,c){b=K(b);var d=L(b);c=K(c);var e=L(c);return Z(a._olm_matches_inbound_session_from)(this.$b,d,b.length,e,c.length)?!0:!1});
+Y.prototype.encrypt=J(function(b){try{var c=Z(a._olm_encrypt_random_length)(this.$b);var d=Z(a._olm_encrypt_message_type)(this.$b);var e=x(b);var f=Z(a._olm_encrypt_message_length)(this.$b,e);var k=N(c,g);var p=F(e+1);v(b,u,p,e+1);var w=F(f+1);Z(a._olm_encrypt)(this.$b,p,e,k,c,w,f);D(w+f);return{type:d,body:t(w,f)}}finally{void 0!==k&&M(k,c),void 0!==p&&(M(p,e+1),H(p)),void 0!==w&&H(w)}});
+Y.prototype.decrypt=J(function(b,c){try{var d=F(c.length);Ka(c,d,!0);var e=Z(a._olm_decrypt_max_plaintext_length)(this.$b,b,d,c.length);Ka(c,d,!0);var f=F(e+1);var k=Z(a._olm_decrypt)(this.$b,b,d,c.length,f,e);D(f+k);return t(f,k)}finally{void 0!==d&&H(d),void 0!==f&&(M(f,e),H(f))}});Y.prototype.describe=J(function(){try{var b=F(256);Z(a._olm_session_describe)(this.$b,b,256);return t(b)}finally{void 0!==b&&H(b)}});
+function Wa(){var b=a._olm_utility_size();this.ac=F(b);this.$b=a._olm_utility(this.ac)}function Xa(b){return function(){var c=b.apply(this,arguments);if(c===h)throw c=t(a._olm_utility_last_error(arguments[0])),Error("OLM."+c);return c}}Wa.prototype.free=function(){a._olm_clear_utility(this.$b);H(this.$b)};
+Wa.prototype.sha256=J(function(b){var c=Xa(a._olm_sha256_length)(this.$b);b=K(b);var d=L(b),e=L(c+1);try{Xa(a._olm_sha256)(this.$b,d,b.length,e,c)}finally{for(M(d,b.length),d=0;d<b.length;d++)b[d]=0}return t(e,c)});Wa.prototype.ed25519_verify=J(function(b,c,d){b=K(b);var e=L(b);c=K(c);var f=L(c);d=K(d);var k=L(d);try{Xa(a._olm_ed25519_verify)(this.$b,e,b.length,f,c.length,k,d.length)}finally{for(M(f,c.length),b=0;b<c.length;b++)c[b]=0}});olm_exports.Account=W;olm_exports.Session=Y;
+olm_exports.Utility=Wa;olm_exports.PkEncryption=Ta;olm_exports.PkDecryption=S;olm_exports.PkSigning=Ua;olm_exports.SAS=U;olm_exports.get_library_version=J(function(){var b=L(3);a._olm_get_library_version(b,b+1,b+2);return[Ja(b,"i8"),Ja(b+1,"i8"),Ja(b+2,"i8")]});
+
+
+ return Module.ready
+}
+);
+})();
+if (typeof exports === 'object' && typeof module === 'object')
+ module.exports = Module;
+else if (typeof define === 'function' && define['amd'])
+ define([], function() { return Module; });
+else if (typeof exports === 'object')
+ exports["Module"] = Module;
+var olmInitPromise;
+
+olm_exports['init'] = function(opts) {
+ if (olmInitPromise) return olmInitPromise;
+
+ if (opts) OLM_OPTIONS = opts;
+
+ olmInitPromise = new Promise(function(resolve, reject) {
+ onInitSuccess = function() {
+ resolve();
+ };
+ onInitFail = function(err) {
+ reject(err);
+ };
+ Module();
+ });
+ return olmInitPromise;
+};
+
+return olm_exports;
+
+})();
+
+if (typeof(window) !== 'undefined') {
+ // We've been imported directly into a browser. Define the global 'Olm' object.
+ // (we do this even if module.exports was defined, because it's useful to have
+ // Olm in the global scope for browserified and webpacked apps.)
+ window["Olm"] = Olm;
+}
+
+if (typeof module === 'object') {
+ // Emscripten sets the module exports to be its module
+ // with wrapped c functions. Clobber it with our higher
+ // level wrapper class.
+ module.exports = Olm;
+}
+
+// @license-end
diff --git a/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.wasm b/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.wasm
new file mode 100755
index 0000000000..6555108d28
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.wasm
Binary files differ
diff --git a/comm/chat/protocols/matrix/lib/README.md b/comm/chat/protocols/matrix/lib/README.md
new file mode 100644
index 0000000000..ee7d392b08
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/README.md
@@ -0,0 +1,174 @@
+This directory contains the Matrix Client-Server SDK for Javascript available
+at https://github.com/matrix-org/matrix-js-sdk/. Current version is v26.0.1.
+
+The following npm dependencies are included:
+
+* @matrix-org/olm: https://gitlab.matrix.org/matrix-org/olm/-/packages?type=npm v3.2.14
+* another-json: https://www.npmjs.com/package/another-json/ v0.2.0
+* base-x: https://www.npmjs.com/package/base-x v4.0.0
+* bs58: https://www.npmjs.com/package/bs58 v5.0.0
+* content-type: https://www.npmjs.com/package/content-type v1.0.5
+* events: https://www.npmjs.com/package/events v3.3.0
+* matrix-events-sdk: https://www.npmjs.com/package/matrix-events-sdk v0.0.1
+* matrix-widget-api: https://www.npmjs.com/package/matrix-widget-api v1.4.0
+* p-retry: https://www.npmjs.com/package/p-retry v4.6.2
+* retry: https://www.npmjs.com/package/retry v0.13.1
+* sdp-transform: https://www.npmjs.com/package/sdp-transform v2.14.1
+* unhomoglyph: https://www.npmjs.com/package/unhomoglyph v1.0.6
+
+The following npm dependencies are shimmed:
+
+* loglevel: The chat framework's logging methods are used internally.
+* safe-buffer: A buffer shim, initially modeled after the safe-buffer NPM package,
+ now used to provide a Buffer object to the crypto stack.
+* uuid: Only the v4 is provided via cryto.randomUUID().
+
+There is not any automated way to update the libraries.
+
+Files have been obtained by downloading the matrix-js-sdk git repository,
+using yarn to obtain the dependencies), and then compiling the SDK using Babel.
+
+To make the whole thing work, some file paths and global variables are defined
+in `chat/protocols/matrix/matrix-sdk.sys.mjs`.
+
+## Updating matrix-js-sdk
+
+1. Download the matrix-js-sdk repository from https://github.com/matrix-org/matrix-js-sdk/.
+2. Modify `.babelrc` (see below).
+3. (If this is an old checkout, remove any previous artifacts. Run `rm -r lib; rm -r node_modules`.)
+4. Run `yarn install`.
+5. Run Babel in the matrix-js-sdk checkout:
+ `./node_modules/.bin/babel --source-maps false -d lib --extensions ".ts,.js" src`
+ (at time of writing identical to `yarn build:compile`)
+6. The following commands assume you're in mozilla-central/comm and that the
+ matrix-js-sdk is checked out next to mozilla-central.
+7. Remove the old SDK files `hg rm chat/protocols/matrix/lib/matrix-sdk`
+9. Undo the removal of the license: `hg revert chat/protocols/matrix/lib/matrix-sdk/LICENSE`
+0. Copy the Babel-ified JavaScript files from the matrix-js-sdk to vendored
+ location: `cp -r ../../matrix-js-sdk/lib/* chat/protocols/matrix/lib/matrix-sdk`
+10. Add the files back to Mercurial: `hg add chat/protocols/matrix/lib/matrix-sdk`
+11. Modify `chat/protocols/matrix/lib/moz.build` to add/remove/rename modified
+ files. Note that some modules that have no actual contents (just an empty
+ export) are not currently included.
+12. Modify `matrix-sdk.sys.mjs` to add/remove/rename any changed modules.
+
+### Custom `.babelrc`
+
+By default, the matrix-js-sdk targets a version of ECMAScript that is far below
+what Gecko supports, this causes lots of additional processing to occur (e.g.
+converting async functions, etc.) To disable this, a custom `.babelrc` file is
+used:
+
+```javascript
+{
+ "sourceMaps": false,
+ "presets": [
+ ["@babel/preset-env", {
+ "targets": "last 1 firefox versions",
+ "modules": "commonjs"
+ }],
+ "@babel/preset-typescript"
+ ],
+ "plugins": [
+ "@babel/plugin-proposal-numeric-separator",
+ "@babel/plugin-proposal-class-properties",
+ "@babel/plugin-proposal-object-rest-spread",
+ "@babel/plugin-syntax-dynamic-import"
+ ]
+}
+```
+
+Babel doesn't natively understand class properties yet, even though we would
+support them, thus the class properties plugin. `last 1 firefox versions` tells
+babel to compile the code so the latest released Firefox (by the time of the
+last update of the packages) could run it. Alternatively a more careful
+`firefox ESR` instead of the full string would compile the code so it could run
+on any currently supported ESR (I guess useful if you want to uplift the code).
+
+## Updating dependencies
+
+First, follow the steps above. Then, check the `node_modules` directory that
+gets created by yarn. The necessary dependencies are available here,
+unfortunately each one has slightly different conventions.
+
+### Updating single file dependencies
+
+another-json, base-x, bs58 and content-type all have a single file
+named for the package or named index.js. This should get copied to the proper
+sub-directory.
+
+```
+cp ../../matrix-js-sdk/node_modules/another-json/another-json.js chat/protocols/matrix/lib/another-json
+cp ../../matrix-js-sdk/node_modules/base-x/src/index.js chat/protocols/matrix/lib/base-x
+cp ../../matrix-js-sdk/node_modules/bs58/index.js chat/protocols/matrix/lib/bs58
+cp ../../matrix-js-sdk/node_modules/content-type/index.js chat/protocols/matrix/lib/content-type
+```
+
+### Updating events
+
+The events package is included as a shim for the native node `events` module.
+As such, it is not a direct dependency of the `matrix-js-sdk`.
+
+### Updating matrix-events-sdk
+
+The matrix-events-sdk includes raw JS modules and Typescript definition files.
+We only want the JS modules. So we want all the js files in `lib/**/*.js`
+from the package.
+
+### Updating matrix-widget-api
+
+The matrix-widget-api includes raw JS modules and Typescript definition files.
+We only want the JS modules. So we want all the js files in `lib/**/*.js`
+from the package.
+
+```
+hg rm chat/protocols/matrix/lib/matrix-widget-api/
+hg revert chat/protocols/matrix/lib/matrix-widget-api/LICENSE
+cp -R ../../matrix-js-sdk/node_modules/matrix-widget-api/lib/* chat/protocols/matrix/lib/matrix-widget-api
+rm chat/protocols/matrix/lib/matrix-widget-api/**/*.ts
+rm chat/protocols/matrix/lib/matrix-widget-api/**/*.js.map
+hg add chat/protocols/matrix/lib/matrix-widget-api/
+```
+
+### Updating sdp-transform
+
+The sdp-transform package includes raw JS modules, so we want all the js files
+under `lib/*.js`.
+
+```
+cp ../../matrix-js-sdk/node_modules/sdp-transform/lib/*.js chat/protocols/matrix/lib/sdp-transform
+```
+
+### Updating unhomoglyph
+
+This is similar to the single file dependencies, but also has a JSON data file.
+Both of these files should be copied to the unhomoglyph directory.
+
+```
+cp ../../matrix-js-sdk/node_modules/unhomoglyph/index.js chat/protocols/matrix/lib/unhomoglyph
+cp ../../matrix-js-sdk/node_modules/unhomoglyph/data.json chat/protocols/matrix/lib/unhomoglyph
+```
+
+### Updating loglevel, safe-buffer, uuid
+
+These packages have an alternate implementation in the `../shims` directory and
+thus are not included here.
+
+### Updating olm
+
+The package is published on the Matrix gitlab. To update the library, download
+the latest `.tgz` bundle and replace the `olm.js` and `olm.wasm` files in the
+`@matrix-org/olm` folder.
+
+### Updating p-retry
+
+While p-retry itself only consists of a single `index.js` file, it depends on
+the `retry` package, which consists of three files, and `index.js` and two
+modules in the `lib` folder. All four files should be mirrored over into this
+folder into a `p-retry` and `retry` folder respectively.
+
+```
+cp ../../matrix-js-sdk/node_modules/p-retry/index.js chat/protocols/matrix/lib/p-retry/
+cp ../../matrix-js-sdk/node_modules/retry/index.js chat/protocols/matrix/lib/retry
+cp ../../matrix-js-sdk/node_modules/retry/lib/*.js chat/protocols/matrix/lib/retry/lib
+```
diff --git a/comm/chat/protocols/matrix/lib/another-json/LICENSE b/comm/chat/protocols/matrix/lib/another-json/LICENSE
new file mode 100644
index 0000000000..f433b1a53f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/another-json/LICENSE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/comm/chat/protocols/matrix/lib/another-json/another-json.js b/comm/chat/protocols/matrix/lib/another-json/another-json.js
new file mode 100644
index 0000000000..2157e7302b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/another-json/another-json.js
@@ -0,0 +1,93 @@
+/* Copyright 2015 Mark Haines
+ *
+ * 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.
+ */
+
+'use strict';
+
+var escaped = /[\\\"\x00-\x1F]/g;
+var escapes = {};
+for (var i = 0; i < 0x20; ++i) {
+ escapes[String.fromCharCode(i)] = (
+ '\\U' + ('0000' + i.toString(16)).slice(-4).toUpperCase()
+ );
+}
+escapes['\b'] = '\\b';
+escapes['\t'] = '\\t';
+escapes['\n'] = '\\n';
+escapes['\f'] = '\\f';
+escapes['\r'] = '\\r';
+escapes['\"'] = '\\\"';
+escapes['\\'] = '\\\\';
+
+function escapeString(value) {
+ escaped.lastIndex = 0;
+ return value.replace(escaped, function(c) { return escapes[c]; });
+}
+
+function stringify(value) {
+ switch (typeof value) {
+ case 'string':
+ return '"' + escapeString(value) + '"';
+ case 'number':
+ return isFinite(value) ? value : 'null';
+ case 'boolean':
+ return value;
+ case 'object':
+ if (value === null) {
+ return 'null';
+ }
+ if (Array.isArray(value)) {
+ return stringifyArray(value);
+ }
+ return stringifyObject(value);
+ default:
+ throw new Error('Cannot stringify: ' + typeof value);
+ }
+}
+
+function stringifyArray(array) {
+ var sep = '[';
+ var result = '';
+ for (var i = 0; i < array.length; ++i) {
+ result += sep;
+ sep = ',';
+ result += stringify(array[i]);
+ }
+ if (sep != ',') {
+ return '[]';
+ } else {
+ return result + ']';
+ }
+}
+
+function stringifyObject(object) {
+ var sep = '{';
+ var result = '';
+ var keys = Object.keys(object);
+ keys.sort();
+ for (var i = 0; i < keys.length; ++i) {
+ var key = keys[i];
+ result += sep + '"' + escapeString(key) + '":';
+ sep = ',';
+ result += stringify(object[key]);
+ }
+ if (sep != ',') {
+ return '{}';
+ } else {
+ return result + '}';
+ }
+}
+
+/** */
+module.exports = {stringify: stringify};
diff --git a/comm/chat/protocols/matrix/lib/base-x/LICENSE.md b/comm/chat/protocols/matrix/lib/base-x/LICENSE.md
new file mode 100644
index 0000000000..c5dca45542
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/base-x/LICENSE.md
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 base-x contributors
+Copyright (c) 2014-2018 The Bitcoin Core developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/base-x/index.js b/comm/chat/protocols/matrix/lib/base-x/index.js
new file mode 100644
index 0000000000..5b695f1a4c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/base-x/index.js
@@ -0,0 +1,119 @@
+'use strict'
+// base-x encoding / decoding
+// Copyright (c) 2018 base-x contributors
+// Copyright (c) 2014-2018 The Bitcoin Core developers (base58.cpp)
+// Distributed under the MIT software license, see the accompanying
+// file LICENSE or http://www.opensource.org/licenses/mit-license.php.
+// @ts-ignore
+var _Buffer = require('safe-buffer').Buffer
+function base (ALPHABET) {
+ if (ALPHABET.length >= 255) { throw new TypeError('Alphabet too long') }
+ var BASE_MAP = new Uint8Array(256)
+ for (var j = 0; j < BASE_MAP.length; j++) {
+ BASE_MAP[j] = 255
+ }
+ for (var i = 0; i < ALPHABET.length; i++) {
+ var x = ALPHABET.charAt(i)
+ var xc = x.charCodeAt(0)
+ if (BASE_MAP[xc] !== 255) { throw new TypeError(x + ' is ambiguous') }
+ BASE_MAP[xc] = i
+ }
+ var BASE = ALPHABET.length
+ var LEADER = ALPHABET.charAt(0)
+ var FACTOR = Math.log(BASE) / Math.log(256) // log(BASE) / log(256), rounded up
+ var iFACTOR = Math.log(256) / Math.log(BASE) // log(256) / log(BASE), rounded up
+ function encode (source) {
+ if (Array.isArray(source) || source instanceof Uint8Array) { source = _Buffer.from(source) }
+ if (!_Buffer.isBuffer(source)) { throw new TypeError('Expected Buffer') }
+ if (source.length === 0) { return '' }
+ // Skip & count leading zeroes.
+ var zeroes = 0
+ var length = 0
+ var pbegin = 0
+ var pend = source.length
+ while (pbegin !== pend && source[pbegin] === 0) {
+ pbegin++
+ zeroes++
+ }
+ // Allocate enough space in big-endian base58 representation.
+ var size = ((pend - pbegin) * iFACTOR + 1) >>> 0
+ var b58 = new Uint8Array(size)
+ // Process the bytes.
+ while (pbegin !== pend) {
+ var carry = source[pbegin]
+ // Apply "b58 = b58 * 256 + ch".
+ var i = 0
+ for (var it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) {
+ carry += (256 * b58[it1]) >>> 0
+ b58[it1] = (carry % BASE) >>> 0
+ carry = (carry / BASE) >>> 0
+ }
+ if (carry !== 0) { throw new Error('Non-zero carry') }
+ length = i
+ pbegin++
+ }
+ // Skip leading zeroes in base58 result.
+ var it2 = size - length
+ while (it2 !== size && b58[it2] === 0) {
+ it2++
+ }
+ // Translate the result into a string.
+ var str = LEADER.repeat(zeroes)
+ for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]) }
+ return str
+ }
+ function decodeUnsafe (source) {
+ if (typeof source !== 'string') { throw new TypeError('Expected String') }
+ if (source.length === 0) { return _Buffer.alloc(0) }
+ var psz = 0
+ // Skip and count leading '1's.
+ var zeroes = 0
+ var length = 0
+ while (source[psz] === LEADER) {
+ zeroes++
+ psz++
+ }
+ // Allocate enough space in big-endian base256 representation.
+ var size = (((source.length - psz) * FACTOR) + 1) >>> 0 // log(58) / log(256), rounded up.
+ var b256 = new Uint8Array(size)
+ // Process the characters.
+ while (source[psz]) {
+ // Decode character
+ var carry = BASE_MAP[source.charCodeAt(psz)]
+ // Invalid character
+ if (carry === 255) { return }
+ var i = 0
+ for (var it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) {
+ carry += (BASE * b256[it3]) >>> 0
+ b256[it3] = (carry % 256) >>> 0
+ carry = (carry / 256) >>> 0
+ }
+ if (carry !== 0) { throw new Error('Non-zero carry') }
+ length = i
+ psz++
+ }
+ // Skip leading zeroes in b256.
+ var it4 = size - length
+ while (it4 !== size && b256[it4] === 0) {
+ it4++
+ }
+ var vch = _Buffer.allocUnsafe(zeroes + (size - it4))
+ vch.fill(0x00, 0, zeroes)
+ var j = zeroes
+ while (it4 !== size) {
+ vch[j++] = b256[it4++]
+ }
+ return vch
+ }
+ function decode (string) {
+ var buffer = decodeUnsafe(string)
+ if (buffer) { return buffer }
+ throw new Error('Non-base' + BASE + ' character')
+ }
+ return {
+ encode: encode,
+ decodeUnsafe: decodeUnsafe,
+ decode: decode
+ }
+}
+module.exports = base
diff --git a/comm/chat/protocols/matrix/lib/bs58/LICENSE b/comm/chat/protocols/matrix/lib/bs58/LICENSE
new file mode 100644
index 0000000000..4d5f7a1c1c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/bs58/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 cryptocoinjs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/bs58/index.js b/comm/chat/protocols/matrix/lib/bs58/index.js
new file mode 100644
index 0000000000..3d02b0cbbe
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/bs58/index.js
@@ -0,0 +1,4 @@
+const basex = require('base-x')
+const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+
+module.exports = basex(ALPHABET)
diff --git a/comm/chat/protocols/matrix/lib/content-type/LICENSE b/comm/chat/protocols/matrix/lib/content-type/LICENSE
new file mode 100644
index 0000000000..34b1a2de37
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/content-type/LICENSE
@@ -0,0 +1,22 @@
+(The MIT License)
+
+Copyright (c) 2015 Douglas Christopher Wilson
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/content-type/index.js b/comm/chat/protocols/matrix/lib/content-type/index.js
new file mode 100644
index 0000000000..41840e7bc3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/content-type/index.js
@@ -0,0 +1,225 @@
+/*!
+ * content-type
+ * Copyright(c) 2015 Douglas Christopher Wilson
+ * MIT Licensed
+ */
+
+'use strict'
+
+/**
+ * RegExp to match *( ";" parameter ) in RFC 7231 sec 3.1.1.1
+ *
+ * parameter = token "=" ( token / quoted-string )
+ * token = 1*tchar
+ * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
+ * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
+ * / DIGIT / ALPHA
+ * ; any VCHAR, except delimiters
+ * quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
+ * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
+ * obs-text = %x80-FF
+ * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
+ */
+var PARAM_REGEXP = /; *([!#$%&'*+.^_`|~0-9A-Za-z-]+) *= *("(?:[\u000b\u0020\u0021\u0023-\u005b\u005d-\u007e\u0080-\u00ff]|\\[\u000b\u0020-\u00ff])*"|[!#$%&'*+.^_`|~0-9A-Za-z-]+) */g // eslint-disable-line no-control-regex
+var TEXT_REGEXP = /^[\u000b\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line no-control-regex
+var TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
+
+/**
+ * RegExp to match quoted-pair in RFC 7230 sec 3.2.6
+ *
+ * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
+ * obs-text = %x80-FF
+ */
+var QESC_REGEXP = /\\([\u000b\u0020-\u00ff])/g // eslint-disable-line no-control-regex
+
+/**
+ * RegExp to match chars that must be quoted-pair in RFC 7230 sec 3.2.6
+ */
+var QUOTE_REGEXP = /([\\"])/g
+
+/**
+ * RegExp to match type in RFC 7231 sec 3.1.1.1
+ *
+ * media-type = type "/" subtype
+ * type = token
+ * subtype = token
+ */
+var TYPE_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
+
+/**
+ * Module exports.
+ * @public
+ */
+
+exports.format = format
+exports.parse = parse
+
+/**
+ * Format object to media type.
+ *
+ * @param {object} obj
+ * @return {string}
+ * @public
+ */
+
+function format (obj) {
+ if (!obj || typeof obj !== 'object') {
+ throw new TypeError('argument obj is required')
+ }
+
+ var parameters = obj.parameters
+ var type = obj.type
+
+ if (!type || !TYPE_REGEXP.test(type)) {
+ throw new TypeError('invalid type')
+ }
+
+ var string = type
+
+ // append parameters
+ if (parameters && typeof parameters === 'object') {
+ var param
+ var params = Object.keys(parameters).sort()
+
+ for (var i = 0; i < params.length; i++) {
+ param = params[i]
+
+ if (!TOKEN_REGEXP.test(param)) {
+ throw new TypeError('invalid parameter name')
+ }
+
+ string += '; ' + param + '=' + qstring(parameters[param])
+ }
+ }
+
+ return string
+}
+
+/**
+ * Parse media type to object.
+ *
+ * @param {string|object} string
+ * @return {Object}
+ * @public
+ */
+
+function parse (string) {
+ if (!string) {
+ throw new TypeError('argument string is required')
+ }
+
+ // support req/res-like objects as argument
+ var header = typeof string === 'object'
+ ? getcontenttype(string)
+ : string
+
+ if (typeof header !== 'string') {
+ throw new TypeError('argument string is required to be a string')
+ }
+
+ var index = header.indexOf(';')
+ var type = index !== -1
+ ? header.slice(0, index).trim()
+ : header.trim()
+
+ if (!TYPE_REGEXP.test(type)) {
+ throw new TypeError('invalid media type')
+ }
+
+ var obj = new ContentType(type.toLowerCase())
+
+ // parse parameters
+ if (index !== -1) {
+ var key
+ var match
+ var value
+
+ PARAM_REGEXP.lastIndex = index
+
+ while ((match = PARAM_REGEXP.exec(header))) {
+ if (match.index !== index) {
+ throw new TypeError('invalid parameter format')
+ }
+
+ index += match[0].length
+ key = match[1].toLowerCase()
+ value = match[2]
+
+ if (value.charCodeAt(0) === 0x22 /* " */) {
+ // remove quotes
+ value = value.slice(1, -1)
+
+ // remove escapes
+ if (value.indexOf('\\') !== -1) {
+ value = value.replace(QESC_REGEXP, '$1')
+ }
+ }
+
+ obj.parameters[key] = value
+ }
+
+ if (index !== header.length) {
+ throw new TypeError('invalid parameter format')
+ }
+ }
+
+ return obj
+}
+
+/**
+ * Get content-type from req/res objects.
+ *
+ * @param {object}
+ * @return {Object}
+ * @private
+ */
+
+function getcontenttype (obj) {
+ var header
+
+ if (typeof obj.getHeader === 'function') {
+ // res-like
+ header = obj.getHeader('content-type')
+ } else if (typeof obj.headers === 'object') {
+ // req-like
+ header = obj.headers && obj.headers['content-type']
+ }
+
+ if (typeof header !== 'string') {
+ throw new TypeError('content-type header is missing from object')
+ }
+
+ return header
+}
+
+/**
+ * Quote a string if necessary.
+ *
+ * @param {string} val
+ * @return {string}
+ * @private
+ */
+
+function qstring (val) {
+ var str = String(val)
+
+ // no need to quote tokens
+ if (TOKEN_REGEXP.test(str)) {
+ return str
+ }
+
+ if (str.length > 0 && !TEXT_REGEXP.test(str)) {
+ throw new TypeError('invalid parameter value')
+ }
+
+ return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'
+}
+
+/**
+ * Class to represent a content type.
+ * @private
+ */
+function ContentType (type) {
+ this.parameters = Object.create(null)
+ this.type = type
+}
diff --git a/comm/chat/protocols/matrix/lib/events/LICENSE b/comm/chat/protocols/matrix/lib/events/LICENSE
new file mode 100644
index 0000000000..52ed3b0a63
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/events/LICENSE
@@ -0,0 +1,22 @@
+MIT
+
+Copyright Joyent, Inc. and other Node contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/events/events.js b/comm/chat/protocols/matrix/lib/events/events.js
new file mode 100644
index 0000000000..34b69a0b4a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/events/events.js
@@ -0,0 +1,497 @@
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+'use strict';
+
+var R = typeof Reflect === 'object' ? Reflect : null
+var ReflectApply = R && typeof R.apply === 'function'
+ ? R.apply
+ : function ReflectApply(target, receiver, args) {
+ return Function.prototype.apply.call(target, receiver, args);
+ }
+
+var ReflectOwnKeys
+if (R && typeof R.ownKeys === 'function') {
+ ReflectOwnKeys = R.ownKeys
+} else if (Object.getOwnPropertySymbols) {
+ ReflectOwnKeys = function ReflectOwnKeys(target) {
+ return Object.getOwnPropertyNames(target)
+ .concat(Object.getOwnPropertySymbols(target));
+ };
+} else {
+ ReflectOwnKeys = function ReflectOwnKeys(target) {
+ return Object.getOwnPropertyNames(target);
+ };
+}
+
+function ProcessEmitWarning(warning) {
+ if (console && console.warn) console.warn(warning);
+}
+
+var NumberIsNaN = Number.isNaN || function NumberIsNaN(value) {
+ return value !== value;
+}
+
+function EventEmitter() {
+ EventEmitter.init.call(this);
+}
+module.exports = EventEmitter;
+module.exports.once = once;
+
+// Backwards-compat with node 0.10.x
+EventEmitter.EventEmitter = EventEmitter;
+
+EventEmitter.prototype._events = undefined;
+EventEmitter.prototype._eventsCount = 0;
+EventEmitter.prototype._maxListeners = undefined;
+
+// By default EventEmitters will print a warning if more than 10 listeners are
+// added to it. This is a useful default which helps finding memory leaks.
+var defaultMaxListeners = 10;
+
+function checkListener(listener) {
+ if (typeof listener !== 'function') {
+ throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener);
+ }
+}
+
+Object.defineProperty(EventEmitter, 'defaultMaxListeners', {
+ enumerable: true,
+ get: function() {
+ return defaultMaxListeners;
+ },
+ set: function(arg) {
+ if (typeof arg !== 'number' || arg < 0 || NumberIsNaN(arg)) {
+ throw new RangeError('The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received ' + arg + '.');
+ }
+ defaultMaxListeners = arg;
+ }
+});
+
+EventEmitter.init = function() {
+
+ if (this._events === undefined ||
+ this._events === Object.getPrototypeOf(this)._events) {
+ this._events = Object.create(null);
+ this._eventsCount = 0;
+ }
+
+ this._maxListeners = this._maxListeners || undefined;
+};
+
+// Obviously not all Emitters should be limited to 10. This function allows
+// that to be increased. Set to zero for unlimited.
+EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
+ if (typeof n !== 'number' || n < 0 || NumberIsNaN(n)) {
+ throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + '.');
+ }
+ this._maxListeners = n;
+ return this;
+};
+
+function _getMaxListeners(that) {
+ if (that._maxListeners === undefined)
+ return EventEmitter.defaultMaxListeners;
+ return that._maxListeners;
+}
+
+EventEmitter.prototype.getMaxListeners = function getMaxListeners() {
+ return _getMaxListeners(this);
+};
+
+EventEmitter.prototype.emit = function emit(type) {
+ var args = [];
+ for (var i = 1; i < arguments.length; i++) args.push(arguments[i]);
+ var doError = (type === 'error');
+
+ var events = this._events;
+ if (events !== undefined)
+ doError = (doError && events.error === undefined);
+ else if (!doError)
+ return false;
+
+ // If there is no 'error' event listener then throw.
+ if (doError) {
+ var er;
+ if (args.length > 0)
+ er = args[0];
+ if (er instanceof Error) {
+ // Note: The comments on the `throw` lines are intentional, they show
+ // up in Node's output if this results in an unhandled exception.
+ throw er; // Unhandled 'error' event
+ }
+ // At least give some kind of context to the user
+ var err = new Error('Unhandled error.' + (er ? ' (' + er.message + ')' : ''));
+ err.context = er;
+ throw err; // Unhandled 'error' event
+ }
+
+ var handler = events[type];
+
+ if (handler === undefined)
+ return false;
+
+ if (typeof handler === 'function') {
+ ReflectApply(handler, this, args);
+ } else {
+ var len = handler.length;
+ var listeners = arrayClone(handler, len);
+ for (var i = 0; i < len; ++i)
+ ReflectApply(listeners[i], this, args);
+ }
+
+ return true;
+};
+
+function _addListener(target, type, listener, prepend) {
+ var m;
+ var events;
+ var existing;
+
+ checkListener(listener);
+
+ events = target._events;
+ if (events === undefined) {
+ events = target._events = Object.create(null);
+ target._eventsCount = 0;
+ } else {
+ // To avoid recursion in the case that type === "newListener"! Before
+ // adding it to the listeners, first emit "newListener".
+ if (events.newListener !== undefined) {
+ target.emit('newListener', type,
+ listener.listener ? listener.listener : listener);
+
+ // Re-assign `events` because a newListener handler could have caused the
+ // this._events to be assigned to a new object
+ events = target._events;
+ }
+ existing = events[type];
+ }
+
+ if (existing === undefined) {
+ // Optimize the case of one listener. Don't need the extra array object.
+ existing = events[type] = listener;
+ ++target._eventsCount;
+ } else {
+ if (typeof existing === 'function') {
+ // Adding the second element, need to change to array.
+ existing = events[type] =
+ prepend ? [listener, existing] : [existing, listener];
+ // If we've already got an array, just append.
+ } else if (prepend) {
+ existing.unshift(listener);
+ } else {
+ existing.push(listener);
+ }
+
+ // Check for listener leak
+ m = _getMaxListeners(target);
+ if (m > 0 && existing.length > m && !existing.warned) {
+ existing.warned = true;
+ // No error code for this since it is a Warning
+ // eslint-disable-next-line no-restricted-syntax
+ var w = new Error('Possible EventEmitter memory leak detected. ' +
+ existing.length + ' ' + String(type) + ' listeners ' +
+ 'added. Use emitter.setMaxListeners() to ' +
+ 'increase limit');
+ w.name = 'MaxListenersExceededWarning';
+ w.emitter = target;
+ w.type = type;
+ w.count = existing.length;
+ ProcessEmitWarning(w);
+ }
+ }
+
+ return target;
+}
+
+EventEmitter.prototype.addListener = function addListener(type, listener) {
+ return _addListener(this, type, listener, false);
+};
+
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+
+EventEmitter.prototype.prependListener =
+ function prependListener(type, listener) {
+ return _addListener(this, type, listener, true);
+ };
+
+function onceWrapper() {
+ if (!this.fired) {
+ this.target.removeListener(this.type, this.wrapFn);
+ this.fired = true;
+ if (arguments.length === 0)
+ return this.listener.call(this.target);
+ return this.listener.apply(this.target, arguments);
+ }
+}
+
+function _onceWrap(target, type, listener) {
+ var state = { fired: false, wrapFn: undefined, target: target, type: type, listener: listener };
+ var wrapped = onceWrapper.bind(state);
+ wrapped.listener = listener;
+ state.wrapFn = wrapped;
+ return wrapped;
+}
+
+EventEmitter.prototype.once = function once(type, listener) {
+ checkListener(listener);
+ this.on(type, _onceWrap(this, type, listener));
+ return this;
+};
+
+EventEmitter.prototype.prependOnceListener =
+ function prependOnceListener(type, listener) {
+ checkListener(listener);
+ this.prependListener(type, _onceWrap(this, type, listener));
+ return this;
+ };
+
+// Emits a 'removeListener' event if and only if the listener was removed.
+EventEmitter.prototype.removeListener =
+ function removeListener(type, listener) {
+ var list, events, position, i, originalListener;
+
+ checkListener(listener);
+
+ events = this._events;
+ if (events === undefined)
+ return this;
+
+ list = events[type];
+ if (list === undefined)
+ return this;
+
+ if (list === listener || list.listener === listener) {
+ if (--this._eventsCount === 0)
+ this._events = Object.create(null);
+ else {
+ delete events[type];
+ if (events.removeListener)
+ this.emit('removeListener', type, list.listener || listener);
+ }
+ } else if (typeof list !== 'function') {
+ position = -1;
+
+ for (i = list.length - 1; i >= 0; i--) {
+ if (list[i] === listener || list[i].listener === listener) {
+ originalListener = list[i].listener;
+ position = i;
+ break;
+ }
+ }
+
+ if (position < 0)
+ return this;
+
+ if (position === 0)
+ list.shift();
+ else {
+ spliceOne(list, position);
+ }
+
+ if (list.length === 1)
+ events[type] = list[0];
+
+ if (events.removeListener !== undefined)
+ this.emit('removeListener', type, originalListener || listener);
+ }
+
+ return this;
+ };
+
+EventEmitter.prototype.off = EventEmitter.prototype.removeListener;
+
+EventEmitter.prototype.removeAllListeners =
+ function removeAllListeners(type) {
+ var listeners, events, i;
+
+ events = this._events;
+ if (events === undefined)
+ return this;
+
+ // not listening for removeListener, no need to emit
+ if (events.removeListener === undefined) {
+ if (arguments.length === 0) {
+ this._events = Object.create(null);
+ this._eventsCount = 0;
+ } else if (events[type] !== undefined) {
+ if (--this._eventsCount === 0)
+ this._events = Object.create(null);
+ else
+ delete events[type];
+ }
+ return this;
+ }
+
+ // emit removeListener for all listeners on all events
+ if (arguments.length === 0) {
+ var keys = Object.keys(events);
+ var key;
+ for (i = 0; i < keys.length; ++i) {
+ key = keys[i];
+ if (key === 'removeListener') continue;
+ this.removeAllListeners(key);
+ }
+ this.removeAllListeners('removeListener');
+ this._events = Object.create(null);
+ this._eventsCount = 0;
+ return this;
+ }
+
+ listeners = events[type];
+
+ if (typeof listeners === 'function') {
+ this.removeListener(type, listeners);
+ } else if (listeners !== undefined) {
+ // LIFO order
+ for (i = listeners.length - 1; i >= 0; i--) {
+ this.removeListener(type, listeners[i]);
+ }
+ }
+
+ return this;
+ };
+
+function _listeners(target, type, unwrap) {
+ var events = target._events;
+
+ if (events === undefined)
+ return [];
+
+ var evlistener = events[type];
+ if (evlistener === undefined)
+ return [];
+
+ if (typeof evlistener === 'function')
+ return unwrap ? [evlistener.listener || evlistener] : [evlistener];
+
+ return unwrap ?
+ unwrapListeners(evlistener) : arrayClone(evlistener, evlistener.length);
+}
+
+EventEmitter.prototype.listeners = function listeners(type) {
+ return _listeners(this, type, true);
+};
+
+EventEmitter.prototype.rawListeners = function rawListeners(type) {
+ return _listeners(this, type, false);
+};
+
+EventEmitter.listenerCount = function(emitter, type) {
+ if (typeof emitter.listenerCount === 'function') {
+ return emitter.listenerCount(type);
+ } else {
+ return listenerCount.call(emitter, type);
+ }
+};
+
+EventEmitter.prototype.listenerCount = listenerCount;
+function listenerCount(type) {
+ var events = this._events;
+
+ if (events !== undefined) {
+ var evlistener = events[type];
+
+ if (typeof evlistener === 'function') {
+ return 1;
+ } else if (evlistener !== undefined) {
+ return evlistener.length;
+ }
+ }
+
+ return 0;
+}
+
+EventEmitter.prototype.eventNames = function eventNames() {
+ return this._eventsCount > 0 ? ReflectOwnKeys(this._events) : [];
+};
+
+function arrayClone(arr, n) {
+ var copy = new Array(n);
+ for (var i = 0; i < n; ++i)
+ copy[i] = arr[i];
+ return copy;
+}
+
+function spliceOne(list, index) {
+ for (; index + 1 < list.length; index++)
+ list[index] = list[index + 1];
+ list.pop();
+}
+
+function unwrapListeners(arr) {
+ var ret = new Array(arr.length);
+ for (var i = 0; i < ret.length; ++i) {
+ ret[i] = arr[i].listener || arr[i];
+ }
+ return ret;
+}
+
+function once(emitter, name) {
+ return new Promise(function (resolve, reject) {
+ function errorListener(err) {
+ emitter.removeListener(name, resolver);
+ reject(err);
+ }
+
+ function resolver() {
+ if (typeof emitter.removeListener === 'function') {
+ emitter.removeListener('error', errorListener);
+ }
+ resolve([].slice.call(arguments));
+ };
+
+ eventTargetAgnosticAddListener(emitter, name, resolver, { once: true });
+ if (name !== 'error') {
+ addErrorHandlerIfEventEmitter(emitter, errorListener, { once: true });
+ }
+ });
+}
+
+function addErrorHandlerIfEventEmitter(emitter, handler, flags) {
+ if (typeof emitter.on === 'function') {
+ eventTargetAgnosticAddListener(emitter, 'error', handler, flags);
+ }
+}
+
+function eventTargetAgnosticAddListener(emitter, name, listener, flags) {
+ if (typeof emitter.on === 'function') {
+ if (flags.once) {
+ emitter.once(name, listener);
+ } else {
+ emitter.on(name, listener);
+ }
+ } else if (typeof emitter.addEventListener === 'function') {
+ // EventTarget does not have `error` event semantics like Node
+ // EventEmitters, we do not listen for `error` events here.
+ emitter.addEventListener(name, function wrapListener(arg) {
+ // IE does not have builtin `{ once: true }` support so we
+ // have to do it manually.
+ if (flags.once) {
+ emitter.removeEventListener(name, wrapListener);
+ }
+ listener(arg);
+ });
+ } else {
+ throw new TypeError('The "emitter" argument must be of type EventEmitter. Received type ' + typeof emitter);
+ }
+}
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/ExtensibleEvents.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/ExtensibleEvents.js
new file mode 100644
index 0000000000..50a2247cb2
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/ExtensibleEvents.js
@@ -0,0 +1,189 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ExtensibleEvents = void 0;
+
+var _NamespacedMap = require("./NamespacedMap");
+
+var _InvalidEventError = require("./InvalidEventError");
+
+var _MRoomMessage = require("./interpreters/legacy/MRoomMessage");
+
+var _MMessage = require("./interpreters/modern/MMessage");
+
+var _message_types = require("./events/message_types");
+
+var _poll_types = require("./events/poll_types");
+
+var _MPoll = require("./interpreters/modern/MPoll");
+
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * Utility class for parsing and identifying event types in a renderable form. An
+ * instance of this class can be created to change rendering preference depending
+ * on use-case.
+ */
+var ExtensibleEvents = /*#__PURE__*/function () {
+ function ExtensibleEvents() {
+ _classCallCheck(this, ExtensibleEvents);
+
+ _defineProperty(this, "interpreters", new _NamespacedMap.NamespacedMap([// Remember to add your unit test when adding to this! ("known events" test description)
+ [_MRoomMessage.LEGACY_M_ROOM_MESSAGE, _MRoomMessage.parseMRoomMessage], [_message_types.M_MESSAGE, _MMessage.parseMMessage], [_message_types.M_EMOTE, _MMessage.parseMMessage], [_message_types.M_NOTICE, _MMessage.parseMMessage], [_poll_types.M_POLL_START, _MPoll.parseMPoll], [_poll_types.M_POLL_RESPONSE, _MPoll.parseMPoll], [_poll_types.M_POLL_END, _MPoll.parseMPoll]]));
+
+ _defineProperty(this, "_unknownInterpretOrder", [_message_types.M_MESSAGE]);
+ }
+ /**
+ * Gets the default instance for all extensible event parsing.
+ */
+
+
+ _createClass(ExtensibleEvents, [{
+ key: "unknownInterpretOrder",
+ get:
+ /**
+ * Gets the order the internal processor will use for unknown primary
+ * event types.
+ */
+ function get() {
+ var _this$_unknownInterpr;
+
+ return (_this$_unknownInterpr = this._unknownInterpretOrder) !== null && _this$_unknownInterpr !== void 0 ? _this$_unknownInterpr : [];
+ }
+ /**
+ * Sets the order the internal processor will use for unknown primary
+ * event types.
+ * @param {NamespacedValue<string, string>[]} val The parsing order.
+ */
+ ,
+ set: function set(val) {
+ this._unknownInterpretOrder = val;
+ }
+ /**
+ * Gets the order the internal processor will use for unknown primary
+ * event types.
+ */
+
+ }, {
+ key: "registerInterpreter",
+ value:
+ /**
+ * Registers a primary event type interpreter. Note that the interpreter might be
+ * called with non-primary events if the event is being parsed as a fallback.
+ * @param {NamespacedValue<string, string>} wireEventType The event type.
+ * @param {EventInterpreter} interpreter The interpreter.
+ */
+ function registerInterpreter(wireEventType, interpreter) {
+ this.interpreters.set(wireEventType, interpreter);
+ }
+ /**
+ * Registers a primary event type interpreter. Note that the interpreter might be
+ * called with non-primary events if the event is being parsed as a fallback.
+ * @param {NamespacedValue<string, string>} wireEventType The event type.
+ * @param {EventInterpreter} interpreter The interpreter.
+ */
+
+ }, {
+ key: "parse",
+ value:
+ /**
+ * Parses an event, trying the primary event type first. If the primary type is not known
+ * then the content will be inspected to find the most suitable fallback.
+ *
+ * If the parsing failed or was a completely unknown type, this will return falsy.
+ * @param {IPartialEvent<object>} wireFormat The event to parse.
+ * @returns {Optional<ExtensibleEvent>} The parsed extensible event.
+ */
+ function parse(wireFormat) {
+ try {
+ if (this.interpreters.hasNamespaced(wireFormat.type)) {
+ return this.interpreters.getNamespaced(wireFormat.type)(wireFormat);
+ }
+
+ var _iterator = _createForOfIteratorHelper(this.unknownInterpretOrder),
+ _step;
+
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var tryType = _step.value;
+
+ if (this.interpreters.has(tryType)) {
+ var val = this.interpreters.get(tryType)(wireFormat);
+ if (val) return val;
+ }
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+
+ return null; // cannot be parsed
+ } catch (e) {
+ if (e instanceof _InvalidEventError.InvalidEventError) {
+ return null; // fail parsing and move on
+ }
+
+ throw e; // re-throw everything else
+ }
+ }
+ /**
+ * Parses an event, trying the primary event type first. If the primary type is not known
+ * then the content will be inspected to find the most suitable fallback.
+ *
+ * If the parsing failed or was a completely unknown type, this will return falsy.
+ * @param {IPartialEvent<object>} wireFormat The event to parse.
+ * @returns {Optional<ExtensibleEvent>} The parsed extensible event.
+ */
+
+ }], [{
+ key: "defaultInstance",
+ get: function get() {
+ return ExtensibleEvents._defaultInstance;
+ }
+ }, {
+ key: "unknownInterpretOrder",
+ get: function get() {
+ return ExtensibleEvents.defaultInstance.unknownInterpretOrder;
+ }
+ /**
+ * Sets the order the internal processor will use for unknown primary
+ * event types.
+ * @param {NamespacedValue<string, string>[]} val The parsing order.
+ */
+ ,
+ set: function set(val) {
+ ExtensibleEvents.defaultInstance.unknownInterpretOrder = val;
+ }
+ }, {
+ key: "registerInterpreter",
+ value: function registerInterpreter(wireEventType, interpreter) {
+ ExtensibleEvents.defaultInstance.registerInterpreter(wireEventType, interpreter);
+ }
+ }, {
+ key: "parse",
+ value: function parse(wireFormat) {
+ return ExtensibleEvents.defaultInstance.parse(wireFormat);
+ }
+ }]);
+
+ return ExtensibleEvents;
+}();
+
+exports.ExtensibleEvents = ExtensibleEvents;
+
+_defineProperty(ExtensibleEvents, "_defaultInstance", new ExtensibleEvents()); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/IPartialEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/IPartialEvent.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/IPartialEvent.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/InvalidEventError.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/InvalidEventError.js
new file mode 100644
index 0000000000..a490925ba5
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/InvalidEventError.js
@@ -0,0 +1,69 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.InvalidEventError = void 0;
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _wrapNativeSuper(Class) { var _cache = typeof Map === "function" ? new Map() : undefined; _wrapNativeSuper = function _wrapNativeSuper(Class) { if (Class === null || !_isNativeFunction(Class)) return Class; if (typeof Class !== "function") { throw new TypeError("Super expression must either be null or a function"); } if (typeof _cache !== "undefined") { if (_cache.has(Class)) return _cache.get(Class); _cache.set(Class, Wrapper); } function Wrapper() { return _construct(Class, arguments, _getPrototypeOf(this).constructor); } Wrapper.prototype = Object.create(Class.prototype, { constructor: { value: Wrapper, enumerable: false, writable: true, configurable: true } }); return _setPrototypeOf(Wrapper, Class); }; return _wrapNativeSuper(Class); }
+
+function _construct(Parent, args, Class) { if (_isNativeReflectConstruct()) { _construct = Reflect.construct; } else { _construct = function _construct(Parent, args, Class) { var a = [null]; a.push.apply(a, args); var Constructor = Function.bind.apply(Parent, a); var instance = new Constructor(); if (Class) _setPrototypeOf(instance, Class.prototype); return instance; }; } return _construct.apply(null, arguments); }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _isNativeFunction(fn) { return Function.toString.call(fn).indexOf("[native code]") !== -1; }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+/*
+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.
+*/
+
+/**
+ * Thrown when an event is unforgivably unparsable.
+ */
+var InvalidEventError = /*#__PURE__*/function (_Error) {
+ _inherits(InvalidEventError, _Error);
+
+ var _super = _createSuper(InvalidEventError);
+
+ function InvalidEventError(message) {
+ _classCallCheck(this, InvalidEventError);
+
+ return _super.call(this, message);
+ }
+
+ return _createClass(InvalidEventError);
+}( /*#__PURE__*/_wrapNativeSuper(Error));
+
+exports.InvalidEventError = InvalidEventError; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/LICENSE b/comm/chat/protocols/matrix/lib/matrix-events-sdk/LICENSE
new file mode 100644
index 0000000000..261eeb9e9f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedMap.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedMap.js
new file mode 100644
index 0000000000..fe71e2a124
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedMap.js
@@ -0,0 +1,149 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.NamespacedMap = void 0;
+
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * A `Map` implementation which accepts a NamespacedValue as a key, and arbitrary value. The
+ * namespaced value must be a string type.
+ */
+var NamespacedMap = /*#__PURE__*/function () {
+ // protected to make tests happy for access
+
+ /**
+ * Creates a new map with optional seed data.
+ * @param {Array<[NS, V]>} initial The seed data.
+ */
+ function NamespacedMap(initial) {
+ _classCallCheck(this, NamespacedMap);
+
+ _defineProperty(this, "internalMap", new Map());
+
+ if (initial) {
+ var _iterator = _createForOfIteratorHelper(initial),
+ _step;
+
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var val = _step.value;
+ this.set(val[0], val[1]);
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+ }
+ }
+ /**
+ * Gets a value from the map. If the value does not exist under
+ * either namespace option, falsy is returned.
+ * @param {NS} key The key.
+ * @returns {Optional<V>} The value, or falsy.
+ */
+
+
+ _createClass(NamespacedMap, [{
+ key: "get",
+ value: function get(key) {
+ if (key.name && this.internalMap.has(key.name)) {
+ return this.internalMap.get(key.name);
+ }
+
+ if (key.altName && this.internalMap.has(key.altName)) {
+ return this.internalMap.get(key.altName);
+ }
+
+ return null;
+ }
+ /**
+ * Sets a value in the map.
+ * @param {NS} key The key.
+ * @param {V} val The value.
+ */
+
+ }, {
+ key: "set",
+ value: function set(key, val) {
+ if (key.name) {
+ this.internalMap.set(key.name, val);
+ }
+
+ if (key.altName) {
+ this.internalMap.set(key.altName, val);
+ }
+ }
+ /**
+ * Determines if any of the valid namespaced values are present
+ * in the map.
+ * @param {NS} key The key.
+ * @returns {boolean} True if present.
+ */
+
+ }, {
+ key: "has",
+ value: function has(key) {
+ return !!this.get(key);
+ }
+ /**
+ * Removes all the namespaced values from the map.
+ * @param {NS} key The key.
+ */
+
+ }, {
+ key: "delete",
+ value: function _delete(key) {
+ if (key.name) {
+ this.internalMap["delete"](key.name);
+ }
+
+ if (key.altName) {
+ this.internalMap["delete"](key.altName);
+ }
+ }
+ /**
+ * Determines if the map contains a specific namespaced value
+ * instead of the parent NS type.
+ * @param {string} key The key.
+ * @returns {boolean} True if present.
+ */
+
+ }, {
+ key: "hasNamespaced",
+ value: function hasNamespaced(key) {
+ return this.internalMap.has(key);
+ }
+ /**
+ * Gets a specific namespaced value from the map instead of the
+ * parent NS type. Returns falsy if not found.
+ * @param {string} key The key.
+ * @returns {Optional<V>} The value, or falsy.
+ */
+
+ }, {
+ key: "getNamespaced",
+ value: function getNamespaced(key) {
+ return this.internalMap.get(key);
+ }
+ }]);
+
+ return NamespacedMap;
+}();
+
+exports.NamespacedMap = NamespacedMap; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedValue.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedValue.js
new file mode 100644
index 0000000000..5775b420ac
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedValue.js
@@ -0,0 +1,166 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UnstableValue = exports.NamespacedValue = void 0;
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+/*
+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.
+*/
+
+/**
+ * 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.
+ */
+var NamespacedValue = /*#__PURE__*/function () {
+ // Stable is optional, but one of the two parameters is required, hence the weird-looking types.
+ // Goal is to have developers explicitly say there is no stable value (if applicable).
+ function NamespacedValue(stable, unstable) {
+ _classCallCheck(this, NamespacedValue);
+
+ this.stable = stable;
+ this.unstable = unstable;
+
+ if (!this.unstable && !this.stable) {
+ throw new Error("One of stable or unstable values must be supplied");
+ }
+ }
+
+ _createClass(NamespacedValue, [{
+ key: "name",
+ get: function get() {
+ if (this.stable) {
+ return this.stable;
+ }
+
+ return this.unstable;
+ }
+ }, {
+ key: "altName",
+ get: function get() {
+ if (!this.stable) {
+ return null;
+ }
+
+ return this.unstable;
+ }
+ }, {
+ key: "matches",
+ value: function matches(val) {
+ return !!this.name && this.name === val || !!this.altName && 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.
+
+ }, {
+ key: "findIn",
+ value: function findIn(obj) {
+ var val;
+
+ if (this.name) {
+ val = obj === null || obj === void 0 ? void 0 : obj[this.name];
+ }
+
+ if (!val && this.altName) {
+ val = obj === null || obj === void 0 ? void 0 : obj[this.altName];
+ }
+
+ return val;
+ }
+ }, {
+ key: "includedIn",
+ value: function includedIn(arr) {
+ var included = false;
+
+ if (this.name) {
+ included = arr.includes(this.name);
+ }
+
+ if (!included && this.altName) {
+ included = arr.includes(this.altName);
+ }
+
+ return included;
+ }
+ }]);
+
+ return NamespacedValue;
+}();
+/**
+ * Represents a namespaced value which prioritizes the unstable value over the stable
+ * value.
+ */
+
+
+exports.NamespacedValue = NamespacedValue;
+
+var UnstableValue = /*#__PURE__*/function (_NamespacedValue) {
+ _inherits(UnstableValue, _NamespacedValue);
+
+ var _super = _createSuper(UnstableValue);
+
+ // Note: Constructor difference is that `unstable` is *required*.
+ function UnstableValue(stable, unstable) {
+ var _this;
+
+ _classCallCheck(this, UnstableValue);
+
+ _this = _super.call(this, stable, unstable);
+
+ if (!_this.unstable) {
+ throw new Error("Unstable value must be supplied");
+ }
+
+ return _this;
+ }
+
+ _createClass(UnstableValue, [{
+ key: "name",
+ get: function get() {
+ return this.unstable;
+ }
+ }, {
+ key: "altName",
+ get: function get() {
+ return this.stable;
+ }
+ }]);
+
+ return UnstableValue;
+}(NamespacedValue);
+
+exports.UnstableValue = UnstableValue; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/EmoteEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/EmoteEvent.js
new file mode 100644
index 0000000000..1b1a2f2cc1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/EmoteEvent.js
@@ -0,0 +1,99 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EmoteEvent = void 0;
+
+var _MessageEvent2 = require("./MessageEvent");
+
+var _message_types = require("./message_types");
+
+var _events = require("../utility/events");
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _get() { if (typeof Reflect !== "undefined" && Reflect.get) { _get = Reflect.get; } else { _get = function _get(target, property, receiver) { var base = _superPropBase(target, property); if (!base) return; var desc = Object.getOwnPropertyDescriptor(base, property); if (desc.get) { return desc.get.call(arguments.length < 3 ? target : receiver); } return desc.value; }; } return _get.apply(this, arguments); }
+
+function _superPropBase(object, property) { while (!Object.prototype.hasOwnProperty.call(object, property)) { object = _getPrototypeOf(object); if (object === null) break; } return object; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+// Emote events are just decorated message events
+
+/**
+ * Represents an emote. This is essentially a MessageEvent with
+ * emote characteristics considered.
+ */
+var EmoteEvent = /*#__PURE__*/function (_MessageEvent) {
+ _inherits(EmoteEvent, _MessageEvent);
+
+ var _super = _createSuper(EmoteEvent);
+
+ function EmoteEvent(wireFormat) {
+ _classCallCheck(this, EmoteEvent);
+
+ return _super.call(this, wireFormat);
+ }
+
+ _createClass(EmoteEvent, [{
+ key: "isEmote",
+ get: function get() {
+ return true; // override
+ }
+ }, {
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _message_types.M_EMOTE) || _get(_getPrototypeOf(EmoteEvent.prototype), "isEquivalentTo", this).call(this, primaryEventType);
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ var message = _get(_getPrototypeOf(EmoteEvent.prototype), "serialize", this).call(this);
+
+ message.content['msgtype'] = "m.emote";
+ return message;
+ }
+ /**
+ * Creates a new EmoteEvent from text and HTML.
+ * @param {string} text The text.
+ * @param {string} html Optional HTML.
+ * @returns {MessageEvent} The representative message event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(text, html) {
+ var _content;
+
+ return new EmoteEvent({
+ type: _message_types.M_EMOTE.name,
+ content: (_content = {}, _defineProperty(_content, _message_types.M_TEXT.name, text), _defineProperty(_content, _message_types.M_HTML.name, html), _content)
+ });
+ }
+ }]);
+
+ return EmoteEvent;
+}(_MessageEvent2.MessageEvent);
+
+exports.EmoteEvent = EmoteEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/ExtensibleEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/ExtensibleEvent.js
new file mode 100644
index 0000000000..e7959d0a39
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/ExtensibleEvent.js
@@ -0,0 +1,60 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ExtensibleEvent = void 0;
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+/*
+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.
+*/
+
+/**
+ * Represents an Extensible Event in Matrix.
+ */
+var ExtensibleEvent = /*#__PURE__*/function () {
+ function ExtensibleEvent(wireFormat) {
+ _classCallCheck(this, ExtensibleEvent);
+
+ this.wireFormat = wireFormat;
+ }
+ /**
+ * Shortcut to wireFormat.content
+ */
+
+
+ _createClass(ExtensibleEvent, [{
+ key: "wireContent",
+ get: function get() {
+ return this.wireFormat.content;
+ }
+ /**
+ * Serializes the event into a format which can be used to send the
+ * event to the room.
+ * @returns {IPartialEvent<object>} The serialized event.
+ */
+
+ }]);
+
+ return ExtensibleEvent;
+}();
+
+exports.ExtensibleEvent = ExtensibleEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/MessageEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/MessageEvent.js
new file mode 100644
index 0000000000..f11d5867be
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/MessageEvent.js
@@ -0,0 +1,214 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MessageEvent = void 0;
+
+var _ExtensibleEvent2 = require("./ExtensibleEvent");
+
+var _types = require("../types");
+
+var _InvalidEventError = require("../InvalidEventError");
+
+var _message_types = require("./message_types");
+
+var _events = require("../utility/events");
+
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * 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.
+ */
+var MessageEvent = /*#__PURE__*/function (_ExtensibleEvent) {
+ _inherits(MessageEvent, _ExtensibleEvent);
+
+ var _super = _createSuper(MessageEvent);
+
+ /**
+ * The default text for the event.
+ */
+
+ /**
+ * The default HTML for the event, if provided.
+ */
+
+ /**
+ * 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.
+ */
+
+ /**
+ * 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 {IPartialEvent<M_MESSAGE_EVENT_CONTENT>} wireFormat The event.
+ */
+ function MessageEvent(wireFormat) {
+ var _this;
+
+ _classCallCheck(this, MessageEvent);
+
+ _this = _super.call(this, wireFormat);
+
+ _defineProperty(_assertThisInitialized(_this), "text", void 0);
+
+ _defineProperty(_assertThisInitialized(_this), "html", void 0);
+
+ _defineProperty(_assertThisInitialized(_this), "renderings", void 0);
+
+ var mmessage = _message_types.M_MESSAGE.findIn(_this.wireContent);
+
+ var mtext = _message_types.M_TEXT.findIn(_this.wireContent);
+
+ var mhtml = _message_types.M_HTML.findIn(_this.wireContent);
+
+ if ((0, _types.isProvided)(mmessage)) {
+ if (!Array.isArray(mmessage)) {
+ throw new _InvalidEventError.InvalidEventError("m.message contents must be an array");
+ }
+
+ var text = mmessage.find(function (r) {
+ return !(0, _types.isProvided)(r.mimetype) || r.mimetype === "text/plain";
+ });
+ var html = mmessage.find(function (r) {
+ return r.mimetype === "text/html";
+ });
+ if (!text) throw new _InvalidEventError.InvalidEventError("m.message is missing a plain text representation");
+ _this.text = text.body;
+ _this.html = html === null || html === void 0 ? void 0 : html.body;
+ _this.renderings = mmessage;
+ } else if ((0, _types.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.InvalidEventError("Missing textual representation for event");
+ }
+
+ return _this;
+ }
+ /**
+ * Gets whether this message is considered an "emote". Note that a message
+ * might be an emote and notice at the same time: while technically possible,
+ * the event should be interpreted as one or the other.
+ */
+
+
+ _createClass(MessageEvent, [{
+ key: "isEmote",
+ get: function get() {
+ return _message_types.M_EMOTE.matches(this.wireFormat.type) || (0, _types.isProvided)(_message_types.M_EMOTE.findIn(this.wireFormat.content));
+ }
+ /**
+ * Gets whether this message is considered a "notice". Note that a message
+ * might be an emote and notice at the same time: while technically possible,
+ * the event should be interpreted as one or the other.
+ */
+
+ }, {
+ key: "isNotice",
+ get: function get() {
+ return _message_types.M_NOTICE.matches(this.wireFormat.type) || (0, _types.isProvided)(_message_types.M_NOTICE.findIn(this.wireFormat.content));
+ }
+ }, {
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _message_types.M_MESSAGE);
+ }
+ }, {
+ key: "serializeMMessageOnly",
+ value: function serializeMMessageOnly() {
+ var messageRendering = _defineProperty({}, _message_types.M_MESSAGE.name, this.renderings); // Use the shorthand if it's just a simple text event
+
+
+ if (this.renderings.length === 1) {
+ var mime = this.renderings[0].mimetype;
+
+ if (mime === undefined || mime === "text/plain") {
+ messageRendering = _defineProperty({}, _message_types.M_TEXT.name, this.renderings[0].body);
+ }
+ }
+
+ return messageRendering;
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ var _this$html;
+
+ return {
+ type: "m.room.message",
+ content: _objectSpread(_objectSpread({}, this.serializeMMessageOnly()), {}, {
+ body: this.text,
+ msgtype: "m.text",
+ format: this.html ? "org.matrix.custom.html" : undefined,
+ formatted_body: (_this$html = this.html) !== null && _this$html !== void 0 ? _this$html : undefined
+ })
+ };
+ }
+ /**
+ * Creates a new MessageEvent from text and HTML.
+ * @param {string} text The text.
+ * @param {string} html Optional HTML.
+ * @returns {MessageEvent} The representative message event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(text, html) {
+ var _content;
+
+ return new MessageEvent({
+ type: _message_types.M_MESSAGE.name,
+ content: (_content = {}, _defineProperty(_content, _message_types.M_TEXT.name, text), _defineProperty(_content, _message_types.M_HTML.name, html), _content)
+ });
+ }
+ }]);
+
+ return MessageEvent;
+}(_ExtensibleEvent2.ExtensibleEvent);
+
+exports.MessageEvent = MessageEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/NoticeEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/NoticeEvent.js
new file mode 100644
index 0000000000..999d192470
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/NoticeEvent.js
@@ -0,0 +1,99 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.NoticeEvent = void 0;
+
+var _MessageEvent2 = require("./MessageEvent");
+
+var _message_types = require("./message_types");
+
+var _events = require("../utility/events");
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _get() { if (typeof Reflect !== "undefined" && Reflect.get) { _get = Reflect.get; } else { _get = function _get(target, property, receiver) { var base = _superPropBase(target, property); if (!base) return; var desc = Object.getOwnPropertyDescriptor(base, property); if (desc.get) { return desc.get.call(arguments.length < 3 ? target : receiver); } return desc.value; }; } return _get.apply(this, arguments); }
+
+function _superPropBase(object, property) { while (!Object.prototype.hasOwnProperty.call(object, property)) { object = _getPrototypeOf(object); if (object === null) break; } return object; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+// Notice events are just decorated message events
+
+/**
+ * Represents a notice. This is essentially a MessageEvent with
+ * notice characteristics considered.
+ */
+var NoticeEvent = /*#__PURE__*/function (_MessageEvent) {
+ _inherits(NoticeEvent, _MessageEvent);
+
+ var _super = _createSuper(NoticeEvent);
+
+ function NoticeEvent(wireFormat) {
+ _classCallCheck(this, NoticeEvent);
+
+ return _super.call(this, wireFormat);
+ }
+
+ _createClass(NoticeEvent, [{
+ key: "isNotice",
+ get: function get() {
+ return true; // override
+ }
+ }, {
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _message_types.M_NOTICE) || _get(_getPrototypeOf(NoticeEvent.prototype), "isEquivalentTo", this).call(this, primaryEventType);
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ var message = _get(_getPrototypeOf(NoticeEvent.prototype), "serialize", this).call(this);
+
+ message.content['msgtype'] = "m.notice";
+ return message;
+ }
+ /**
+ * Creates a new NoticeEvent from text and HTML.
+ * @param {string} text The text.
+ * @param {string} html Optional HTML.
+ * @returns {MessageEvent} The representative message event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(text, html) {
+ var _content;
+
+ return new NoticeEvent({
+ type: _message_types.M_NOTICE.name,
+ content: (_content = {}, _defineProperty(_content, _message_types.M_TEXT.name, text), _defineProperty(_content, _message_types.M_HTML.name, html), _content)
+ });
+ }
+ }]);
+
+ return NoticeEvent;
+}(_MessageEvent2.MessageEvent);
+
+exports.NoticeEvent = NoticeEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollEndEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollEndEvent.js
new file mode 100644
index 0000000000..4c55da81a4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollEndEvent.js
@@ -0,0 +1,138 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollEndEvent = void 0;
+
+var _poll_types = require("./poll_types");
+
+var _InvalidEventError = require("../InvalidEventError");
+
+var _relationship_types = require("./relationship_types");
+
+var _MessageEvent = require("./MessageEvent");
+
+var _message_types = require("./message_types");
+
+var _events = require("../utility/events");
+
+var _ExtensibleEvent2 = require("./ExtensibleEvent");
+
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * Represents a poll end/closure event.
+ */
+var PollEndEvent = /*#__PURE__*/function (_ExtensibleEvent) {
+ _inherits(PollEndEvent, _ExtensibleEvent);
+
+ var _super = _createSuper(PollEndEvent);
+
+ /**
+ * The poll start event ID referenced by the response.
+ */
+
+ /**
+ * The closing message for the event.
+ */
+
+ /**
+ * 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 {IPartialEvent<M_POLL_END_EVENT_CONTENT>} wireFormat The event.
+ */
+ function PollEndEvent(wireFormat) {
+ var _this;
+
+ _classCallCheck(this, PollEndEvent);
+
+ _this = _super.call(this, wireFormat);
+
+ _defineProperty(_assertThisInitialized(_this), "pollEventId", void 0);
+
+ _defineProperty(_assertThisInitialized(_this), "closingMessage", void 0);
+
+ var rel = _this.wireContent["m.relates_to"];
+
+ if (!_relationship_types.REFERENCE_RELATION.matches(rel === null || rel === void 0 ? void 0 : rel.rel_type) || typeof (rel === null || rel === void 0 ? void 0 : rel.event_id) !== "string") {
+ throw new _InvalidEventError.InvalidEventError("Relationship must be a reference to an event");
+ }
+
+ _this.pollEventId = rel.event_id;
+ _this.closingMessage = new _MessageEvent.MessageEvent(_this.wireFormat);
+ return _this;
+ }
+
+ _createClass(PollEndEvent, [{
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _poll_types.M_POLL_END);
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ return {
+ type: _poll_types.M_POLL_END.name,
+ content: _objectSpread(_defineProperty({
+ "m.relates_to": {
+ rel_type: _relationship_types.REFERENCE_RELATION.name,
+ event_id: this.pollEventId
+ }
+ }, _poll_types.M_POLL_END.name, {}), this.closingMessage.serialize().content)
+ };
+ }
+ /**
+ * Creates a new PollEndEvent from a poll event ID.
+ * @param {string} pollEventId The poll start event ID.
+ * @param {string} message A closing message, typically revealing the top answer.
+ * @returns {PollStartEvent} The representative poll closure event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(pollEventId, message) {
+ var _content;
+
+ return new PollEndEvent({
+ type: _poll_types.M_POLL_END.name,
+ content: (_content = {
+ "m.relates_to": {
+ rel_type: _relationship_types.REFERENCE_RELATION.name,
+ event_id: pollEventId
+ }
+ }, _defineProperty(_content, _poll_types.M_POLL_END.name, {}), _defineProperty(_content, _message_types.M_TEXT.name, message), _content)
+ });
+ }
+ }]);
+
+ return PollEndEvent;
+}(_ExtensibleEvent2.ExtensibleEvent);
+
+exports.PollEndEvent = PollEndEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollResponseEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollResponseEvent.js
new file mode 100644
index 0000000000..f060d55bc7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollResponseEvent.js
@@ -0,0 +1,198 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollResponseEvent = void 0;
+
+var _ExtensibleEvent2 = require("./ExtensibleEvent");
+
+var _poll_types = require("./poll_types");
+
+var _InvalidEventError = require("../InvalidEventError");
+
+var _relationship_types = require("./relationship_types");
+
+var _events = require("../utility/events");
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * Represents a poll response event.
+ */
+var PollResponseEvent = /*#__PURE__*/function (_ExtensibleEvent) {
+ _inherits(PollResponseEvent, _ExtensibleEvent);
+
+ var _super = _createSuper(PollResponseEvent);
+
+ /**
+ * 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 {IPartialEvent<M_POLL_RESPONSE_EVENT_CONTENT>} wireFormat The event.
+ */
+ function PollResponseEvent(wireFormat) {
+ var _this;
+
+ _classCallCheck(this, PollResponseEvent);
+
+ _this = _super.call(this, wireFormat);
+
+ _defineProperty(_assertThisInitialized(_this), "internalAnswerIds", void 0);
+
+ _defineProperty(_assertThisInitialized(_this), "internalSpoiled", void 0);
+
+ _defineProperty(_assertThisInitialized(_this), "pollEventId", void 0);
+
+ var rel = _this.wireContent["m.relates_to"];
+
+ if (!_relationship_types.REFERENCE_RELATION.matches(rel === null || rel === void 0 ? void 0 : rel.rel_type) || typeof (rel === null || rel === void 0 ? void 0 : rel.event_id) !== "string") {
+ throw new _InvalidEventError.InvalidEventError("Relationship must be a reference to an event");
+ }
+
+ _this.pollEventId = rel.event_id;
+
+ _this.validateAgainst(null);
+
+ return _this;
+ }
+ /**
+ * 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 {PollStartEvent} poll The poll start event.
+ */
+
+
+ _createClass(PollResponseEvent, [{
+ key: "answerIds",
+ get:
+ /**
+ * The provided answers for the poll. Note that this may be falsy/unpredictable if
+ * the `spoiled` property is true.
+ */
+ function get() {
+ return this.internalAnswerIds;
+ }
+ /**
+ * The poll start event ID referenced by the response.
+ */
+
+ }, {
+ key: "spoiled",
+ get:
+ /**
+ * Whether the vote is spoiled.
+ */
+ function get() {
+ return this.internalSpoiled;
+ }
+ }, {
+ key: "validateAgainst",
+ value: function validateAgainst(poll) {
+ var response = _poll_types.M_POLL_RESPONSE.findIn(this.wireContent);
+
+ if (!Array.isArray(response === null || response === void 0 ? void 0 : response.answers)) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ var answers = response.answers;
+
+ if (answers.some(function (a) {
+ return typeof a !== "string";
+ }) || answers.length === 0) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ if (poll) {
+ if (answers.some(function (a) {
+ return !poll.answers.some(function (pa) {
+ return pa.id === a;
+ });
+ })) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ answers = answers.slice(0, poll.maxSelections);
+ }
+
+ this.internalAnswerIds = answers;
+ this.internalSpoiled = false;
+ }
+ }, {
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _poll_types.M_POLL_RESPONSE);
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ return {
+ type: _poll_types.M_POLL_RESPONSE.name,
+ content: _defineProperty({
+ "m.relates_to": {
+ rel_type: _relationship_types.REFERENCE_RELATION.name,
+ event_id: this.pollEventId
+ }
+ }, _poll_types.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 {string} answers The user's answers. Should be valid from a poll's answer IDs.
+ * @param {string} pollEventId The poll start event ID.
+ * @returns {PollStartEvent} The representative poll response event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(answers, pollEventId) {
+ return new PollResponseEvent({
+ type: _poll_types.M_POLL_RESPONSE.name,
+ content: _defineProperty({
+ "m.relates_to": {
+ rel_type: _relationship_types.REFERENCE_RELATION.name,
+ event_id: pollEventId
+ }
+ }, _poll_types.M_POLL_RESPONSE.name, {
+ answers: answers
+ })
+ });
+ }
+ }]);
+
+ return PollResponseEvent;
+}(_ExtensibleEvent2.ExtensibleEvent);
+
+exports.PollResponseEvent = PollResponseEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollStartEvent.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollStartEvent.js
new file mode 100644
index 0000000000..23bca9d415
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollStartEvent.js
@@ -0,0 +1,287 @@
+"use strict";
+
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollStartEvent = exports.PollAnswerSubevent = void 0;
+
+var _poll_types = require("./poll_types");
+
+var _MessageEvent2 = require("./MessageEvent");
+
+var _message_types = require("./message_types");
+
+var _InvalidEventError = require("../InvalidEventError");
+
+var _NamespacedValue = require("../NamespacedValue");
+
+var _events = require("../utility/events");
+
+var _ExtensibleEvent2 = require("./ExtensibleEvent");
+
+function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
+
+function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
+
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+
+function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); }
+
+function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
+
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
+
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+/**
+ * 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.
+ */
+var PollAnswerSubevent = /*#__PURE__*/function (_MessageEvent) {
+ _inherits(PollAnswerSubevent, _MessageEvent);
+
+ var _super = _createSuper(PollAnswerSubevent);
+
+ /**
+ * The answer ID.
+ */
+ function PollAnswerSubevent(wireFormat) {
+ var _this;
+
+ _classCallCheck(this, PollAnswerSubevent);
+
+ _this = _super.call(this, wireFormat);
+
+ _defineProperty(_assertThisInitialized(_this), "id", void 0);
+
+ var id = wireFormat.content.id;
+
+ if (!id || typeof id !== "string") {
+ throw new _InvalidEventError.InvalidEventError("Answer ID must be a non-empty string");
+ }
+
+ _this.id = id;
+ return _this;
+ }
+
+ _createClass(PollAnswerSubevent, [{
+ key: "serialize",
+ value: function serialize() {
+ return {
+ type: "org.matrix.sdk.poll.answer",
+ content: _objectSpread({
+ id: this.id
+ }, this.serializeMMessageOnly())
+ };
+ }
+ /**
+ * Creates a new PollAnswerSubevent from ID and text.
+ * @param {string} id The answer ID (unique within the poll).
+ * @param {string} text The text.
+ * @returns {PollAnswerSubevent} The representative answer.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(id, text) {
+ return new PollAnswerSubevent({
+ type: "org.matrix.sdk.poll.answer",
+ content: _defineProperty({
+ id: id
+ }, _message_types.M_TEXT.name, text)
+ });
+ }
+ }]);
+
+ return PollAnswerSubevent;
+}(_MessageEvent2.MessageEvent);
+/**
+ * Represents a poll start event.
+ */
+
+
+exports.PollAnswerSubevent = PollAnswerSubevent;
+
+var PollStartEvent = /*#__PURE__*/function (_ExtensibleEvent) {
+ _inherits(PollStartEvent, _ExtensibleEvent);
+
+ var _super2 = _createSuper(PollStartEvent);
+
+ /**
+ * The question being asked, as a MessageEvent node.
+ */
+
+ /**
+ * 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.
+ */
+
+ /**
+ * The true kind as provided by the event sender. Might not be valid.
+ */
+
+ /**
+ * The maximum number of selections a user is allowed to make.
+ */
+
+ /**
+ * The possible answers for the poll.
+ */
+
+ /**
+ * 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 {IPartialEvent<M_POLL_START_EVENT_CONTENT>} wireFormat The event.
+ */
+ function PollStartEvent(wireFormat) {
+ var _this2;
+
+ _classCallCheck(this, PollStartEvent);
+
+ _this2 = _super2.call(this, wireFormat);
+
+ _defineProperty(_assertThisInitialized(_this2), "question", void 0);
+
+ _defineProperty(_assertThisInitialized(_this2), "kind", void 0);
+
+ _defineProperty(_assertThisInitialized(_this2), "rawKind", void 0);
+
+ _defineProperty(_assertThisInitialized(_this2), "maxSelections", void 0);
+
+ _defineProperty(_assertThisInitialized(_this2), "answers", void 0);
+
+ var poll = _poll_types.M_POLL_START.findIn(_this2.wireContent);
+
+ if (!poll.question) {
+ throw new _InvalidEventError.InvalidEventError("A question is required");
+ }
+
+ _this2.question = new _MessageEvent2.MessageEvent({
+ type: "org.matrix.sdk.poll.question",
+ content: poll.question
+ });
+ _this2.rawKind = poll.kind;
+
+ if (_poll_types.M_POLL_KIND_DISCLOSED.matches(_this2.rawKind)) {
+ _this2.kind = _poll_types.M_POLL_KIND_DISCLOSED;
+ } else {
+ _this2.kind = _poll_types.M_POLL_KIND_UNDISCLOSED; // default & assumed value
+ }
+
+ _this2.maxSelections = Number.isFinite(poll.max_selections) && poll.max_selections > 0 ? poll.max_selections : 1;
+
+ if (!Array.isArray(poll.answers)) {
+ throw new _InvalidEventError.InvalidEventError("Poll answers must be an array");
+ }
+
+ var answers = poll.answers.slice(0, 20).map(function (a) {
+ return new PollAnswerSubevent({
+ type: "org.matrix.sdk.poll.answer",
+ content: a
+ });
+ });
+
+ if (answers.length <= 0) {
+ throw new _InvalidEventError.InvalidEventError("No answers available");
+ }
+
+ _this2.answers = answers;
+ return _this2;
+ }
+
+ _createClass(PollStartEvent, [{
+ key: "isEquivalentTo",
+ value: function isEquivalentTo(primaryEventType) {
+ return (0, _events.isEventTypeSame)(primaryEventType, _poll_types.M_POLL_START);
+ }
+ }, {
+ key: "serialize",
+ value: function serialize() {
+ var _content2;
+
+ return {
+ type: _poll_types.M_POLL_START.name,
+ content: (_content2 = {}, _defineProperty(_content2, _poll_types.M_POLL_START.name, {
+ question: this.question.serialize().content,
+ kind: this.rawKind,
+ max_selections: this.maxSelections,
+ answers: this.answers.map(function (a) {
+ return a.serialize().content;
+ })
+ }), _defineProperty(_content2, _message_types.M_TEXT.name, "".concat(this.question.text, "\n").concat(this.answers.map(function (a, i) {
+ return "".concat(i + 1, ". ").concat(a.text);
+ }).join("\n"))), _content2)
+ };
+ }
+ /**
+ * Creates a new PollStartEvent from question, answers, and metadata.
+ * @param {string} question The question to ask.
+ * @param {string} answers The answers. Should be unique within each other.
+ * @param {KNOWN_POLL_KIND|string} kind The kind of poll.
+ * @param {number} maxSelections The maximum number of selections. Must be 1 or higher.
+ * @returns {PollStartEvent} The representative poll start event.
+ */
+
+ }], [{
+ key: "from",
+ value: function from(question, answers, kind) {
+ var _content3;
+
+ var maxSelections = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 1;
+ return new PollStartEvent({
+ type: _poll_types.M_POLL_START.name,
+ content: (_content3 = {}, _defineProperty(_content3, _message_types.M_TEXT.name, question), _defineProperty(_content3, _poll_types.M_POLL_START.name, {
+ question: _defineProperty({}, _message_types.M_TEXT.name, question),
+ kind: kind instanceof _NamespacedValue.NamespacedValue ? kind.name : kind,
+ max_selections: maxSelections,
+ answers: answers.map(function (a) {
+ return _defineProperty({
+ id: makeId()
+ }, _message_types.M_TEXT.name, a);
+ })
+ }), _content3)
+ });
+ }
+ }]);
+
+ return PollStartEvent;
+}(_ExtensibleEvent2.ExtensibleEvent);
+
+exports.PollStartEvent = PollStartEvent;
+var LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+function makeId() {
+ return _toConsumableArray(Array(16)).map(function () {
+ return LETTERS.charAt(Math.floor(Math.random() * LETTERS.length));
+ }).join('');
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/message_types.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/message_types.js
new file mode 100644
index 0000000000..a466668bc1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/message_types.js
@@ -0,0 +1,74 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_TEXT = exports.M_NOTICE = exports.M_MESSAGE = exports.M_HTML = exports.M_EMOTE = void 0;
+
+var _NamespacedValue = require("../NamespacedValue");
+
+/*
+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 namespaced value for m.message
+ */
+var M_MESSAGE = new _NamespacedValue.UnstableValue("m.message", "org.matrix.msc1767.message");
+/**
+ * An m.message event rendering
+ */
+
+exports.M_MESSAGE = M_MESSAGE;
+
+/**
+ * The namespaced value for m.text
+ */
+var M_TEXT = new _NamespacedValue.UnstableValue("m.text", "org.matrix.msc1767.text");
+/**
+ * The content for an m.text event
+ */
+
+exports.M_TEXT = M_TEXT;
+
+/**
+ * The namespaced value for m.html
+ */
+var M_HTML = new _NamespacedValue.UnstableValue("m.html", "org.matrix.msc1767.html");
+/**
+ * The content for an m.html event
+ */
+
+exports.M_HTML = M_HTML;
+
+/**
+ * The namespaced value for m.emote
+ */
+var M_EMOTE = new _NamespacedValue.UnstableValue("m.emote", "org.matrix.msc1767.emote");
+/**
+ * The event definition for an m.emote event (in content)
+ */
+
+exports.M_EMOTE = M_EMOTE;
+
+/**
+ * The namespaced value for m.notice
+ */
+var M_NOTICE = new _NamespacedValue.UnstableValue("m.notice", "org.matrix.msc1767.notice");
+/**
+ * The event definition for an m.notice event (in content)
+ */
+
+exports.M_NOTICE = M_NOTICE; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/poll_types.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/poll_types.js
new file mode 100644
index 0000000000..48e4793ae1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/poll_types.js
@@ -0,0 +1,70 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_POLL_START = exports.M_POLL_RESPONSE = exports.M_POLL_KIND_UNDISCLOSED = exports.M_POLL_KIND_DISCLOSED = exports.M_POLL_END = void 0;
+
+var _NamespacedValue = require("../NamespacedValue");
+
+/*
+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.
+*/
+
+/**
+ * Identifier for a disclosed poll.
+ */
+var M_POLL_KIND_DISCLOSED = new _NamespacedValue.UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed");
+/**
+ * Identifier for an undisclosed poll.
+ */
+
+exports.M_POLL_KIND_DISCLOSED = M_POLL_KIND_DISCLOSED;
+var M_POLL_KIND_UNDISCLOSED = new _NamespacedValue.UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed");
+/**
+ * Any poll kind.
+ */
+
+exports.M_POLL_KIND_UNDISCLOSED = M_POLL_KIND_UNDISCLOSED;
+
+/**
+ * The namespaced value for m.poll.start
+ */
+var M_POLL_START = new _NamespacedValue.UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start");
+/**
+ * The m.poll.start type within event content
+ */
+
+exports.M_POLL_START = M_POLL_START;
+
+/**
+ * The namespaced value for m.poll.response
+ */
+var M_POLL_RESPONSE = new _NamespacedValue.UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response");
+/**
+ * The m.poll.response type within event content
+ */
+
+exports.M_POLL_RESPONSE = M_POLL_RESPONSE;
+
+/**
+ * The namespaced value for m.poll.end
+ */
+var M_POLL_END = new _NamespacedValue.UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
+/**
+ * The event definition for an m.poll.end event (in content)
+ */
+
+exports.M_POLL_END = M_POLL_END; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/relationship_types.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/relationship_types.js
new file mode 100644
index 0000000000..0704266479
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/events/relationship_types.js
@@ -0,0 +1,34 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.REFERENCE_RELATION = void 0;
+
+var _NamespacedValue = require("../NamespacedValue");
+
+/*
+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 namespaced value for an m.reference relation
+ */
+var REFERENCE_RELATION = new _NamespacedValue.NamespacedValue("m.reference");
+/**
+ * Represents any relation type
+ */
+
+exports.REFERENCE_RELATION = REFERENCE_RELATION; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/index.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/index.js
new file mode 100644
index 0000000000..d4abcd31d5
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/index.js
@@ -0,0 +1,278 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+
+var _ExtensibleEvents = require("./ExtensibleEvents");
+
+Object.keys(_ExtensibleEvents).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ExtensibleEvents[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ExtensibleEvents[key];
+ }
+ });
+});
+
+var _IPartialEvent = require("./IPartialEvent");
+
+Object.keys(_IPartialEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IPartialEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IPartialEvent[key];
+ }
+ });
+});
+
+var _InvalidEventError = require("./InvalidEventError");
+
+Object.keys(_InvalidEventError).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _InvalidEventError[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _InvalidEventError[key];
+ }
+ });
+});
+
+var _NamespacedValue = require("./NamespacedValue");
+
+Object.keys(_NamespacedValue).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _NamespacedValue[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _NamespacedValue[key];
+ }
+ });
+});
+
+var _NamespacedMap = require("./NamespacedMap");
+
+Object.keys(_NamespacedMap).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _NamespacedMap[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _NamespacedMap[key];
+ }
+ });
+});
+
+var _types = require("./types");
+
+Object.keys(_types).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _types[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _types[key];
+ }
+ });
+});
+
+var _MessageMatchers = require("./utility/MessageMatchers");
+
+Object.keys(_MessageMatchers).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MessageMatchers[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _MessageMatchers[key];
+ }
+ });
+});
+
+var _events = require("./utility/events");
+
+Object.keys(_events).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _events[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _events[key];
+ }
+ });
+});
+
+var _MRoomMessage = require("./interpreters/legacy/MRoomMessage");
+
+Object.keys(_MRoomMessage).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MRoomMessage[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _MRoomMessage[key];
+ }
+ });
+});
+
+var _MMessage = require("./interpreters/modern/MMessage");
+
+Object.keys(_MMessage).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MMessage[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _MMessage[key];
+ }
+ });
+});
+
+var _MPoll = require("./interpreters/modern/MPoll");
+
+Object.keys(_MPoll).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MPoll[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _MPoll[key];
+ }
+ });
+});
+
+var _relationship_types = require("./events/relationship_types");
+
+Object.keys(_relationship_types).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _relationship_types[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _relationship_types[key];
+ }
+ });
+});
+
+var _ExtensibleEvent = require("./events/ExtensibleEvent");
+
+Object.keys(_ExtensibleEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ExtensibleEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ExtensibleEvent[key];
+ }
+ });
+});
+
+var _message_types = require("./events/message_types");
+
+Object.keys(_message_types).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _message_types[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _message_types[key];
+ }
+ });
+});
+
+var _MessageEvent = require("./events/MessageEvent");
+
+Object.keys(_MessageEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MessageEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _MessageEvent[key];
+ }
+ });
+});
+
+var _EmoteEvent = require("./events/EmoteEvent");
+
+Object.keys(_EmoteEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _EmoteEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _EmoteEvent[key];
+ }
+ });
+});
+
+var _NoticeEvent = require("./events/NoticeEvent");
+
+Object.keys(_NoticeEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _NoticeEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _NoticeEvent[key];
+ }
+ });
+});
+
+var _poll_types = require("./events/poll_types");
+
+Object.keys(_poll_types).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _poll_types[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _poll_types[key];
+ }
+ });
+});
+
+var _PollStartEvent = require("./events/PollStartEvent");
+
+Object.keys(_PollStartEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _PollStartEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _PollStartEvent[key];
+ }
+ });
+});
+
+var _PollResponseEvent = require("./events/PollResponseEvent");
+
+Object.keys(_PollResponseEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _PollResponseEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _PollResponseEvent[key];
+ }
+ });
+});
+
+var _PollEndEvent = require("./events/PollEndEvent");
+
+Object.keys(_PollEndEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _PollEndEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _PollEndEvent[key];
+ }
+ });
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/legacy/MRoomMessage.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/legacy/MRoomMessage.js
new file mode 100644
index 0000000000..b362b52b16
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/legacy/MRoomMessage.js
@@ -0,0 +1,62 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.LEGACY_M_ROOM_MESSAGE = void 0;
+exports.parseMRoomMessage = parseMRoomMessage;
+
+var _MessageEvent = require("../../events/MessageEvent");
+
+var _NoticeEvent = require("../../events/NoticeEvent");
+
+var _EmoteEvent = require("../../events/EmoteEvent");
+
+var _NamespacedValue = require("../../NamespacedValue");
+
+var _message_types = require("../../events/message_types");
+
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+var LEGACY_M_ROOM_MESSAGE = new _NamespacedValue.NamespacedValue("m.room.message");
+exports.LEGACY_M_ROOM_MESSAGE = LEGACY_M_ROOM_MESSAGE;
+
+function parseMRoomMessage(wireEvent) {
+ var _wireEvent$content, _wireEvent$content2, _wireEvent$content3;
+
+ if (_message_types.M_MESSAGE.findIn(wireEvent.content) || _message_types.M_TEXT.findIn(wireEvent.content)) {
+ // We know enough about the event to coerce it into the right type
+ return new _MessageEvent.MessageEvent(wireEvent);
+ }
+
+ var msgtype = (_wireEvent$content = wireEvent.content) === null || _wireEvent$content === void 0 ? void 0 : _wireEvent$content.msgtype;
+ var text = (_wireEvent$content2 = wireEvent.content) === null || _wireEvent$content2 === void 0 ? void 0 : _wireEvent$content2.body;
+ var html = ((_wireEvent$content3 = wireEvent.content) === null || _wireEvent$content3 === void 0 ? void 0 : _wireEvent$content3.format) === "org.matrix.custom.html" ? wireEvent.content.formatted_body : null;
+
+ if (msgtype === "m.text") {
+ var _objectSpread2;
+
+ return new _MessageEvent.MessageEvent(_objectSpread(_objectSpread({}, wireEvent), {}, {
+ content: _objectSpread(_objectSpread({}, wireEvent.content), {}, (_objectSpread2 = {}, _defineProperty(_objectSpread2, _message_types.M_TEXT.name, text), _defineProperty(_objectSpread2, _message_types.M_HTML.name, html), _objectSpread2))
+ }));
+ } else if (msgtype === "m.notice") {
+ var _objectSpread3;
+
+ return new _NoticeEvent.NoticeEvent(_objectSpread(_objectSpread({}, wireEvent), {}, {
+ content: _objectSpread(_objectSpread({}, wireEvent.content), {}, (_objectSpread3 = {}, _defineProperty(_objectSpread3, _message_types.M_TEXT.name, text), _defineProperty(_objectSpread3, _message_types.M_HTML.name, html), _objectSpread3))
+ }));
+ } else if (msgtype === "m.emote") {
+ var _objectSpread4;
+
+ return new _EmoteEvent.EmoteEvent(_objectSpread(_objectSpread({}, wireEvent), {}, {
+ content: _objectSpread(_objectSpread({}, wireEvent.content), {}, (_objectSpread4 = {}, _defineProperty(_objectSpread4, _message_types.M_TEXT.name, text), _defineProperty(_objectSpread4, _message_types.M_HTML.name, html), _objectSpread4))
+ }));
+ } else {
+ // TODO: Handle other types
+ return null;
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MMessage.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MMessage.js
new file mode 100644
index 0000000000..dd74621eb2
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MMessage.js
@@ -0,0 +1,40 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.parseMMessage = parseMMessage;
+
+var _MessageEvent = require("../../events/MessageEvent");
+
+var _message_types = require("../../events/message_types");
+
+var _EmoteEvent = require("../../events/EmoteEvent");
+
+var _NoticeEvent = require("../../events/NoticeEvent");
+
+/*
+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.
+*/
+function parseMMessage(wireEvent) {
+ if (_message_types.M_EMOTE.matches(wireEvent.type)) {
+ return new _EmoteEvent.EmoteEvent(wireEvent);
+ } else if (_message_types.M_NOTICE.matches(wireEvent.type)) {
+ return new _NoticeEvent.NoticeEvent(wireEvent);
+ } // default: return a generic message
+
+
+ return new _MessageEvent.MessageEvent(wireEvent);
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MPoll.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MPoll.js
new file mode 100644
index 0000000000..af35a0fa94
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MPoll.js
@@ -0,0 +1,41 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.parseMPoll = parseMPoll;
+
+var _poll_types = require("../../events/poll_types");
+
+var _PollStartEvent = require("../../events/PollStartEvent");
+
+var _PollResponseEvent = require("../../events/PollResponseEvent");
+
+var _PollEndEvent = require("../../events/PollEndEvent");
+
+/*
+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.
+*/
+function parseMPoll(wireEvent) {
+ if (_poll_types.M_POLL_START.matches(wireEvent.type)) {
+ return new _PollStartEvent.PollStartEvent(wireEvent);
+ } else if (_poll_types.M_POLL_RESPONSE.matches(wireEvent.type)) {
+ return new _PollResponseEvent.PollResponseEvent(wireEvent);
+ } else if (_poll_types.M_POLL_END.matches(wireEvent.type)) {
+ return new _PollEndEvent.PollEndEvent(wireEvent);
+ }
+
+ return null; // not a poll event
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/types.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/types.js
new file mode 100644
index 0000000000..1332fef9da
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/types.js
@@ -0,0 +1,49 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isOptionalAString = isOptionalAString;
+exports.isProvided = isProvided;
+
+/*
+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.
+*/
+
+/**
+ * Represents an optional type: can either be T or a falsy value.
+ */
+
+/**
+ * Determines if the given optional string is a defined string.
+ * @param {Optional<string>} s The input string.
+ * @returns {boolean} True if the input is a defined string.
+ */
+function isOptionalAString(s) {
+ return isProvided(s) && typeof s === 'string';
+}
+/**
+ * Determines if the given optional was provided a value.
+ * @param {Optional<T>} s The optional to test.
+ * @returns {boolean} True if the value is defined.
+ */
+
+
+function isProvided(s) {
+ return s !== null && s !== undefined;
+}
+/**
+ * Represents either just T1, just T2, or T1 and T2 mixed.
+ */ \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/MessageMatchers.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/MessageMatchers.js
new file mode 100644
index 0000000000..beb43e5c3c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/MessageMatchers.js
@@ -0,0 +1,59 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.LegacyMsgType = void 0;
+exports.isEventLike = isEventLike;
+
+var _message_types = require("../events/message_types");
+
+/*
+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.
+*/
+
+/**
+ * Represents a legacy m.room.message msgtype
+ */
+var LegacyMsgType;
+/**
+ * Determines if the given partial event looks similar enough to the given legacy msgtype
+ * to count as that message type.
+ * @param {IPartialEvent<EitherAnd<IPartialLegacyContent, M_MESSAGE_EVENT_CONTENT>>} event The event.
+ * @param {LegacyMsgType} msgtype The message type to compare for.
+ * @returns {boolean} True if the event appears to look similar enough to the msgtype.
+ */
+
+exports.LegacyMsgType = LegacyMsgType;
+
+(function (LegacyMsgType) {
+ LegacyMsgType["Text"] = "m.text";
+ LegacyMsgType["Notice"] = "m.notice";
+ LegacyMsgType["Emote"] = "m.emote";
+})(LegacyMsgType || (exports.LegacyMsgType = LegacyMsgType = {}));
+
+function isEventLike(event, msgtype) {
+ var content = event.content;
+
+ if (msgtype === LegacyMsgType.Text) {
+ return _message_types.M_MESSAGE.matches(event.type) || event.type === "m.room.message" && (content === null || content === void 0 ? void 0 : content['msgtype']) === "m.text";
+ } else if (msgtype === LegacyMsgType.Emote) {
+ return _message_types.M_EMOTE.matches(event.type) || event.type === "m.room.message" && (content === null || content === void 0 ? void 0 : content['msgtype']) === "m.emote";
+ } else if (msgtype === LegacyMsgType.Notice) {
+ return _message_types.M_NOTICE.matches(event.type) || event.type === "m.room.message" && (content === null || content === void 0 ? void 0 : content['msgtype']) === "m.notice";
+ }
+
+ return false;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/events.js b/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/events.js
new file mode 100644
index 0000000000..ace7464863
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/events.js
@@ -0,0 +1,51 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isEventTypeSame = isEventTypeSame;
+
+/*
+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.
+*/
+
+/**
+ * Represents a potentially namespaced event type.
+ */
+
+/**
+ * Determines if two event types are the same, including namespaces.
+ * @param {EventType} given The given event type. This will be compared
+ * against the expected type.
+ * @param {EventType} expected The expected event type.
+ * @returns {boolean} True if the given type matches the expected type.
+ */
+function isEventTypeSame(given, expected) {
+ if (typeof given === "string") {
+ if (typeof expected === "string") {
+ return expected === given;
+ } else {
+ return expected.matches(given);
+ }
+ } else {
+ if (typeof expected === "string") {
+ return given.matches(expected);
+ } else {
+ var expectedNs = expected;
+ var givenNs = given;
+ return expectedNs.matches(givenNs.name) || expectedNs.matches(givenNs.altName);
+ }
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js
new file mode 100644
index 0000000000..a3f9efa1ef
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js
@@ -0,0 +1,101 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TweakName = exports.RuleId = exports.PushRuleKind = exports.PushRuleActionName = exports.DMMemberCountCondition = exports.ConditionOperator = exports.ConditionKind = void 0;
+exports.isDmMemberCountCondition = isDmMemberCountCondition;
+/*
+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 */
+let PushRuleActionName = /*#__PURE__*/function (PushRuleActionName) {
+ PushRuleActionName["DontNotify"] = "dont_notify";
+ PushRuleActionName["Notify"] = "notify";
+ PushRuleActionName["Coalesce"] = "coalesce";
+ return PushRuleActionName;
+}({});
+exports.PushRuleActionName = PushRuleActionName;
+let TweakName = /*#__PURE__*/function (TweakName) {
+ TweakName["Highlight"] = "highlight";
+ TweakName["Sound"] = "sound";
+ return TweakName;
+}({});
+exports.TweakName = TweakName;
+let ConditionOperator = /*#__PURE__*/function (ConditionOperator) {
+ ConditionOperator["ExactEquals"] = "==";
+ ConditionOperator["LessThan"] = "<";
+ ConditionOperator["GreaterThan"] = ">";
+ ConditionOperator["GreaterThanOrEqual"] = ">=";
+ ConditionOperator["LessThanOrEqual"] = "<=";
+ return ConditionOperator;
+}({});
+exports.ConditionOperator = ConditionOperator;
+const DMMemberCountCondition = "2";
+exports.DMMemberCountCondition = DMMemberCountCondition;
+function isDmMemberCountCondition(condition) {
+ return condition === "==2" || condition === "2";
+}
+let ConditionKind = /*#__PURE__*/function (ConditionKind) {
+ ConditionKind["EventMatch"] = "event_match";
+ ConditionKind["EventPropertyIs"] = "event_property_is";
+ ConditionKind["EventPropertyContains"] = "event_property_contains";
+ ConditionKind["ContainsDisplayName"] = "contains_display_name";
+ ConditionKind["RoomMemberCount"] = "room_member_count";
+ ConditionKind["SenderNotificationPermission"] = "sender_notification_permission";
+ ConditionKind["CallStarted"] = "call_started";
+ ConditionKind["CallStartedPrefix"] = "org.matrix.msc3914.call_started";
+ return ConditionKind;
+}({}); // 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.
+exports.ConditionKind = ConditionKind;
+let PushRuleKind = /*#__PURE__*/function (PushRuleKind) {
+ PushRuleKind["Override"] = "override";
+ PushRuleKind["ContentSpecific"] = "content";
+ PushRuleKind["RoomSpecific"] = "room";
+ PushRuleKind["SenderSpecific"] = "sender";
+ PushRuleKind["Underride"] = "underride";
+ return PushRuleKind;
+}({});
+exports.PushRuleKind = PushRuleKind;
+let RuleId = /*#__PURE__*/function (RuleId) {
+ RuleId["Master"] = ".m.rule.master";
+ RuleId["IsUserMention"] = ".org.matrix.msc3952.is_user_mention";
+ RuleId["IsRoomMention"] = ".org.matrix.msc3952.is_room_mention";
+ RuleId["ContainsDisplayName"] = ".m.rule.contains_display_name";
+ RuleId["ContainsUserName"] = ".m.rule.contains_user_name";
+ RuleId["AtRoomNotification"] = ".m.rule.roomnotif";
+ RuleId["DM"] = ".m.rule.room_one_to_one";
+ RuleId["EncryptedDM"] = ".m.rule.encrypted_room_one_to_one";
+ RuleId["Message"] = ".m.rule.message";
+ RuleId["EncryptedMessage"] = ".m.rule.encrypted";
+ RuleId["InviteToSelf"] = ".m.rule.invite_for_me";
+ RuleId["MemberEvent"] = ".m.rule.member_event";
+ RuleId["IncomingCall"] = ".m.rule.call";
+ RuleId["SuppressNotices"] = ".m.rule.suppress_notices";
+ RuleId["Tombstone"] = ".m.rule.tombstone";
+ RuleId["PollStart"] = ".m.rule.poll_start";
+ RuleId["PollStartUnstable"] = ".org.matrix.msc3930.rule.poll_start";
+ RuleId["PollEnd"] = ".m.rule.poll_end";
+ RuleId["PollEndUnstable"] = ".org.matrix.msc3930.rule.poll_end";
+ RuleId["PollStartOneToOne"] = ".m.rule.poll_start_one_to_one";
+ RuleId["PollStartOneToOneUnstable"] = ".org.matrix.msc3930.rule.poll_start_one_to_one";
+ RuleId["PollEndOneToOne"] = ".m.rule.poll_end_one_to_one";
+ RuleId["PollEndOneToOneUnstable"] = ".org.matrix.msc3930.rule.poll_end_one_to_one";
+ return RuleId;
+}({});
+/* eslint-enable camelcase */
+exports.RuleId = RuleId; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js
new file mode 100644
index 0000000000..9a390c31f7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js
@@ -0,0 +1 @@
+"use strict"; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js
new file mode 100644
index 0000000000..360f679311
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js
@@ -0,0 +1,68 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SSOAction = exports.IdentityProviderBrand = exports.DELEGATED_OIDC_COMPATIBILITY = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+/*
+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.
+*/
+
+// disable lint because these are wire responses
+/* eslint-disable camelcase */
+
+/**
+ * Represents a response to the CSAPI `/refresh` endpoint.
+ */
+
+/* eslint-enable camelcase */
+
+/**
+ * Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login
+ */
+
+const DELEGATED_OIDC_COMPATIBILITY = new _NamespacedValue.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
+ */
+exports.DELEGATED_OIDC_COMPATIBILITY = DELEGATED_OIDC_COMPATIBILITY;
+let IdentityProviderBrand = /*#__PURE__*/function (IdentityProviderBrand) {
+ IdentityProviderBrand["Gitlab"] = "gitlab";
+ IdentityProviderBrand["Github"] = "github";
+ IdentityProviderBrand["Apple"] = "apple";
+ IdentityProviderBrand["Google"] = "google";
+ IdentityProviderBrand["Facebook"] = "facebook";
+ IdentityProviderBrand["Twitter"] = "twitter";
+ return IdentityProviderBrand;
+}({});
+/**
+ * Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
+ */
+/* eslint-disable camelcase */
+exports.IdentityProviderBrand = IdentityProviderBrand;
+/* eslint-enable camelcase */
+let SSOAction = /*#__PURE__*/function (SSOAction) {
+ SSOAction["LOGIN"] = "login";
+ SSOAction["REGISTER"] = "register";
+ return SSOAction;
+}({});
+/**
+ * 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.
+ */
+exports.SSOAction = SSOAction; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js
new file mode 100644
index 0000000000..b844583bcc
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js
@@ -0,0 +1,126 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_BEACON_INFO = exports.M_BEACON = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+/*
+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.
+*/
+
+/**
+ * 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
+ */
+const M_BEACON_INFO = new _NamespacedValue.UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
+exports.M_BEACON_INFO = M_BEACON_INFO;
+const M_BEACON = new _NamespacedValue.UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
+
+/**
+ * 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
+ */
+
+/**
+ * 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
+ */
+exports.M_BEACON = M_BEACON; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js
new file mode 100644
index 0000000000..1dbbbcaf5e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js
@@ -0,0 +1,240 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UNSTABLE_MSC3089_TREE_SUBTYPE = exports.UNSTABLE_MSC3089_LEAF = exports.UNSTABLE_MSC3089_BRANCH = exports.UNSTABLE_MSC3088_PURPOSE = exports.UNSTABLE_MSC3088_ENABLED = exports.UNSTABLE_MSC2716_MARKER = exports.UNSTABLE_ELEMENT_FUNCTIONAL_USERS = exports.UNSIGNED_THREAD_ID_FIELD = exports.ToDeviceMessageId = exports.RoomType = exports.RoomCreateTypeField = exports.RelationType = exports.PUSHER_ENABLED = exports.PUSHER_DEVICE_ID = exports.MsgType = exports.MSC3912_RELATION_BASED_REDACTIONS_PROP = exports.LOCAL_NOTIFICATION_SETTINGS_PREFIX = exports.EventType = exports.EVENT_VISIBILITY_CHANGE_TYPE = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+/*
+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.
+*/
+let EventType = /*#__PURE__*/function (EventType) {
+ EventType["RoomCanonicalAlias"] = "m.room.canonical_alias";
+ EventType["RoomCreate"] = "m.room.create";
+ EventType["RoomJoinRules"] = "m.room.join_rules";
+ EventType["RoomMember"] = "m.room.member";
+ EventType["RoomThirdPartyInvite"] = "m.room.third_party_invite";
+ EventType["RoomPowerLevels"] = "m.room.power_levels";
+ EventType["RoomName"] = "m.room.name";
+ EventType["RoomTopic"] = "m.room.topic";
+ EventType["RoomAvatar"] = "m.room.avatar";
+ EventType["RoomPinnedEvents"] = "m.room.pinned_events";
+ EventType["RoomEncryption"] = "m.room.encryption";
+ EventType["RoomHistoryVisibility"] = "m.room.history_visibility";
+ EventType["RoomGuestAccess"] = "m.room.guest_access";
+ EventType["RoomServerAcl"] = "m.room.server_acl";
+ EventType["RoomTombstone"] = "m.room.tombstone";
+ EventType["RoomPredecessor"] = "org.matrix.msc3946.room_predecessor";
+ EventType["SpaceChild"] = "m.space.child";
+ EventType["SpaceParent"] = "m.space.parent";
+ EventType["RoomRedaction"] = "m.room.redaction";
+ EventType["RoomMessage"] = "m.room.message";
+ EventType["RoomMessageEncrypted"] = "m.room.encrypted";
+ EventType["Sticker"] = "m.sticker";
+ EventType["CallInvite"] = "m.call.invite";
+ EventType["CallCandidates"] = "m.call.candidates";
+ EventType["CallAnswer"] = "m.call.answer";
+ EventType["CallHangup"] = "m.call.hangup";
+ EventType["CallReject"] = "m.call.reject";
+ EventType["CallSelectAnswer"] = "m.call.select_answer";
+ EventType["CallNegotiate"] = "m.call.negotiate";
+ EventType["CallSDPStreamMetadataChanged"] = "m.call.sdp_stream_metadata_changed";
+ EventType["CallSDPStreamMetadataChangedPrefix"] = "org.matrix.call.sdp_stream_metadata_changed";
+ EventType["CallReplaces"] = "m.call.replaces";
+ EventType["CallAssertedIdentity"] = "m.call.asserted_identity";
+ EventType["CallAssertedIdentityPrefix"] = "org.matrix.call.asserted_identity";
+ EventType["KeyVerificationRequest"] = "m.key.verification.request";
+ EventType["KeyVerificationStart"] = "m.key.verification.start";
+ EventType["KeyVerificationCancel"] = "m.key.verification.cancel";
+ EventType["KeyVerificationMac"] = "m.key.verification.mac";
+ EventType["KeyVerificationDone"] = "m.key.verification.done";
+ EventType["KeyVerificationKey"] = "m.key.verification.key";
+ EventType["KeyVerificationAccept"] = "m.key.verification.accept";
+ EventType["KeyVerificationReady"] = "m.key.verification.ready";
+ EventType["RoomMessageFeedback"] = "m.room.message.feedback";
+ EventType["Reaction"] = "m.reaction";
+ EventType["PollStart"] = "org.matrix.msc3381.poll.start";
+ EventType["Typing"] = "m.typing";
+ EventType["Receipt"] = "m.receipt";
+ EventType["Presence"] = "m.presence";
+ EventType["FullyRead"] = "m.fully_read";
+ EventType["Tag"] = "m.tag";
+ EventType["SpaceOrder"] = "org.matrix.msc3230.space_order";
+ EventType["PushRules"] = "m.push_rules";
+ EventType["Direct"] = "m.direct";
+ EventType["IgnoredUserList"] = "m.ignored_user_list";
+ EventType["RoomKey"] = "m.room_key";
+ EventType["RoomKeyRequest"] = "m.room_key_request";
+ EventType["ForwardedRoomKey"] = "m.forwarded_room_key";
+ EventType["Dummy"] = "m.dummy";
+ EventType["GroupCallPrefix"] = "org.matrix.msc3401.call";
+ EventType["GroupCallMemberPrefix"] = "org.matrix.msc3401.call.member";
+ return EventType;
+}({});
+exports.EventType = EventType;
+let RelationType = /*#__PURE__*/function (RelationType) {
+ RelationType["Annotation"] = "m.annotation";
+ RelationType["Replace"] = "m.replace";
+ RelationType["Reference"] = "m.reference";
+ RelationType["Thread"] = "m.thread";
+ return RelationType;
+}({});
+exports.RelationType = RelationType;
+let MsgType = /*#__PURE__*/function (MsgType) {
+ MsgType["Text"] = "m.text";
+ MsgType["Emote"] = "m.emote";
+ MsgType["Notice"] = "m.notice";
+ MsgType["Image"] = "m.image";
+ MsgType["File"] = "m.file";
+ MsgType["Audio"] = "m.audio";
+ MsgType["Location"] = "m.location";
+ MsgType["Video"] = "m.video";
+ MsgType["KeyVerificationRequest"] = "m.key.verification.request";
+ return MsgType;
+}({});
+exports.MsgType = MsgType;
+const RoomCreateTypeField = "type";
+exports.RoomCreateTypeField = RoomCreateTypeField;
+let RoomType = /*#__PURE__*/function (RoomType) {
+ RoomType["Space"] = "m.space";
+ RoomType["UnstableCall"] = "org.matrix.msc3417.call";
+ RoomType["ElementVideo"] = "io.element.video";
+ return RoomType;
+}({});
+exports.RoomType = RoomType;
+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.
+ */
+exports.ToDeviceMessageId = ToDeviceMessageId;
+const UNSTABLE_MSC3088_PURPOSE = new _NamespacedValue.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.
+ */
+exports.UNSTABLE_MSC3088_PURPOSE = UNSTABLE_MSC3088_PURPOSE;
+const UNSTABLE_MSC3088_ENABLED = new _NamespacedValue.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.
+ */
+exports.UNSTABLE_MSC3088_ENABLED = UNSTABLE_MSC3088_ENABLED;
+const UNSTABLE_MSC3089_TREE_SUBTYPE = new _NamespacedValue.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.
+ */
+exports.UNSTABLE_MSC3089_TREE_SUBTYPE = UNSTABLE_MSC3089_TREE_SUBTYPE;
+const UNSTABLE_MSC3089_LEAF = new _NamespacedValue.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.
+ */
+exports.UNSTABLE_MSC3089_LEAF = UNSTABLE_MSC3089_LEAF;
+const UNSTABLE_MSC3089_BRANCH = new _NamespacedValue.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.
+ */
+exports.UNSTABLE_MSC3089_BRANCH = UNSTABLE_MSC3089_BRANCH;
+const UNSTABLE_MSC2716_MARKER = new _NamespacedValue.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}
+ */
+exports.UNSTABLE_MSC2716_MARKER = UNSTABLE_MSC2716_MARKER;
+const MSC3912_RELATION_BASED_REDACTIONS_PROP = new _NamespacedValue.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"
+ * ]
+ * }
+ * ```
+ */
+exports.MSC3912_RELATION_BASED_REDACTIONS_PROP = MSC3912_RELATION_BASED_REDACTIONS_PROP;
+const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new _NamespacedValue.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
+ */
+exports.UNSTABLE_ELEMENT_FUNCTIONAL_USERS = UNSTABLE_ELEMENT_FUNCTIONAL_USERS;
+const EVENT_VISIBILITY_CHANGE_TYPE = new _NamespacedValue.UnstableValue("m.visibility", "org.matrix.msc3531.visibility");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3881
+ *
+ * @experimental
+ */
+exports.EVENT_VISIBILITY_CHANGE_TYPE = EVENT_VISIBILITY_CHANGE_TYPE;
+const PUSHER_ENABLED = new _NamespacedValue.UnstableValue("enabled", "org.matrix.msc3881.enabled");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3881
+ *
+ * @experimental
+ */
+exports.PUSHER_ENABLED = PUSHER_ENABLED;
+const PUSHER_DEVICE_ID = new _NamespacedValue.UnstableValue("device_id", "org.matrix.msc3881.device_id");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3890
+ *
+ * @experimental
+ */
+exports.PUSHER_DEVICE_ID = PUSHER_DEVICE_ID;
+const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new _NamespacedValue.UnstableValue("m.local_notification_settings", "org.matrix.msc3890.local_notification_settings");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/4023
+ *
+ * @experimental
+ */
+exports.LOCAL_NOTIFICATION_SETTINGS_PREFIX = LOCAL_NOTIFICATION_SETTINGS_PREFIX;
+const UNSIGNED_THREAD_ID_FIELD = new _NamespacedValue.UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
+exports.UNSIGNED_THREAD_ID_FIELD = UNSIGNED_THREAD_ID_FIELD; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js
new file mode 100644
index 0000000000..847909dd99
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js
@@ -0,0 +1,121 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.REFERENCE_RELATION = exports.M_TEXT = exports.M_MESSAGE = exports.M_HTML = void 0;
+exports.isEventTypeSame = isEventTypeSame;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _utilities = require("../extensible_events_v1/utilities");
+/*
+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.
+*/
+
+// Types and utilities for MSC1767: Extensible events (version 1) in Matrix
+
+/**
+ * Represents the stable and unstable values of a given namespace.
+ */
+
+/**
+ * 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).
+ */
+
+/**
+ * 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.
+
+/**
+ * The namespaced value for m.message
+ */
+const M_MESSAGE = new _matrixEventsSdk.UnstableValue("m.message", "org.matrix.msc1767.message");
+
+/**
+ * An m.message event rendering
+ */
+
+/**
+ * The content for an m.message event
+ */
+exports.M_MESSAGE = M_MESSAGE;
+/**
+ * The namespaced value for m.text
+ */
+const M_TEXT = new _matrixEventsSdk.UnstableValue("m.text", "org.matrix.msc1767.text");
+
+/**
+ * The content for an m.text event
+ */
+exports.M_TEXT = M_TEXT;
+/**
+ * The namespaced value for m.html
+ */
+const M_HTML = new _matrixEventsSdk.UnstableValue("m.html", "org.matrix.msc1767.html");
+
+/**
+ * The content for an m.html event
+ */
+
+/**
+ * The content for an m.message, m.text, or m.html event
+ */
+exports.M_HTML = M_HTML;
+/**
+ * The namespaced value for an m.reference relation
+ */
+const REFERENCE_RELATION = new _matrixEventsSdk.NamespacedValue("m.reference");
+
+/**
+ * Represents any relation type
+ */
+
+/**
+ * An m.relates_to relationship
+ */
+
+/**
+ * Partial types for a Matrix Event.
+ */
+
+/**
+ * Represents a potentially namespaced event type.
+ */
+exports.REFERENCE_RELATION = REFERENCE_RELATION;
+/**
+ * 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.
+ */
+function isEventTypeSame(given, expected) {
+ if (typeof given === "string") {
+ if (typeof expected === "string") {
+ return expected === given;
+ } else {
+ return expected.matches(given);
+ }
+ } else {
+ if (typeof expected === "string") {
+ return given.matches(expected);
+ } else {
+ const expectedNs = expected;
+ const givenNs = given;
+ return expectedNs.matches(givenNs.name) || (0, _utilities.isProvided)(givenNs.altName) && expectedNs.matches(givenNs.altName);
+ }
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js
new file mode 100644
index 0000000000..9329ae092a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+require("@matrix-org/olm"); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js
new file mode 100644
index 0000000000..0acaf952bf
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js
@@ -0,0 +1,72 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_TIMESTAMP = exports.M_LOCATION = exports.M_ASSET = exports.LocationAssetType = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+var _extensible_events = require("./extensible_events");
+/*
+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
+let LocationAssetType = /*#__PURE__*/function (LocationAssetType) {
+ LocationAssetType["Self"] = "m.self";
+ LocationAssetType["Pin"] = "m.pin";
+ return LocationAssetType;
+}({});
+exports.LocationAssetType = LocationAssetType;
+const M_ASSET = new _NamespacedValue.UnstableValue("m.asset", "org.matrix.msc3488.asset");
+
+/**
+ * The event definition for an m.asset event (in content)
+ */
+exports.M_ASSET = M_ASSET;
+const M_TIMESTAMP = new _NamespacedValue.UnstableValue("m.ts", "org.matrix.msc3488.ts");
+/**
+ * The event definition for an m.ts event (in content)
+ */
+exports.M_TIMESTAMP = M_TIMESTAMP;
+const M_LOCATION = new _NamespacedValue.UnstableValue("m.location", "org.matrix.msc3488.location");
+
+/* 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,
+ }
+}
+*/
+
+/**
+ * The content for an m.location event
+ */
+
+/**
+ * Possible content for location events as sent over the wire
+ */
+exports.M_LOCATION = M_LOCATION; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js
new file mode 100644
index 0000000000..8d12c51c3d
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js
@@ -0,0 +1,63 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.Visibility = exports.RestrictedAllowType = exports.Preset = exports.JoinRule = exports.HistoryVisibility = exports.GuestAccess = void 0;
+/*
+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.
+*/
+let Visibility = /*#__PURE__*/function (Visibility) {
+ Visibility["Public"] = "public";
+ Visibility["Private"] = "private";
+ return Visibility;
+}({});
+exports.Visibility = Visibility;
+let Preset = /*#__PURE__*/function (Preset) {
+ Preset["PrivateChat"] = "private_chat";
+ Preset["TrustedPrivateChat"] = "trusted_private_chat";
+ Preset["PublicChat"] = "public_chat";
+ return Preset;
+}({});
+exports.Preset = Preset;
+// Knock and private are reserved keywords which are not yet implemented.
+let JoinRule = /*#__PURE__*/function (JoinRule) {
+ JoinRule["Public"] = "public";
+ JoinRule["Invite"] = "invite";
+ JoinRule["Private"] = "private";
+ JoinRule["Knock"] = "knock";
+ JoinRule["Restricted"] = "restricted";
+ return JoinRule;
+}({});
+exports.JoinRule = JoinRule;
+let RestrictedAllowType = /*#__PURE__*/function (RestrictedAllowType) {
+ RestrictedAllowType["RoomMembership"] = "m.room_membership";
+ return RestrictedAllowType;
+}({});
+exports.RestrictedAllowType = RestrictedAllowType;
+let GuestAccess = /*#__PURE__*/function (GuestAccess) {
+ GuestAccess["CanJoin"] = "can_join";
+ GuestAccess["Forbidden"] = "forbidden";
+ return GuestAccess;
+}({});
+exports.GuestAccess = GuestAccess;
+let HistoryVisibility = /*#__PURE__*/function (HistoryVisibility) {
+ HistoryVisibility["Invited"] = "invited";
+ HistoryVisibility["Joined"] = "joined";
+ HistoryVisibility["Shared"] = "shared";
+ HistoryVisibility["WorldReadable"] = "world_readable";
+ return HistoryVisibility;
+}({});
+exports.HistoryVisibility = HistoryVisibility; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js
new file mode 100644
index 0000000000..6366b7ebfa
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js
@@ -0,0 +1,93 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_POLL_START = exports.M_POLL_RESPONSE = exports.M_POLL_KIND_UNDISCLOSED = exports.M_POLL_KIND_DISCLOSED = exports.M_POLL_END = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+/*
+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.
+*/
+
+/**
+ * Identifier for a disclosed poll.
+ */
+const M_POLL_KIND_DISCLOSED = new _matrixEventsSdk.UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed");
+
+/**
+ * Identifier for an undisclosed poll.
+ */
+exports.M_POLL_KIND_DISCLOSED = M_POLL_KIND_DISCLOSED;
+const M_POLL_KIND_UNDISCLOSED = new _matrixEventsSdk.UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed");
+
+/**
+ * Any poll kind.
+ */
+
+/**
+ * Known poll kind namespaces.
+ */
+exports.M_POLL_KIND_UNDISCLOSED = M_POLL_KIND_UNDISCLOSED;
+/**
+ * The namespaced value for m.poll.start
+ */
+const M_POLL_START = new _matrixEventsSdk.UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start");
+
+/**
+ * The m.poll.start type within event content
+ */
+
+/**
+ * A poll answer.
+ */
+
+/**
+ * The event definition for an m.poll.start event (in content)
+ */
+
+/**
+ * The content for an m.poll.start event
+ */
+exports.M_POLL_START = M_POLL_START;
+/**
+ * The namespaced value for m.poll.response
+ */
+const M_POLL_RESPONSE = new _matrixEventsSdk.UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response");
+
+/**
+ * The m.poll.response type within event content
+ */
+
+/**
+ * The event definition for an m.poll.response event (in content)
+ */
+
+/**
+ * The content for an m.poll.response event
+ */
+exports.M_POLL_RESPONSE = M_POLL_RESPONSE;
+/**
+ * The namespaced value for m.poll.end
+ */
+const M_POLL_END = new _matrixEventsSdk.UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
+
+/**
+ * The event definition for an m.poll.end event (in content)
+ */
+
+/**
+ * The content for an m.poll.end event
+ */
+exports.M_POLL_END = M_POLL_END; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js
new file mode 100644
index 0000000000..70ad6132f5
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js
@@ -0,0 +1,33 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ReceiptType = exports.MAIN_ROOM_TIMELINE = void 0;
+/*
+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.
+*/
+let ReceiptType = /*#__PURE__*/function (ReceiptType) {
+ ReceiptType["Read"] = "m.read";
+ ReceiptType["FullyRead"] = "m.fully_read";
+ ReceiptType["ReadPrivate"] = "m.read.private";
+ return ReceiptType;
+}({});
+exports.ReceiptType = ReceiptType;
+const MAIN_ROOM_TIMELINE = "main";
+
+// 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
+exports.MAIN_ROOM_TIMELINE = MAIN_ROOM_TIMELINE; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js
new file mode 100644
index 0000000000..52a63fd30b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js
@@ -0,0 +1,35 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SearchOrderBy = void 0;
+/*
+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
+/* eslint-disable camelcase */
+var GroupKey = /*#__PURE__*/function (GroupKey) {
+ GroupKey["RoomId"] = "room_id";
+ GroupKey["Sender"] = "sender";
+ return GroupKey;
+}(GroupKey || {});
+let SearchOrderBy = /*#__PURE__*/function (SearchOrderBy) {
+ SearchOrderBy["Recent"] = "recent";
+ SearchOrderBy["Rank"] = "rank";
+ return SearchOrderBy;
+}({});
+/* eslint-enable camelcase */
+exports.SearchOrderBy = SearchOrderBy; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js
new file mode 100644
index 0000000000..4e1da31379
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js
@@ -0,0 +1,30 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UNREAD_THREAD_NOTIFICATIONS = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+/*
+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.
+*/
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3773
+ *
+ * @experimental
+ */
+const UNREAD_THREAD_NOTIFICATIONS = new _NamespacedValue.ServerControlledNamespacedValue("unread_thread_notifications", "org.matrix.msc3773.unread_thread_notifications");
+exports.UNREAD_THREAD_NOTIFICATIONS = UNREAD_THREAD_NOTIFICATIONS; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js
new file mode 100644
index 0000000000..5c0f76a731
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js
@@ -0,0 +1,27 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ThreepidMedium = void 0;
+/*
+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.
+*/
+let ThreepidMedium = /*#__PURE__*/function (ThreepidMedium) {
+ ThreepidMedium["Email"] = "email";
+ ThreepidMedium["Phone"] = "msisdn";
+ return ThreepidMedium;
+}({}); // TODO: Are these types universal, or specific to just /account/3pid?
+exports.ThreepidMedium = ThreepidMedium; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js
new file mode 100644
index 0000000000..97db425c21
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js
@@ -0,0 +1,63 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.M_TOPIC = void 0;
+var _NamespacedValue = require("../NamespacedValue");
+/*
+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.
+*/
+
+/**
+ * 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)
+ */
+const M_TOPIC = new _NamespacedValue.UnstableValue("m.topic", "org.matrix.msc3765.topic");
+
+/**
+ * The event content for an m.topic event (in content)
+ */
+
+/**
+ * The event definition for an m.topic event (in content)
+ */
+
+/**
+ * The event content for an m.room.topic event
+ */
+exports.M_TOPIC = M_TOPIC; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE b/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE
new file mode 100644
index 0000000000..f433b1a53f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js b/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js
new file mode 100644
index 0000000000..ec911fe886
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js
@@ -0,0 +1,123 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UnstableValue = exports.ServerControlledNamespacedValue = exports.NamespacedValue = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+/**
+ * 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.
+ */
+class NamespacedValue {
+ // 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).
+
+ constructor(stable, unstable) {
+ this.stable = stable;
+ this.unstable = unstable;
+ if (!this.unstable && !this.stable) {
+ throw new Error("One of stable or unstable values must be supplied");
+ }
+ }
+ get name() {
+ if (this.stable) {
+ return this.stable;
+ }
+ return this.unstable;
+ }
+ get altName() {
+ if (!this.stable) {
+ return null;
+ }
+ return this.unstable;
+ }
+ get names() {
+ const names = [this.name];
+ const altName = this.altName;
+ if (altName) names.push(altName);
+ return names;
+ }
+ matches(val) {
+ 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.
+ findIn(obj) {
+ let val = undefined;
+ if (this.name) {
+ val = obj?.[this.name];
+ }
+ if (!val && this.altName) {
+ val = obj?.[this.altName];
+ }
+ return val;
+ }
+ includedIn(arr) {
+ let included = false;
+ if (this.name) {
+ included = arr.includes(this.name);
+ }
+ if (!included && this.altName) {
+ included = arr.includes(this.altName);
+ }
+ return included;
+ }
+}
+exports.NamespacedValue = NamespacedValue;
+class ServerControlledNamespacedValue extends NamespacedValue {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "preferUnstable", false);
+ }
+ setPreferUnstable(preferUnstable) {
+ this.preferUnstable = preferUnstable;
+ }
+ get name() {
+ if (this.stable && !this.preferUnstable) {
+ return this.stable;
+ }
+ return this.unstable;
+ }
+}
+
+/**
+ * Represents a namespaced value which prioritizes the unstable value over the stable
+ * value.
+ */
+exports.ServerControlledNamespacedValue = ServerControlledNamespacedValue;
+class UnstableValue extends NamespacedValue {
+ // Note: Constructor difference is that `unstable` is *required*.
+ constructor(stable, unstable) {
+ super(stable, unstable);
+ if (!this.unstable) {
+ throw new Error("Unstable value must be supplied");
+ }
+ }
+ get name() {
+ return this.unstable;
+ }
+ get altName() {
+ return this.stable;
+ }
+}
+exports.UnstableValue = UnstableValue; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js
new file mode 100644
index 0000000000..17a2e986f3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js
@@ -0,0 +1,89 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TypedReEmitter = exports.ReEmitter = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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
+
+class ReEmitter {
+ constructor(target) {
+ this.target = target;
+ // Map from emitter to event name to re-emitter
+ _defineProperty(this, "reEmitters", new WeakMap());
+ }
+ reEmit(source, eventNames) {
+ let reEmittersByEvent = this.reEmitters.get(source);
+ if (!reEmittersByEvent) {
+ reEmittersByEvent = new Map();
+ this.reEmitters.set(source, reEmittersByEvent);
+ }
+ for (const eventName of eventNames) {
+ if (reEmittersByEvent.has(eventName)) continue;
+
+ // 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) => {
+ // 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);
+ }
+ }
+ stopReEmitting(source, eventNames) {
+ 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);
+ }
+}
+exports.ReEmitter = ReEmitter;
+class TypedReEmitter extends ReEmitter {
+ constructor(target) {
+ super(target);
+ }
+ reEmit(source, eventNames) {
+ super.reEmit(source, eventNames);
+ }
+ stopReEmitting(source, eventNames) {
+ super.stopReEmitting(source, eventNames);
+ }
+}
+exports.TypedReEmitter = TypedReEmitter; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js b/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js
new file mode 100644
index 0000000000..e3edb80009
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js
@@ -0,0 +1,133 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ToDeviceMessageQueue = void 0;
+var _event = require("./@types/event");
+var _logger = require("./logger");
+var _client = require("./client");
+var _scheduler = require("./scheduler");
+var _sync = require("./sync");
+var _utils = require("./utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const MAX_BATCH_SIZE = 20;
+
+/**
+ * Maintains a queue of outgoing to-device messages, sending them
+ * as soon as the homeserver is reachable.
+ */
+class ToDeviceMessageQueue {
+ constructor(client) {
+ this.client = client;
+ _defineProperty(this, "sending", false);
+ _defineProperty(this, "running", true);
+ _defineProperty(this, "retryTimeout", null);
+ _defineProperty(this, "retryAttempts", 0);
+ _defineProperty(this, "sendQueue", async () => {
+ if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
+ this.retryTimeout = null;
+ if (this.sending || !this.running) return;
+ _logger.logger.debug("Attempting to send queued to-device messages");
+ this.sending = true;
+ let headBatch;
+ 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.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 = _scheduler.MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, 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(e.httpStatus / 100) === 4) {
+ _logger.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
+ await this.client.store.removeToDeviceBatch(headBatch.id);
+ } else {
+ _logger.logger.info("Automatic retry limit reached for to-device messages.");
+ }
+ return;
+ }
+ _logger.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;
+ }
+ });
+ /**
+ * Listen to sync state changes and automatically resend any pending events
+ * once syncing is resumed
+ */
+ _defineProperty(this, "onResumedSync", (state, oldState) => {
+ if (state === _sync.SyncState.Syncing && oldState !== _sync.SyncState.Syncing) {
+ _logger.logger.info(`Resuming queue after resumed sync`);
+ this.sendQueue();
+ }
+ });
+ }
+ start() {
+ this.running = true;
+ this.sendQueue();
+ this.client.on(_client.ClientEvent.Sync, this.onResumedSync);
+ }
+ stop() {
+ this.running = false;
+ if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
+ this.retryTimeout = null;
+ this.client.removeListener(_client.ClientEvent.Sync, this.onResumedSync);
+ }
+ async queueBatch(batch) {
+ const batches = [];
+ 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[_event.ToDeviceMessageId]})`);
+ _logger.logger.info(`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`, msgmap);
+ }
+ await this.client.store.saveToDeviceBatches(batches);
+ this.sendQueue();
+ }
+ /**
+ * Attempts to send a batch of to-device messages.
+ */
+ async sendBatch(batch) {
+ const contentMap = new _utils.MapWithDefault(() => new Map());
+ for (const item of batch.batch) {
+ contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
+ }
+ _logger.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);
+ }
+}
+exports.ToDeviceMessageQueue = ToDeviceMessageQueue; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js b/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js
new file mode 100644
index 0000000000..60d1ed5fd4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js
@@ -0,0 +1,429 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.AutoDiscoveryAction = exports.AutoDiscovery = void 0;
+var _logger = require("./logger");
+var _httpApi = require("./http-api");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// Dev note: Auto discovery is part of the spec.
+// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
+let AutoDiscoveryAction = /*#__PURE__*/function (AutoDiscoveryAction) {
+ AutoDiscoveryAction["SUCCESS"] = "SUCCESS";
+ AutoDiscoveryAction["IGNORE"] = "IGNORE";
+ AutoDiscoveryAction["PROMPT"] = "PROMPT";
+ AutoDiscoveryAction["FAIL_PROMPT"] = "FAIL_PROMPT";
+ AutoDiscoveryAction["FAIL_ERROR"] = "FAIL_ERROR";
+ return AutoDiscoveryAction;
+}({});
+exports.AutoDiscoveryAction = AutoDiscoveryAction;
+var AutoDiscoveryError = /*#__PURE__*/function (AutoDiscoveryError) {
+ AutoDiscoveryError["Invalid"] = "Invalid homeserver discovery response";
+ AutoDiscoveryError["GenericFailure"] = "Failed to get autodiscovery configuration from server";
+ AutoDiscoveryError["InvalidHsBaseUrl"] = "Invalid base_url for m.homeserver";
+ AutoDiscoveryError["InvalidHomeserver"] = "Homeserver URL does not appear to be a valid Matrix homeserver";
+ AutoDiscoveryError["InvalidIsBaseUrl"] = "Invalid base_url for m.identity_server";
+ AutoDiscoveryError["InvalidIdentityServer"] = "Identity server URL does not appear to be a valid identity server";
+ AutoDiscoveryError["InvalidIs"] = "Invalid identity server discovery response";
+ AutoDiscoveryError["MissingWellknown"] = "No .well-known JSON file found";
+ AutoDiscoveryError["InvalidJson"] = "Invalid JSON";
+ return AutoDiscoveryError;
+}(AutoDiscoveryError || {});
+/**
+ * Utilities for automatically discovery resources, such as homeservers
+ * for users to log in to.
+ */
+class AutoDiscovery {
+ /**
+ * 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.
+ */
+ static async fromDiscoveryConfig(wellknown) {
+ // 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 = {
+ "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?.["m.homeserver"]) {
+ _logger.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.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.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?.raw?.["versions"]) {
+ _logger.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 = "";
+ if (wellknown["m.identity_server"]) {
+ // We prepare a failing identity server response to save lines later
+ // in this branch.
+ const failingClientConfig = {
+ "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.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.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 => {
+ 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;
+ // @ts-ignore - ts gets unhappy as we're mixing types here
+ clientConfig[k][prop] = wellknown[k][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.
+ */
+ static async findClientConfig(domain) {
+ 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 = {
+ "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.logger.error("No response or error when parsing .well-known");
+ if (wellknown.reason) _logger.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.
+ */
+ static async getRawClientConfig(domain) {
+ 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
+ */
+ static sanitizeWellKnownUrl(url) {
+ if (!url) return false;
+ try {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch (e) {
+ _logger.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.logger.error(e);
+ return false;
+ }
+ }
+ static fetch(resource, options) {
+ if (this.fetchFn) {
+ return this.fetchFn(resource, options);
+ }
+ return global.fetch(resource, options);
+ }
+ static setFetchFn(fetchFn) {
+ 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
+ */
+ static async fetchWellKnownObject(url) {
+ let response;
+ try {
+ response = await AutoDiscovery.fetch(url, {
+ method: _httpApi.Method.Get,
+ signal: (0, _httpApi.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;
+ let reason = "";
+ if (typeof error === "object") {
+ reason = 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;
+ return {
+ error,
+ raw: {},
+ action: AutoDiscoveryAction.FAIL_PROMPT,
+ reason: error?.name === "SyntaxError" ? AutoDiscovery.ERROR_INVALID_JSON : AutoDiscovery.ERROR_INVALID
+ };
+ }
+ }
+}
+exports.AutoDiscovery = 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.
+_defineProperty(AutoDiscovery, "ERROR_INVALID", AutoDiscoveryError.Invalid);
+_defineProperty(AutoDiscovery, "ERROR_GENERIC_FAILURE", AutoDiscoveryError.GenericFailure);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_HS_BASE_URL", AutoDiscoveryError.InvalidHsBaseUrl);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_HOMESERVER", AutoDiscoveryError.InvalidHomeserver);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_IS_BASE_URL", AutoDiscoveryError.InvalidIsBaseUrl);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_IDENTITY_SERVER", AutoDiscoveryError.InvalidIdentityServer);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_IS", AutoDiscoveryError.InvalidIs);
+_defineProperty(AutoDiscovery, "ERROR_MISSING_WELLKNOWN", AutoDiscoveryError.MissingWellknown);
+_defineProperty(AutoDiscovery, "ERROR_INVALID_JSON", AutoDiscoveryError.InvalidJson);
+_defineProperty(AutoDiscovery, "ALL_ERRORS", Object.keys(AutoDiscoveryError));
+/**
+ * The auto discovery failed. The client is expected to communicate
+ * the error to the user and refuse logging in.
+ */
+_defineProperty(AutoDiscovery, "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.
+ */
+_defineProperty(AutoDiscovery, "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.
+ */
+_defineProperty(AutoDiscovery, "PROMPT", AutoDiscoveryAction.PROMPT);
+/**
+ * The auto discovery was successful.
+ */
+_defineProperty(AutoDiscovery, "SUCCESS", AutoDiscoveryAction.SUCCESS);
+_defineProperty(AutoDiscovery, "fetchFn", void 0); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js
new file mode 100644
index 0000000000..4d6259825c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js
@@ -0,0 +1,58 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _exportNames = {};
+exports.default = void 0;
+var matrixcs = _interopRequireWildcard(require("./matrix"));
+Object.keys(matrixcs).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === matrixcs[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return matrixcs[key];
+ }
+ });
+});
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+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;
+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.
+var _default = matrixcs; // keep export for browserify package deps
+exports.default = _default;
+global.matrixcs = matrixcs; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/client.js b/comm/chat/protocols/matrix/lib/matrix-sdk/client.js
new file mode 100644
index 0000000000..76d6f1dac9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/client.js
@@ -0,0 +1,7660 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UNSTABLE_MSC3882_CAPABILITY = exports.UNSTABLE_MSC3852_LAST_SEEN_UA = exports.RoomVersionStability = exports.PendingEventOrdering = exports.MatrixClient = exports.M_AUTHENTICATION = exports.ClientEvent = exports.CRYPTO_ENABLED = void 0;
+exports.fixNotificationCountOnDecryption = fixNotificationCountOnDecryption;
+var _sync = require("./sync");
+var _event = require("./models/event");
+var _stub = require("./store/stub");
+var _call = require("./webrtc/call");
+var _filter = require("./filter");
+var _callEventHandler = require("./webrtc/callEventHandler");
+var _groupCallEventHandler = require("./webrtc/groupCallEventHandler");
+var utils = _interopRequireWildcard(require("./utils"));
+var _eventTimeline = require("./models/event-timeline");
+var _pushprocessor = require("./pushprocessor");
+var _autodiscovery = require("./autodiscovery");
+var olmlib = _interopRequireWildcard(require("./crypto/olmlib"));
+var _ReEmitter = require("./ReEmitter");
+var _RoomList = require("./crypto/RoomList");
+var _logger = require("./logger");
+var _serviceTypes = require("./service-types");
+var _httpApi = require("./http-api");
+var _crypto = require("./crypto");
+var _recoverykey = require("./crypto/recoverykey");
+var _key_passphrase = require("./crypto/key_passphrase");
+var _user = require("./models/user");
+var _contentRepo = require("./content-repo");
+var _searchResult = require("./models/search-result");
+var _dehydration = require("./crypto/dehydration");
+var _api = require("./crypto/api");
+var ContentHelpers = _interopRequireWildcard(require("./content-helpers"));
+var _room = require("./models/room");
+var _roomMember = require("./models/room-member");
+var _event2 = require("./@types/event");
+var _partials = require("./@types/partials");
+var _eventMapper = require("./event-mapper");
+var _randomstring = require("./randomstring");
+var _backup = require("./crypto/backup");
+var _MSC3089TreeSpace = require("./models/MSC3089TreeSpace");
+var _search = require("./@types/search");
+var _PushRules = require("./@types/PushRules");
+var _groupCall = require("./webrtc/groupCall");
+var _mediaHandler = require("./webrtc/mediaHandler");
+var _typedEventEmitter = require("./models/typed-event-emitter");
+var _read_receipts = require("./@types/read_receipts");
+var _slidingSyncSdk = require("./sliding-sync-sdk");
+var _thread = require("./models/thread");
+var _beacon = require("./@types/beacon");
+var _NamespacedValue = require("./NamespacedValue");
+var _ToDeviceMessageQueue = require("./ToDeviceMessageQueue");
+var _invitesIgnorer = require("./models/invites-ignorer");
+var _feature = require("./feature");
+var _constants = require("./rust-crypto/constants");
+var _secretStorage = require("./secret-storage");
+const _excluded = ["server", "limit", "since"];
+function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }
+function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const SCROLLBACK_DELAY_MS = 3000;
+const CRYPTO_ENABLED = (0, _crypto.isCryptoAvailable)();
+exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
+const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
+const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
+
+const UNSTABLE_MSC3852_LAST_SEEN_UA = new _NamespacedValue.UnstableValue("last_seen_user_agent", "org.matrix.msc3852.last_seen_user_agent");
+exports.UNSTABLE_MSC3852_LAST_SEEN_UA = UNSTABLE_MSC3852_LAST_SEEN_UA;
+let PendingEventOrdering = /*#__PURE__*/function (PendingEventOrdering) {
+ PendingEventOrdering["Chronological"] = "chronological";
+ PendingEventOrdering["Detached"] = "detached";
+ return PendingEventOrdering;
+}({});
+exports.PendingEventOrdering = PendingEventOrdering;
+let RoomVersionStability = /*#__PURE__*/function (RoomVersionStability) {
+ RoomVersionStability["Stable"] = "stable";
+ RoomVersionStability["Unstable"] = "unstable";
+ return RoomVersionStability;
+}({});
+exports.RoomVersionStability = RoomVersionStability;
+const UNSTABLE_MSC3882_CAPABILITY = new _NamespacedValue.UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token");
+
+/**
+ * A representation of the capabilities advertised by a homeserver as defined by
+ * [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
+ */
+
+/* eslint-disable camelcase */
+exports.UNSTABLE_MSC3882_CAPABILITY = UNSTABLE_MSC3882_CAPABILITY;
+var CrossSigningKeyType = /*#__PURE__*/function (CrossSigningKeyType) {
+ CrossSigningKeyType["MasterKey"] = "master_key";
+ CrossSigningKeyType["SelfSigningKey"] = "self_signing_key";
+ CrossSigningKeyType["UserSigningKey"] = "user_signing_key";
+ return CrossSigningKeyType;
+}(CrossSigningKeyType || {});
+const M_AUTHENTICATION = new _NamespacedValue.UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
+exports.M_AUTHENTICATION = M_AUTHENTICATION;
+/* 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 = "$";
+let ClientEvent = /*#__PURE__*/function (ClientEvent) {
+ ClientEvent["Sync"] = "sync";
+ ClientEvent["Event"] = "event";
+ ClientEvent["ToDeviceEvent"] = "toDeviceEvent";
+ ClientEvent["AccountData"] = "accountData";
+ ClientEvent["Room"] = "Room";
+ ClientEvent["DeleteRoom"] = "deleteRoom";
+ ClientEvent["SyncUnexpectedError"] = "sync.unexpectedError";
+ ClientEvent["ClientWellKnown"] = "WellKnown.client";
+ ClientEvent["ReceivedVoipEvent"] = "received_voip_event";
+ ClientEvent["UndecryptableToDeviceEvent"] = "toDeviceEvent.undecryptable";
+ ClientEvent["TurnServers"] = "turnServers";
+ ClientEvent["TurnServersError"] = "turnServers.error";
+ return ClientEvent;
+}({});
+exports.ClientEvent = ClientEvent;
+const SSO_ACTION_PARAM = new _NamespacedValue.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.
+ */
+class MatrixClient extends _typedEventEmitter.TypedEventEmitter {
+ constructor(opts) {
+ super();
+ _defineProperty(this, "reEmitter", new _ReEmitter.TypedReEmitter(this));
+ _defineProperty(this, "olmVersion", null);
+ // populated after initCrypto
+ _defineProperty(this, "usingExternalCrypto", false);
+ _defineProperty(this, "store", void 0);
+ _defineProperty(this, "deviceId", void 0);
+ _defineProperty(this, "credentials", void 0);
+ _defineProperty(this, "pickleKey", void 0);
+ _defineProperty(this, "scheduler", void 0);
+ _defineProperty(this, "clientRunning", false);
+ _defineProperty(this, "timelineSupport", false);
+ _defineProperty(this, "urlPreviewCache", {});
+ _defineProperty(this, "identityServer", void 0);
+ _defineProperty(this, "http", void 0);
+ // 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}.
+ */
+ _defineProperty(this, "crypto", void 0);
+ // XXX: Intended private, used in code. Being replaced by cryptoBackend
+ _defineProperty(this, "cryptoBackend", void 0);
+ // one of crypto or rustCrypto
+ _defineProperty(this, "cryptoCallbacks", void 0);
+ // XXX: Intended private, used in code.
+ _defineProperty(this, "callEventHandler", void 0);
+ // XXX: Intended private, used in code.
+ _defineProperty(this, "groupCallEventHandler", void 0);
+ _defineProperty(this, "supportsCallTransfer", false);
+ // XXX: Intended private, used in code.
+ _defineProperty(this, "forceTURN", false);
+ // XXX: Intended private, used in code.
+ _defineProperty(this, "iceCandidatePoolSize", 0);
+ // XXX: Intended private, used in code.
+ _defineProperty(this, "idBaseUrl", void 0);
+ _defineProperty(this, "baseUrl", void 0);
+ _defineProperty(this, "isVoipWithNoMediaAllowed", void 0);
+ // 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.
+ _defineProperty(this, "canSupportVoip", false);
+ _defineProperty(this, "peekSync", null);
+ _defineProperty(this, "isGuestAccount", false);
+ _defineProperty(this, "ongoingScrollbacks", {});
+ _defineProperty(this, "notifTimelineSet", null);
+ _defineProperty(this, "cryptoStore", void 0);
+ _defineProperty(this, "verificationMethods", void 0);
+ _defineProperty(this, "fallbackICEServerAllowed", false);
+ _defineProperty(this, "roomList", void 0);
+ _defineProperty(this, "syncApi", void 0);
+ _defineProperty(this, "roomNameGenerator", void 0);
+ _defineProperty(this, "pushRules", void 0);
+ _defineProperty(this, "syncLeftRoomsPromise", void 0);
+ _defineProperty(this, "syncedLeftRooms", false);
+ _defineProperty(this, "clientOpts", void 0);
+ _defineProperty(this, "clientWellKnownIntervalID", void 0);
+ _defineProperty(this, "canResetTimelineCallback", void 0);
+ _defineProperty(this, "canSupport", new Map());
+ // The pushprocessor caches useful things, so keep one and re-use it
+ _defineProperty(this, "pushProcessor", new _pushprocessor.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
+ _defineProperty(this, "serverVersionsPromise", void 0);
+ _defineProperty(this, "cachedCapabilities", void 0);
+ _defineProperty(this, "clientWellKnown", void 0);
+ _defineProperty(this, "clientWellKnownPromise", void 0);
+ _defineProperty(this, "turnServers", []);
+ _defineProperty(this, "turnServersExpiry", 0);
+ _defineProperty(this, "checkTurnServersIntervalID", void 0);
+ _defineProperty(this, "exportedOlmDeviceToImport", void 0);
+ _defineProperty(this, "txnCtr", 0);
+ _defineProperty(this, "mediaHandler", new _mediaHandler.MediaHandler(this));
+ _defineProperty(this, "sessionId", void 0);
+ _defineProperty(this, "pendingEventEncryption", new Map());
+ _defineProperty(this, "useE2eForGroupCall", true);
+ _defineProperty(this, "toDeviceMessageQueue", void 0);
+ _defineProperty(this, "_secretStorage", void 0);
+ // A manager for determining which invites should be ignored.
+ _defineProperty(this, "ignoredInvites", void 0);
+ _defineProperty(this, "startCallEventHandler", () => {
+ 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.
+ */
+ _defineProperty(this, "fixupRoomNotifications", () => {
+ if (this.isInitialSyncComplete()) {
+ const unreadRooms = (this.getRooms() ?? []).filter(room => {
+ return room.getUnreadNotificationCount(_room.NotificationCountType.Total) > 0;
+ });
+ for (const room of unreadRooms) {
+ const currentUserId = this.getSafeUserId();
+ room.fixupNotifications(currentUserId);
+ }
+ this.off(ClientEvent.Sync, this.fixupRoomNotifications);
+ }
+ });
+ 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 _stub.StubStore();
+ this.deviceId = opts.deviceId || null;
+ this.sessionId = (0, _randomstring.randomString)(10);
+ const userId = opts.userId || null;
+ this.credentials = {
+ userId
+ };
+ this.http = new _httpApi.MatrixHttpApi(this, {
+ fetchFn: opts.fetchFn,
+ baseUrl: opts.baseUrl,
+ idBaseUrl: opts.idBaseUrl,
+ accessToken: opts.accessToken,
+ prefix: _httpApi.ClientPrefix.R0,
+ onlyData: true,
+ extraParams: opts.queryParams,
+ localTimeoutMs: opts.localTimeoutMs,
+ useAuthorizationHeader: opts.useAuthorizationHeader
+ });
+ if (opts.deviceToImport) {
+ if (this.deviceId) {
+ _logger.logger.warn("not importing device because device ID is provided to " + "constructor independently of exported data");
+ } else if (this.credentials.userId) {
+ _logger.logger.warn("not importing device because user ID is provided to " + "constructor independently of exported data");
+ } else if (!opts.deviceToImport.deviceId) {
+ _logger.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 => {
+ const room = this.getRoom(eventToSend.getRoomId());
+ if (eventToSend.status !== _event.EventStatus.SENDING) {
+ this.updatePendingEventStatus(room, eventToSend, _event.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, _event.EventStatus.SENT, res.event_id);
+ }
+ return res;
+ });
+ }
+ if ((0, _call.supportsMatrixCall)()) {
+ this.callEventHandler = new _callEventHandler.CallEventHandler(this);
+ this.groupCallEventHandler = new _groupCallEventHandler.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.RoomList(this.cryptoStore);
+ this.roomNameGenerator = opts.roomNameGenerator;
+ this.toDeviceMessageQueue = new _ToDeviceMessageQueue.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(_event.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(_room.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(_room.NotificationCountType.Highlight, highlightCount);
+ }
+ });
+ this.ignoredInvites = new _invitesIgnorer.IgnoredInvites(this);
+ this._secretStorage = new _secretStorage.ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
+ }
+
+ /**
+ * 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.
+ */
+ async startClient(opts) {
+ 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.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.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.Thread.setServerSideSupport(threads);
+ _thread.Thread.setServerSideListSupport(list);
+ _thread.Thread.setServerSideFwdPaginationSupport(fwdPagination);
+ } catch (e) {
+ _logger.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.SlidingSyncSdk(this.clientOpts.slidingSync, this, this.clientOpts, this.buildSyncApiOptions());
+ } else {
+ this.syncApi = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ }
+ if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
+ _logger.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
+ */
+ buildSyncApiOptions() {
+ return {
+ crypto: this.crypto,
+ cryptoCallbacks: this.cryptoBackend,
+ canResetEntireTimeline: roomId => {
+ if (!this.canResetTimelineCallback) {
+ return false;
+ }
+ return this.canResetTimelineCallback(roomId);
+ }
+ };
+ }
+
+ /**
+ * High level helper method to stop the client from polling and allow a
+ * clean shutdown.
+ */
+ stopClient() {
+ this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started
+
+ if (!this.clientRunning) return; // already stopped
+
+ _logger.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.
+ */
+ async rehydrateDevice() {
+ 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.logger.info("no dehydrated device found");
+ return;
+ }
+ const account = new global.Olm.Account();
+ try {
+ const deviceData = getDeviceResult.device_data;
+ if (deviceData.algorithm !== _dehydration.DEHYDRATION_ALGORITHM) {
+ _logger.logger.warn("Wrong algorithm for dehydrated device");
+ return;
+ }
+ _logger.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.logger.log("unpickled device");
+ const rehydrateResult = await this.http.authedRequest(_httpApi.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.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.logger.info("not using dehydrated device");
+ return;
+ }
+ } catch (e) {
+ account.free();
+ _logger.logger.warn("could not unpickle", e);
+ }
+ }
+
+ /**
+ * Get the current dehydrated device, if any
+ * @returns A promise of an object containing the dehydrated device
+ */
+ async getDehydratedDevice() {
+ try {
+ return await this.http.authedRequest(_httpApi.Method.Get, "/dehydrated_device", undefined, undefined, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2"
+ });
+ } catch (e) {
+ _logger.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.
+ */
+ async setDehydrationKey(key, keyInfo, deviceDisplayName) {
+ if (!this.crypto) {
+ _logger.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
+ */
+ async createDehydratedDevice(key, keyInfo, deviceDisplayName) {
+ if (!this.crypto) {
+ _logger.logger.warn("not dehydrating device if crypto is not enabled");
+ return;
+ }
+ await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName);
+ return this.crypto.dehydrationManager.dehydrateDevice();
+ }
+ async exportDevice() {
+ if (!this.crypto) {
+ _logger.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.
+ */
+ clearStores() {
+ if (this.clientRunning) {
+ throw new Error("Cannot clear stores while client is running");
+ }
+ const promises = [];
+ 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 () => {
+ let indexedDB;
+ try {
+ indexedDB = global.indexedDB;
+ } catch (e) {
+ // No indexeddb support
+ return;
+ }
+ for (const dbname of [`${_constants.RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`, `${_constants.RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`]) {
+ const prom = new Promise((resolve, reject) => {
+ _logger.logger.info(`Removing IndexedDB instance ${dbname}`);
+ const req = indexedDB.deleteDatabase(dbname);
+ req.onsuccess = _ => {
+ _logger.logger.info(`Removed IndexedDB instance ${dbname}`);
+ resolve(0);
+ };
+ req.onerror = e => {
+ // 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.logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
+ resolve(0);
+ };
+ req.onblocked = e => {
+ _logger.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
+ */
+ getUserId() {
+ 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
+ */
+ getSafeUserId() {
+ 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
+ */
+ getDomain() {
+ 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.
+ */
+ getUserIdLocalpart() {
+ 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
+ */
+ getDeviceId() {
+ return this.deviceId;
+ }
+
+ /**
+ * Get the session ID of this client
+ * @returns session ID
+ */
+ getSessionId() {
+ return this.sessionId;
+ }
+
+ /**
+ * Check if the runtime environment supports VoIP calling.
+ * @returns True if VoIP is supported.
+ */
+ supportsVoip() {
+ return this.canSupportVoip;
+ }
+
+ /**
+ * @returns
+ */
+ getMediaHandler() {
+ 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
+ */
+ setForceTURN(force) {
+ 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
+ */
+ setSupportsCallTransfer(support) {
+ 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
+ */
+ getUseE2eForGroupCall() {
+ 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.
+ */
+ createCall(roomId) {
+ return (0, _call.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.
+ */
+ async createGroupCall(roomId, type, isPtt, intent, dataChannelsEnabled, dataChannelOptions) {
+ 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.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.
+ */
+ waitUntilRoomReadyForGroupCalls(roomId) {
+ 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.
+ */
+ getGroupCallForRoom(roomId) {
+ return this.groupCallEventHandler.groupCalls.get(roomId) || null;
+ }
+
+ /**
+ * Get the current sync state.
+ * @returns the sync state, which may be null.
+ * @see MatrixClient#event:"sync"
+ */
+ getSyncState() {
+ 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.
+ */
+ getSyncStateData() {
+ if (!this.syncApi) {
+ return null;
+ }
+ return this.syncApi.getSyncStateData();
+ }
+
+ /**
+ * Whether the initial sync has completed.
+ * @returns True if at least one sync has happened.
+ */
+ isInitialSyncComplete() {
+ const state = this.getSyncState();
+ if (!state) {
+ return false;
+ }
+ return state === _sync.SyncState.Prepared || state === _sync.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).
+ */
+ isGuest() {
+ 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.
+ */
+ setGuest(guest) {
+ // 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
+ */
+ getScheduler() {
+ 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.
+ */
+ retryImmediately() {
+ // 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
+ */
+ getNotifTimelineSet() {
+ return this.notifTimelineSet;
+ }
+
+ /**
+ * Set the global notification EventTimelineSet
+ *
+ */
+ setNotifTimelineSet(set) {
+ 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.
+ */
+ getCapabilities(fresh = false) {
+ const now = new Date().getTime();
+ if (this.cachedCapabilities && !fresh) {
+ if (now < this.cachedCapabilities.expiration) {
+ _logger.logger.log("Returning cached capabilities");
+ return Promise.resolve(this.cachedCapabilities.capabilities);
+ }
+ }
+ return this.http.authedRequest(_httpApi.Method.Get, "/capabilities").catch(e => {
+ // We swallow errors because we need a default object anyhow
+ _logger.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.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.
+ */
+ async initCrypto() {
+ if (!(0, _crypto.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.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.logger.log("Crypto: Starting up crypto store...");
+ await this.cryptoStore.startup();
+
+ // initialise the list of encrypted rooms (whether or not crypto is enabled)
+ _logger.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.Crypto(this, userId, this.deviceId, this.store, this.cryptoStore, this.roomList, this.verificationMethods);
+ this.reEmitter.reEmit(crypto, [_crypto.CryptoEvent.KeyBackupFailed, _crypto.CryptoEvent.KeyBackupSessionsRemaining, _crypto.CryptoEvent.RoomKeyRequest, _crypto.CryptoEvent.RoomKeyRequestCancellation, _crypto.CryptoEvent.Warning, _crypto.CryptoEvent.DevicesUpdated, _crypto.CryptoEvent.WillUpdateDevices, _crypto.CryptoEvent.DeviceVerificationChanged, _crypto.CryptoEvent.UserTrustStatusChanged, _crypto.CryptoEvent.KeysChanged]);
+ _logger.logger.log("Crypto: initialising crypto object...");
+ await crypto.init({
+ exportedOlmDevice: this.exportedOlmDeviceToImport,
+ pickleKey: this.pickleKey
+ });
+ delete this.exportedOlmDeviceToImport;
+ this.olmVersion = _crypto.Crypto.getOlmVersion();
+
+ // if crypto initialisation was successful, tell it to attach its event handlers.
+ crypto.registerEventHandlers(this);
+ 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.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.
+ */
+ async initRustCrypto() {
+ if (this.cryptoBackend) {
+ _logger.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 Promise.resolve().then(() => _interopRequireWildcard(require("./rust-crypto")));
+ const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
+ this.cryptoBackend = rustCrypto;
+
+ // attach the event listeners needed by RustCrypto
+ this.on(_roomMember.RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
+ }
+
+ /**
+ * Access the server-side secret storage API for this client.
+ */
+ get secretStorage() {
+ return this._secretStorage;
+ }
+
+ /**
+ * 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`.
+ */
+ getCrypto() {
+ return this.cryptoBackend;
+ }
+
+ /**
+ * Is end-to-end crypto enabled for this client.
+ * @returns True if end-to-end is enabled.
+ * @deprecated prefer {@link getCrypto}
+ */
+ isCryptoEnabled() {
+ return !!this.cryptoBackend;
+ }
+
+ /**
+ * Get the Ed25519 key for this device
+ *
+ * @returns base64-encoded ed25519 key. Null if crypto is
+ * disabled.
+ */
+ getDeviceEd25519Key() {
+ return this.crypto?.getDeviceEd25519Key() ?? null;
+ }
+
+ /**
+ * Get the Curve25519 key for this device
+ *
+ * @returns base64-encoded curve25519 key. Null if crypto is
+ * disabled.
+ */
+ getDeviceCurve25519Key() {
+ return this.crypto?.getDeviceCurve25519Key() ?? null;
+ }
+
+ /**
+ * @deprecated Does nothing.
+ */
+ async uploadKeys() {
+ _logger.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-\>`DeviceInfo`
+ *
+ * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
+ */
+ downloadKeys(userIds, forceDownload) {
+ 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
+ * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
+ */
+ getStoredDevicesForUser(userId) {
+ 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
+ * @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
+ */
+ getStoredDevice(userId, deviceId) {
+ 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}
+ */
+ setDeviceVerified(userId, deviceId, verified = true) {
+ 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}
+ */
+ setDeviceBlocked(userId, deviceId, blocked = true) {
+ 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}
+ */
+ setDeviceKnown(userId, deviceId, known = true) {
+ return this.setDeviceVerification(userId, deviceId, null, null, known);
+ }
+ async setDeviceVerification(userId, deviceId, verified, blocked, known) {
+ 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.
+ */
+ requestVerificationDM(userId, roomId) {
+ 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
+ */
+ findVerificationRequestDMInProgress(roomId) {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.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
+ */
+ getVerificationRequestsToDeviceInProgress(userId) {
+ 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.
+ */
+ requestVerification(userId, devices) {
+ 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.
+ */
+ beginKeyVerification(method, userId, deviceId) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.beginKeyVerification(method, userId, deviceId);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
+ */
+ checkSecretStorageKey(key, info) {
+ return this.secretStorage.checkKey(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;
+ * ```
+ */
+ setGlobalBlacklistUnverifiedDevices(value) {
+ 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;
+ * ```
+ */
+ getGlobalBlacklistUnverifiedDevices() {
+ 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;
+ * ```
+ */
+ setGlobalErrorOnUnknownDevices(value) {
+ 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.
+ */
+ getGlobalErrorOnUnknownDevices() {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.globalErrorOnUnknownDevices;
+ }
+
+ /**
+ * Get the ID of one of the user's cross-signing keys
+ *
+ * @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
+ * @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId}
+ */
+ getCrossSigningId(type = _api.CrossSigningKey.Master) {
+ 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.
+ */
+ getStoredCrossSigningForUser(userId) {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.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.
+ */
+ checkUserTrust(userId) {
+ 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
+ *
+ * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
+ */
+ checkDeviceTrust(userId, deviceId) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.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
+ */
+ checkIfOwnDeviceCrossSigned(deviceId) {
+ 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
+ */
+ checkOwnCrossSigningTrust(opts) {
+ 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
+ */
+ checkCrossSigningPrivateKey(privateKey, expectedPublicKey) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey);
+ }
+
+ // deprecated: use requestVerification instead
+ legacyDeviceVerification(userId, deviceId, method) {
+ 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);
+ * ```
+ */
+ prepareToEncrypt(room) {
+ 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();
+ * ```
+ */
+ userHasCrossSigningKeys() {
+ 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
+ * @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}:
+ */
+ isCrossSigningReady() {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.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 set up)
+ *
+ * @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}.
+ */
+ bootstrapCrossSigning(opts) {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.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
+ *
+ * @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}.
+ */
+ getCryptoTrustCrossSignedDevices() {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.getTrustCrossSignedDevices();
+ }
+
+ /**
+ * See getCryptoTrustCrossSignedDevices
+ *
+ * @param val - True to trust cross-signed devices
+ *
+ * @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}.
+ */
+ setCryptoTrustCrossSignedDevices(val) {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.setTrustCrossSignedDevices(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
+ */
+ countSessionsNeedingBackup() {
+ 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.
+ */
+ getEventEncryptionInfo(event) {
+ 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.
+ */
+ createRecoveryKeyFromPassphrase(password) {
+ 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.
+ *
+ * @returns True if secret storage is ready to be used on this device
+ * @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
+ */
+ isSecretStorageReady() {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.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
+ *
+ */
+ bootstrapSecretStorage(opts) {
+ 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)
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
+ */
+ addSecretStorageKey(algorithm, opts, keyName) {
+ return this.secretStorage.addKey(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.
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
+ */
+ hasSecretStorageKey(keyId) {
+ return this.secretStorage.hasKey(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).
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
+ */
+ storeSecret(name, secret, keys) {
+ return this.secretStorage.store(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
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
+ */
+ getSecret(name) {
+ return this.secretStorage.get(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
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
+ */
+ isSecretStored(name) {
+ return this.secretStorage.isStored(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
+ */
+ requestSecret(name, devices) {
+ 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
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
+ */
+ getDefaultSecretStorageKeyId() {
+ return this.secretStorage.getDefaultKeyId();
+ }
+
+ /**
+ * 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
+ *
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
+ */
+ setDefaultSecretStorageKeyId(keyId) {
+ return this.secretStorage.setDefaultKeyId(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
+ *
+ * @deprecated The use of asymmetric keys for SSSS is deprecated.
+ * Use {@link SecretStorage.ServerSideSecretStorage#checkKey} for symmetric keys.
+ */
+ checkSecretStoragePrivateKey(privateKey, expectedPublicKey) {
+ 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
+ */
+ async getEventSenderDeviceInfo(event) {
+ 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}.
+ */
+ async isEventSenderVerified(event) {
+ 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
+ */
+ getOutgoingRoomKeyRequest(event) {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ const wireContent = event.getWireContent();
+ const requestBody = {
+ 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
+ */
+ cancelAndResendEventRoomKeyRequest(event) {
+ 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.
+ */
+ setRoomEncryption(roomId, config) {
+ 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.
+ */
+ isRoomEncrypted(roomId) {
+ 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(_event2.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.
+ */
+ encryptAndSendToDevices(userDeviceInfoArr, payload) {
+ 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`}:
+ *
+ */
+ forceDiscardSession(roomId) {
+ 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();
+ * ```
+ */
+ exportRoomKeys() {
+ 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
+ */
+ importRoomKeys(keys, opts) {
+ 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.
+ */
+ checkKeyBackup() {
+ 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
+ */
+ async getKeyBackupVersion() {
+ let res;
+ try {
+ res = await this.http.authedRequest(_httpApi.Method.Get, "/room_keys/version", undefined, undefined, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ } catch (e) {
+ if (e.errcode === "M_NOT_FOUND") {
+ return null;
+ } else {
+ throw e;
+ }
+ }
+ _backup.BackupManager.checkBackupVersion(res);
+ return res;
+ }
+
+ /**
+ * @param info - key backup info dict from getKeyBackupVersion()
+ */
+ isKeyBackupTrusted(info) {
+ 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.
+ */
+ getKeyBackupEnabled() {
+ 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.
+ */
+ enableKeyBackup(info) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.enableKeyBackup(info);
+ }
+
+ /**
+ * Disable backing up of keys.
+ */
+ disableKeyBackup() {
+ 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.
+ */
+ async prepareKeyBackupVersion(password, opts = {
+ secureSecretStorage: false
+ }) {
+ 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.secretStorage.store("m.megolm_backup.v1", (0, olmlib.encodeBase64)(privateKey));
+ _logger.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
+ */
+ isKeyBackupKeyStored() {
+ return Promise.resolve(this.secretStorage.isStored("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
+ */
+ async createKeyBackupVersion(info) {
+ 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(_httpApi.Method.Post, "/room_keys/version", undefined, data, {
+ prefix: _httpApi.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.logger.error("Key backup not usable even though we just created it");
+ }
+ return res;
+ }
+ async deleteKeyBackupVersion(version) {
+ 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(_httpApi.Method.Delete, path, undefined, undefined, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ }
+ makeKeyBackupPath(roomId, sessionId, version) {
+ let path;
+ 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
+ */
+
+ async sendKeyBackup(roomId, sessionId, version, data) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const path = this.makeKeyBackupPath(roomId, sessionId, version);
+ await this.http.authedRequest(_httpApi.Method.Put, path.path, path.queryData, data, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+ async scheduleAllGroupSessionsForBackup() {
+ 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.
+ */
+ flagAllGroupSessionsForBackup() {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.flagAllGroupSessionsForBackup();
+ }
+ isValidRecoveryKey(recoveryKey) {
+ try {
+ (0, _recoverykey.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
+ */
+ keyBackupKeyFromPassword(password, backupInfo) {
+ return (0, _key_passphrase.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
+ */
+ keyBackupKeyFromRecoveryKey(recoveryKey) {
+ return (0, _recoverykey.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.
+ */
+
+ async restoreKeyBackupWithPassword(password, targetRoomId, targetSessionId, backupInfo, opts) {
+ const privKey = await (0, _key_passphrase.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.
+ */
+ async restoreKeyBackupWithSecretStorage(backupInfo, targetRoomId, targetSessionId, opts) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const storedKey = await this.secretStorage.get("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 = (0, _crypto.fixBackupKey)(storedKey);
+ if (fixedKey) {
+ const keys = await this.secretStorage.getKey();
+ await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys[0]]);
+ }
+ const privKey = (0, olmlib.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.
+ */
+
+ restoreKeyBackupWithRecoveryKey(recoveryKey, targetRoomId, targetSessionId, backupInfo, opts) {
+ const privKey = (0, _recoverykey.decodeRecoveryKey)(recoveryKey);
+ return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts);
+ }
+ async restoreKeyBackupWithCache(targetRoomId, targetSessionId, backupInfo, opts) {
+ 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);
+ }
+ async restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts) {
+ const cacheCompleteCallback = opts?.cacheCompleteCallback;
+ const progressCallback = opts?.progressCallback;
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ let totalKeyCount = 0;
+ let keys = [];
+ const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
+ const algorithm = await _backup.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 _httpApi.MatrixError({
+ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY
+ }));
+ }
+
+ // Cache the key, if possible.
+ // This is async.
+ this.crypto.storeSessionBackupPrivateKey(privKey).catch(e => {
+ _logger.logger.warn("Error caching session backup key:", e);
+ }).then(cacheCompleteCallback);
+ if (progressCallback) {
+ progressCallback({
+ stage: "fetch"
+ });
+ }
+ const res = await this.http.authedRequest(_httpApi.Method.Get, path.path, path.queryData, undefined, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ if (res.rooms) {
+ const rooms = res.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.sessions) {
+ const sessions = res.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
+ });
+ key.room_id = targetRoomId;
+ key.session_id = targetSessionId;
+ keys.push(key);
+ } catch (e) {
+ _logger.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
+ };
+ }
+ async deleteKeysFromBackup(roomId, sessionId, version) {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const path = this.makeKeyBackupPath(roomId, sessionId, version);
+ await this.http.authedRequest(_httpApi.Method.Delete, path.path, path.queryData, undefined, {
+ prefix: _httpApi.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.
+ */
+ async sendSharedHistoryKeys(roomId, userIds) {
+ 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.logger.error("Unknown room. Not sharing decryption keys");
+ return;
+ }
+ const deviceInfos = await this.crypto.downloadKeys(userIds);
+ const devicesByUser = 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.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.
+ */
+ getMediaConfig() {
+ return this.http.authedRequest(_httpApi.Method.Get, "/config", undefined, undefined, {
+ prefix: _httpApi.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.
+ */
+ getRoom(roomId) {
+ 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.
+ */
+ getRooms() {
+ 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.
+ */
+ getVisibleRooms(msc3946ProcessDynamicPredecessor = false) {
+ 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(_event2.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.
+ */
+ getUser(userId) {
+ return this.store.getUser(userId);
+ }
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, or an empty list if there is no data store.
+ */
+ getUsers() {
+ 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.
+ */
+ setAccountData(eventType, content) {
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId,
+ $type: eventType
+ });
+ return (0, _httpApi.retryNetworkOperation)(5, () => {
+ return this.http.authedRequest(_httpApi.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
+ */
+ getAccountData(eventType) {
+ 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.
+ */
+ async getAccountDataFromServer(eventType) {
+ 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();
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId,
+ $type: eventType
+ });
+ try {
+ return await this.http.authedRequest(_httpApi.Method.Get, path);
+ } catch (e) {
+ if (e.data?.errcode === "M_NOT_FOUND") {
+ return null;
+ }
+ throw e;
+ }
+ }
+ async deleteAccountData(eventType) {
+ const msc3391DeleteAccountDataServerSupport = this.canSupport.get(_feature.Feature.AccountDataDeletion);
+ // if deletion is not supported overwrite with empty content
+ if (msc3391DeleteAccountDataServerSupport === _feature.ServerSupport.Unsupported) {
+ await this.setAccountData(eventType, {});
+ return;
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.getSafeUserId(),
+ $type: eventType
+ });
+ const options = msc3391DeleteAccountDataServerSupport === _feature.ServerSupport.Unstable ? {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3391"
+ } : undefined;
+ return await this.http.authedRequest(_httpApi.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)
+ */
+ getIgnoredUsers() {
+ 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.
+ */
+ setIgnoredUsers(userIds) {
+ const content = {
+ ignored_users: {}
+ };
+ 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
+ */
+ isUserIgnored(userId) {
+ 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.
+ */
+ async joinRoom(roomIdOrAlias, opts = {}) {
+ 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.resolve();
+ if (opts.inviteSignUrl) {
+ const url = new URL(opts.inviteSignUrl);
+ url.searchParams.set("mxid", this.credentials.userId);
+ signPromise = this.http.requestOtherUrl(_httpApi.Method.Post, url);
+ }
+ const queryString = {};
+ if (opts.viaServers) {
+ queryString["server_name"] = opts.viaServers;
+ }
+ const data = {};
+ 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(_httpApi.Method.Post, path, queryString, data);
+ const roomId = res.room_id;
+ const syncApi = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ const syncRoom = syncApi.createRoom(roomId);
+ if (opts.syncRoom) {
+ // v2 will do this for us
+ // return syncApi.syncRoom(room);
+ }
+ return syncRoom;
+ }
+
+ /**
+ * 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.
+ */
+ resendEvent(event, room) {
+ // also kick the to-device queue to retry
+ this.toDeviceMessageQueue.sendQueue();
+ this.updatePendingEventStatus(room, event, _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
+ */
+ cancelPendingEvent(event) {
+ if (![_event.EventStatus.QUEUED, _event.EventStatus.NOT_SENT, _event.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 === _event.EventStatus.ENCRYPTING) {
+ this.pendingEventEncryption.delete(event.getId());
+ } else if (this.scheduler && event.status === _event.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, _event.EventStatus.CANCELLED);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ setRoomName(roomId, name) {
+ return this.sendStateEvent(roomId, _event2.EventType.RoomName, {
+ name: name
+ });
+ }
+
+ /**
+ * @param htmlTopic - Optional.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ setRoomTopic(roomId, topic, htmlTopic) {
+ const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
+ return this.sendStateEvent(roomId, _event2.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.
+ */
+ getRoomTags(roomId) {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", {
+ $userId: this.credentials.userId,
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setRoomTag(roomId, tagName, metadata = {}) {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId,
+ $roomId: roomId,
+ $tag: tagName
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deleteRoomTag(roomId, tagName) {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId,
+ $roomId: roomId,
+ $tag: tagName
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setRoomAccountData(roomId, eventType, content) {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
+ $userId: this.credentials.userId,
+ $roomId: roomId,
+ $type: eventType
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setPowerLevel(roomId, userId, powerLevel, event) {
+ let content = {
+ users: {}
+ };
+ if (event?.getType() === _event2.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(_httpApi.Method.Put, path, undefined, content);
+ }
+
+ /**
+ * Create an m.beacon_info event
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ async unstable_createLiveBeacon(roomId, beaconInfoContent) {
+ 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
+ async unstable_setLiveBeacon(roomId, beaconInfoContent) {
+ return this.sendStateEvent(roomId, _beacon.M_BEACON_INFO.name, beaconInfoContent, this.getUserId());
+ }
+ sendEvent(roomId, threadIdOrEventType, eventTypeOrContent, contentOrTxnId, txnIdOrVoid) {
+ let threadId;
+ let eventType;
+ let content;
+ let txnId;
+ if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) {
+ txnId = contentOrTxnId;
+ content = eventTypeOrContent;
+ eventType = threadIdOrEventType;
+ threadId = null;
+ } else {
+ txnId = txnIdOrVoid;
+ content = contentOrTxnId;
+ eventType = eventTypeOrContent;
+ 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"] = _objectSpread(_objectSpread({}, content["m.relates_to"]), {}, {
+ rel_type: _thread.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 => {
+ return ev.isRelation(_thread.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.
+ */
+ sendCompleteEvent(roomId, threadId, eventObject, txnId) {
+ 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 _event.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, [_event.MatrixEventEvent.Replaced, _event.MatrixEventEvent.VisibilityChange]);
+ room?.reEmitter.reEmit(localEvent, [_event.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(_event.MatrixEventEvent.LocalEventIdReplaced, () => {
+ localEvent.updateAssociatedId(target.getId());
+ });
+ }
+ const type = localEvent.getType();
+ _logger.logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
+ localEvent.setTxnId(txnId);
+ localEvent.setStatus(_event.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 === _event.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
+ */
+ encryptAndSendEvent(room, event) {
+ 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, _event.EventStatus.ENCRYPTING);
+ return encryptionPromise.then(() => {
+ if (!this.pendingEventEncryption.has(event.getId())) {
+ // cancelled via MatrixClient::cancelPendingEvent
+ cancelled = true;
+ return;
+ }
+ this.updatePendingEventStatus(room, event, _event.EventStatus.SENDING);
+ });
+ }).then(() => {
+ if (cancelled) return {};
+ let promise = 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, _event.EventStatus.QUEUED);
+ }
+ }
+ if (!promise) {
+ promise = this.sendEventHttpRequest(event);
+ if (room) {
+ promise = promise.then(res => {
+ room.updatePendingEvent(event, _event.EventStatus.SENT, res["event_id"]);
+ return res;
+ });
+ }
+ }
+ return promise;
+ }).catch(err => {
+ _logger.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, _event.EventStatus.NOT_SENT);
+ } catch (e) {
+ _logger.logger.error("Exception in error handler!", e.stack || err);
+ }
+ if (err instanceof _httpApi.MatrixError) {
+ err.event = event;
+ }
+ throw err;
+ });
+ }
+ encryptEventIfNeeded(event, room) {
+ 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() === _event2.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
+ */
+ getEncryptedIfNeededEventType(roomId, eventType) {
+ if (eventType === _event2.EventType.Reaction) return eventType;
+ return this.isRoomEncrypted(roomId) ? _event2.EventType.RoomMessageEncrypted : eventType;
+ }
+ updatePendingEventStatus(room, event, newStatus) {
+ if (room) {
+ room.updatePendingEvent(event, newStatus);
+ } else {
+ event.setStatus(newStatus);
+ }
+ }
+ sendEventHttpRequest(event) {
+ 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;
+ 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, _objectSpread({
+ $redactsEventId: event.event.redacts
+ }, pathParams));
+ } else {
+ path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
+ }
+ return this.http.authedRequest(_httpApi.Method.Put, path, undefined, event.getWireContent()).then(res => {
+ _logger.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`.
+ */
+
+ redactEvent(roomId, threadId, eventId, txnId, opts) {
+ if (!eventId?.startsWith(EVENT_ID_PREFIX)) {
+ opts = txnId;
+ txnId = eventId;
+ eventId = threadId;
+ threadId = null;
+ }
+ const reason = opts?.reason;
+ if (opts?.with_relations && this.canSupport.get(_feature.Feature.RelationBasedRedactions) === _feature.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.Feature.RelationBasedRedactions) === _feature.ServerSupport.Stable ? _event2.MSC3912_RELATION_BASED_REDACTIONS_PROP.stable : _event2.MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable]: opts?.with_relations
+ } : {};
+ return this.sendCompleteEvent(roomId, threadId, {
+ type: _event2.EventType.RoomRedaction,
+ content: _objectSpread(_objectSpread({}, withRelations), {}, {
+ reason
+ }),
+ redacts: eventId
+ }, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+
+ sendMessage(roomId, threadId, content, txnId) {
+ if (typeof threadId !== "string" && threadId !== null) {
+ txnId = content;
+ content = threadId;
+ threadId = null;
+ }
+ const eventType = _event2.EventType.RoomMessage;
+ const sendContent = content;
+ return this.sendEvent(roomId, threadId, eventType, sendContent, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns
+ * @returns Rejects: with an error response.
+ */
+
+ sendTextMessage(roomId, threadId, body, txnId) {
+ 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.
+ */
+
+ sendNotice(roomId, threadId, body, txnId) {
+ 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.
+ */
+
+ sendEmoteMessage(roomId, threadId, body, txnId) {
+ 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.
+ */
+
+ sendImageMessage(roomId, threadId, url, info, text = "Image") {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = info || "Image";
+ info = url;
+ url = threadId;
+ threadId = null;
+ }
+ const content = {
+ msgtype: _event2.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.
+ */
+
+ sendStickerMessage(roomId, threadId, url, info, text = "Sticker") {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = info || "Sticker";
+ info = url;
+ url = threadId;
+ threadId = null;
+ }
+ const content = {
+ url: url,
+ info: info,
+ body: text
+ };
+ return this.sendEvent(roomId, threadId, _event2.EventType.Sticker, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+
+ sendHtmlMessage(roomId, threadId, body, htmlBody) {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body;
+ 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.
+ */
+
+ sendHtmlNotice(roomId, threadId, body, htmlBody) {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body;
+ 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.
+ */
+
+ sendHtmlEmote(roomId, threadId, body, htmlBody) {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body;
+ 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.
+ */
+ async sendReceipt(event, receiptType, body, unthreaded = false) {
+ 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 = _objectSpread(_objectSpread({}, body), {}, {
+ thread_id: isThread ? event.threadRootId : _read_receipts.MAIN_ROOM_TIMELINE
+ });
+ }
+ const promise = this.http.authedRequest(_httpApi.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.
+ */
+ async sendReadReceipt(event, receiptType = _read_receipts.ReceiptType.Read, unthreaded = false) {
+ 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, `{}`.
+ */
+ async setRoomReadMarkers(roomId, rmEventId, rrEvent, rpEvent) {
+ const room = this.getRoom(roomId);
+ if (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;
+ 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, _read_receipts.ReceiptType.Read);
+ }
+
+ // Add the optional private RR update, do local echo like `sendReceipt`
+ let rpEventId;
+ 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, _read_receipts.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.
+ */
+ getUrlPreview(url, ts) {
+ // 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.
+ if (key in this.urlPreviewCache) {
+ return this.urlPreviewCache[key];
+ }
+ const resp = this.http.authedRequest(_httpApi.Method.Get, "/preview_url", {
+ url,
+ ts: ts.toString()
+ }, undefined, {
+ prefix: _httpApi.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.
+ */
+ sendTyping(roomId, isTyping, timeoutMs) {
+ 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 = {
+ typing: isTyping
+ };
+ if (isTyping) {
+ data.timeout = timeoutMs ? timeoutMs : 20000;
+ }
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getRoomUpgradeHistory(roomId, verifyLinks = false, msc3946ProcessDynamicPredecessor = false) {
+ 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];
+ }
+ findPredecessorRooms(room, verifyLinks, msc3946ProcessDynamicPredecessor) {
+ const ret = [];
+
+ // 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(_event2.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;
+ }
+ findSuccessorRooms(room, verifyLinks, msc3946ProcessDynamicPredecessor) {
+ const ret = [];
+
+ // Work forwards, looking at tombstone events
+ let tombstoneEvent = room.currentState.getStateEvents(_event2.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(_event2.EventType.RoomTombstone, "");
+ }
+ return ret;
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ invite(roomId, userId, reason) {
+ 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.
+ */
+ inviteByEmail(roomId, email) {
+ 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.
+ */
+ async inviteByThreePid(roomId, medium, address) {
+ const path = utils.encodeUri("/rooms/$roomId/invite", {
+ $roomId: roomId
+ });
+ const identityServerUrl = this.getIdentityServerUrl(true);
+ if (!identityServerUrl) {
+ return Promise.reject(new _httpApi.MatrixError({
+ error: "No supplied identity server URL",
+ errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM"
+ }));
+ }
+ const params = {
+ 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(_httpApi.Method.Post, path, undefined, params);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ leave(roomId) {
+ 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.
+ */
+ leaveRoomChain(roomId, includeFuture = true) {
+ 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 = {};
+ const promises = [];
+ const doLeave = roomId => {
+ 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.
+ */
+ ban(roomId, userId, reason) {
+ 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.
+ */
+ forget(roomId, deleteRoom = true) {
+ 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.
+ */
+ unban(roomId, userId) {
+ // 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(_httpApi.Method.Post, path, undefined, data);
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ kick(roomId, userId, reason) {
+ const path = utils.encodeUri("/rooms/$roomId/kick", {
+ $roomId: roomId
+ });
+ const data = {
+ user_id: userId,
+ reason: reason
+ };
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, data);
+ }
+ membershipChange(roomId, userId, membership, reason) {
+ // API returns an empty object
+ const path = utils.encodeUri("/rooms/$room_id/$membership", {
+ $room_id: roomId,
+ $membership: membership
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getPushActionsForEvent(event, forceRecalculate = false) {
+ if (!event.getPushActions() || forceRecalculate) {
+ const {
+ actions,
+ rule
+ } = this.pushProcessor.actionsAndRuleForEvent(event);
+ event.setPushDetails(actions, rule);
+ }
+ return event.getPushActions();
+ }
+
+ /**
+ * 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.
+ */
+ getPushDetailsForEvent(event, forceRecalculate = false) {
+ if (!event.getPushDetails() || forceRecalculate) {
+ const {
+ actions,
+ rule
+ } = this.pushProcessor.actionsAndRuleForEvent(event);
+ event.setPushDetails(actions, rule);
+ }
+ return event.getPushDetails();
+ }
+
+ /**
+ * @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
+ setProfileInfo(info, data) {
+ const path = utils.encodeUri("/profile/$userId/$info", {
+ $userId: this.credentials.userId,
+ $info: info
+ });
+ return this.http.authedRequest(_httpApi.Method.Put, path, undefined, data);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ async setDisplayName(name) {
+ 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(_user.UserEvent.DisplayName, user.events.presence, user);
+ }
+ return prom;
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ async setAvatarUrl(url) {
+ 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(_user.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.
+ */
+ mxcUrlToHttp(mxcUrl, width, height, resizeMethod, allowDirectLinks) {
+ return (0, _contentRepo.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.
+ */
+ async setPresence(opts) {
+ 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(_httpApi.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.
+ */
+ getPresence(userId) {
+ const path = utils.encodeUri("/presence/$userId/status", {
+ $userId: userId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ scrollback(room, limit = 30) {
+ 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((resolve, reject) => {
+ // wait for a time before doing this request
+ // (which may be 0 in order not to special case the code paths)
+ (0, utils.sleep)(timeToWaitMs).then(() => {
+ return this.createMessagesRequest(room.roomId, room.oldState.paginationToken, limit, _eventTimeline.Direction.Backward);
+ }).then(res => {
+ const matrixEvents = res.chunk.map(this.getEventMapper());
+ if (res.state) {
+ const stateEvents = res.state.map(this.getEventMapper());
+ room.currentState.setUnknownStateEvents(stateEvents);
+ }
+ const [timelineEvents, threadedEvents, unknownRelations] = room.partitionThreadedEvents(matrixEvents);
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
+ this.processThreadEvents(room, threadedEvents, true);
+ unknownRelations.forEach(event => room.relations.aggregateChildEvent(event));
+ 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;
+ }
+ getEventMapper(options) {
+ return (0, _eventMapper.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
+ */
+ async getEventTimeline(timelineSet, eventId) {
+ // 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 = undefined;
+ if (this.clientOpts?.lazyLoadMembers) {
+ params = {
+ filter: JSON.stringify(_filter.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(_httpApi.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.THREAD_RELATION_TYPE.name)) {
+ _logger.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.EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
+ } else {
+ timeline = timelineSet.addTimeline();
+ timeline.initialiseState(res.state.map(mapper));
+ timeline.getState(_eventTimeline.EventTimeline.FORWARDS).paginationToken = res.end;
+ }
+ const [timelineEvents, threadedEvents, unknownRelations] = 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);
+ unknownRelations.forEach(event => timelineSet.relations.aggregateChildEvent(event));
+
+ // 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;
+ }
+ async getThreadTimeline(timelineSet, eventId) {
+ 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 = {
+ limit: "0"
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(_filter.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(_httpApi.Method.Get, path, params);
+ const mapper = this.getEventMapper();
+ const event = mapper(res.event);
+ if (!timelineSet.canContain(event)) {
+ return undefined;
+ }
+ const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported;
+ if (_thread.Thread.hasServerSideSupport) {
+ if (_thread.Thread.hasServerSideFwdPaginationSupport) {
+ if (!timelineSet.thread) {
+ throw new Error("could not get thread timeline: not a thread timeline");
+ }
+ const thread = timelineSet.thread;
+ const resOlder = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, {
+ dir: _eventTimeline.Direction.Backward,
+ from: res.start,
+ recurse: recurse || undefined
+ });
+ const resNewer = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, {
+ dir: _eventTimeline.Direction.Forward,
+ from: res.end,
+ recurse: recurse || 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.
+ ...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.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, _eventTimeline.Direction.Backward);
+ timeline.setPaginationToken(resNewer.next_batch ?? null, _eventTimeline.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.THREAD_RELATION_TYPE.name, null, {
+ dir: _eventTimeline.Direction.Backward,
+ from: res.start,
+ recurse: recurse || undefined
+ });
+ const eventsNewer = [];
+ let nextBatch = res.end;
+ while (nextBatch) {
+ const resNewer = await this.fetchRelations(timelineSet.room.roomId, thread.id, _thread.THREAD_RELATION_TYPE.name, null, {
+ dir: _eventTimeline.Direction.Forward,
+ from: nextBatch,
+ recurse: recurse || undefined
+ });
+ 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.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, _eventTimeline.Direction.Backward);
+ timeline.setPaginationToken(null, _eventTimeline.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
+ */
+ async getLatestTimeline(timelineSet) {
+ // 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, _eventTimeline.Direction.Backward, timelineSet.threadListType, timelineSet.getFilter());
+ event = res.chunk?.[0];
+ } else if (timelineSet.thread && _thread.Thread.hasServerSideSupport) {
+ const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported;
+ const res = await this.fetchRelations(timelineSet.room.roomId, timelineSet.thread.id, _thread.THREAD_RELATION_TYPE.name, null, {
+ dir: _eventTimeline.Direction.Backward,
+ limit: 1,
+ recurse: recurse || undefined
+ });
+ event = res.chunk?.[0];
+ } else {
+ const messagesPath = utils.encodeUri("/rooms/$roomId/messages", {
+ $roomId: timelineSet.room.roomId
+ });
+ const params = {
+ dir: "b"
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(_filter.Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+ const res = await this.http.authedRequest(_httpApi.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.
+ createMessagesRequest(roomId, fromToken, limit = 30, dir, timelineFilter) {
+ const path = utils.encodeUri("/rooms/$roomId/messages", {
+ $roomId: roomId
+ });
+ const params = {
+ limit: limit.toString(),
+ dir: dir
+ };
+ if (fromToken) {
+ params.from = fromToken;
+ }
+ let filter = 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.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(_httpApi.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
+ createThreadListMessagesRequest(roomId, fromToken, limit = 30, dir = _eventTimeline.Direction.Backward, threadListType = _thread.ThreadFilterType.All, timelineFilter) {
+ const path = utils.encodeUri("/rooms/$roomId/threads", {
+ $roomId: roomId
+ });
+ const params = {
+ limit: limit.toString(),
+ dir: dir,
+ include: (0, _thread.threadFilterTypeToFilter)(threadListType)
+ };
+ if (fromToken) {
+ params.from = fromToken;
+ }
+ let filter = {};
+ if (this.clientOpts?.lazyLoadMembers) {
+ // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+ // so the timelineFilter doesn't get written into it below
+ filter = _objectSpread({}, _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 = _objectSpread(_objectSpread({}, filter), timelineFilter.getRoomTimelineFilterComponent()?.toJSON());
+ }
+ if (Object.keys(filter).length) {
+ params.filter = JSON.stringify(filter);
+ }
+ const opts = {
+ prefix: _thread.Thread.hasServerSideListSupport === _thread.FeatureSupport.Stable ? "/_matrix/client/v1" : "/_matrix/client/unstable/org.matrix.msc3856"
+ };
+ return this.http.authedRequest(_httpApi.Method.Get, path, params, undefined, opts).then(res => _objectSpread(_objectSpread({}, 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.
+ */
+ paginateEventTimeline(eventTimeline, opts) {
+ 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.EventTimeline.BACKWARDS : _eventTimeline.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;
+ let params;
+ let promise;
+ if (isNotifTimeline) {
+ path = "/notifications";
+ params = {
+ limit: (opts.limit ?? 30).toString(),
+ only: "highlight"
+ };
+ if (token && token !== "end") {
+ params.from = token;
+ }
+ promise = this.http.authedRequest(_httpApi.Method.Get, path, params).then(async res => {
+ const token = res.next_token;
+ const matrixEvents = [];
+ res.notifications = res.notifications.filter(utils.noUnsafeEventProps);
+ for (let i = 0; i < res.notifications.length; i++) {
+ const notification = res.notifications[i];
+ const event = this.getEventMapper()(notification.event);
+
+ // @TODO(kerrya) reprocessing every notification is ugly
+ // remove if we get server MSC3994 support
+ this.getPushDetailsForEvent(event, true);
+ 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.Thread.hasServerSideFwdPaginationSupport && dir === _eventTimeline.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(utils.noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(utils.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());
+ }
+ const recurse = this.canSupport.get(_feature.Feature.RelationsRecursion) !== _feature.ServerSupport.Unsupported;
+ promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, _thread.THREAD_RELATION_TYPE.name, null, {
+ dir,
+ limit: opts.limit,
+ from: token ?? undefined,
+ recurse: recurse || undefined
+ }).then(async res => {
+ const mapper = this.getEventMapper();
+ const matrixEvents = res.chunk.filter(utils.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, _read_receipts.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(utils.noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(utils.noUnsafeEventProps).map(this.getEventMapper());
+ const timelineSet = eventTimeline.getTimelineSet();
+ const [timelineEvents,, unknownRelations] = room.partitionThreadedEvents(matrixEvents);
+ timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ this.processThreadRoots(room, timelineEvents.filter(it => it.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name)), false);
+ unknownRelations.forEach(event => room.relations.aggregateChildEvent(event));
+ 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.
+ */
+ resetNotifTimelineSet() {
+ 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.
+ */
+ peekInRoom(roomId) {
+ this.peekSync?.stopPeeking();
+ this.peekSync = new _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ return this.peekSync.peek(roomId);
+ }
+
+ /**
+ * Stop any ongoing room peeking.
+ */
+ stopPeeking() {
+ 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.
+ */
+ setGuestAccess(roomId, opts) {
+ const writePromise = this.sendStateEvent(roomId, _event2.EventType.RoomGuestAccess, {
+ guest_access: opts.allowJoin ? "can_join" : "forbidden"
+ }, "");
+ let readPromise = Promise.resolve(undefined);
+ if (opts.allowRead) {
+ readPromise = this.sendStateEvent(roomId, _event2.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
+ */
+ requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ requestRegisterMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ requestAdd3pidEmailToken(email, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ requestAdd3pidMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ requestPasswordMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+ 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
+ */
+ async requestTokenFromEndpoint(endpoint, params) {
+ 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(_httpApi.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.
+ */
+ getRoomPushRule(scope, roomId) {
+ // 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.
+ */
+ setRoomMutePushRule(scope, roomId, mute) {
+ let promise;
+ let hasDontNotifyRule = false;
+
+ // Get the existing room-kind push rule if any
+ const roomPushRule = this.getRoomPushRule(scope, roomId);
+ if (roomPushRule?.actions.includes(_PushRules.PushRuleActionName.DontNotify)) {
+ hasDontNotifyRule = true;
+ }
+ if (!mute) {
+ // Remove the rule only if it is a muting rule
+ if (hasDontNotifyRule) {
+ promise = this.deletePushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomPushRule.rule_id);
+ }
+ } else {
+ if (!roomPushRule) {
+ promise = this.addPushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomId, {
+ actions: [_PushRules.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, _PushRules.PushRuleKind.RoomSpecific, roomPushRule.rule_id).then(() => {
+ this.addPushRule(scope, _PushRules.PushRuleKind.RoomSpecific, roomId, {
+ actions: [_PushRules.PushRuleActionName.DontNotify]
+ }).then(() => {
+ deferred.resolve();
+ }).catch(err => {
+ deferred.reject(err);
+ });
+ }).catch(err => {
+ deferred.reject(err);
+ });
+ promise = deferred.promise;
+ }
+ }
+ if (promise) {
+ return new Promise((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 => {
+ // 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);
+ });
+ });
+ });
+ }
+ }
+ searchMessageText(opts) {
+ const roomEvents = {
+ 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.
+ */
+ searchRoomEvents(opts) {
+ // TODO: support search groups
+
+ const body = {
+ search_categories: {
+ room_events: {
+ search_term: opts.term,
+ filter: opts.filter,
+ order_by: _search.SearchOrderBy.Recent,
+ event_context: {
+ before_limit: 1,
+ after_limit: 1,
+ include_profile: true
+ }
+ }
+ }
+ };
+ const searchResults = {
+ _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.
+ */
+ backPaginateRoomEventsSearch(searchResults) {
+ // 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;
+ }
+ 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
+ processRoomEventsSearch(searchResults, response) {
+ 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(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.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.
+ */
+ syncLeftRooms() {
+ // 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 _sync.SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
+
+ // cleanup locks
+ this.syncLeftRoomsPromise.then(() => {
+ _logger.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.
+ */
+ createFilter(content) {
+ const path = utils.encodeUri("/user/$userId/filter", {
+ $userId: this.credentials.userId
+ });
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, content).then(response => {
+ // persist the filter
+ const filter = _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.
+ */
+ getFilter(userId, filterId, allowCached) {
+ 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(_httpApi.Method.Get, path).then(response => {
+ // persist the filter
+ const filter = _filter.Filter.fromJson(userId, filterId, response);
+ this.store.storeFilter(filter);
+ return filter;
+ });
+ }
+
+ /**
+ * @returns Filter ID
+ */
+ async getOrCreateFilter(filterName, filter) {
+ const filterId = this.store.getFilterIdByName(filterName);
+ let existingId;
+ 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 (error.errcode !== "M_UNKNOWN" && 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.
+ */
+ getOpenIdToken() {
+ const path = utils.encodeUri("/user/$userId/openid/request_token", {
+ $userId: this.credentials.userId
+ });
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, {});
+ }
+ /**
+ * @returns Promise which resolves: ITurnServerResponse object
+ * @returns Rejects: with an error response.
+ */
+ turnServer() {
+ return this.http.authedRequest(_httpApi.Method.Get, "/voip/turnServer");
+ }
+
+ /**
+ * Get the TURN servers for this homeserver.
+ * @returns The servers or an empty list.
+ */
+ getTurnServers() {
+ return this.turnServers || [];
+ }
+
+ /**
+ * Get the unix timestamp (in milliseconds) at which the current
+ * TURN credentials (from getTurnServers) expire
+ * @returns The expiry timestamp in milliseconds
+ */
+ getTurnServersExpiry() {
+ return this.turnServersExpiry;
+ }
+ get pollingTurnServers() {
+ return this.checkTurnServersIntervalID !== undefined;
+ }
+
+ // XXX: Intended private, used in code.
+ async checkTurnServers() {
+ if (!this.canSupportVoip) {
+ return;
+ }
+ let credentialsGood = false;
+ const remainingTime = this.turnServersExpiry - Date.now();
+ if (remainingTime > TURN_CHECK_INTERVAL) {
+ _logger.logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
+ credentialsGood = true;
+ } else {
+ _logger.logger.debug("Fetching new TURN credentials");
+ try {
+ const res = await this.turnServer();
+ if (res.uris) {
+ _logger.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 = {
+ 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.logger.error("Failed to get TURN URIs", err);
+ if (err.httpStatus === 403) {
+ // We got a 403, so there's no point in looping forever.
+ _logger.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, err, true); // fatal
+ } else {
+ // otherwise, if we failed for whatever reason, try again the next time we're called.
+ this.emit(ClientEvent.TurnServersError, 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.
+ *
+ */
+ setFallbackICEServerAllowed(allow) {
+ 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
+ */
+ isFallbackICEServerAllowed() {
+ 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.
+ */
+ isSynapseAdministrator() {
+ const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", {
+ $userId: this.getUserId()
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ whoisSynapseUser(userId) {
+ const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", {
+ $userId: userId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deactivateSynapseUser(userId) {
+ const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", {
+ $userId: userId
+ });
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, undefined, {
+ prefix: ""
+ });
+ }
+ async fetchClientWellKnown() {
+ // `getRawClientConfig` does not throw or reject on network errors, instead
+ // it absorbs errors and returns `{}`.
+ this.clientWellKnownPromise = _autodiscovery.AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined);
+ this.clientWellKnown = await this.clientWellKnownPromise;
+ this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown);
+ }
+ getClientWellKnown() {
+ return this.clientWellKnown;
+ }
+ waitForClientWellKnown() {
+ 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
+ */
+ storeClientOptions() {
+ // 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((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
+ async _unstable_getSharedRooms(userId) {
+ 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(_httpApi.Method.Get, path, undefined, undefined, {
+ prefix: _httpApi.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
+ */
+ async getVersions() {
+ if (this.serverVersionsPromise) {
+ return this.serverVersionsPromise;
+ }
+ this.serverVersionsPromise = this.http.request(_httpApi.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 (0, _feature.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
+ */
+ async isVersionSupported(version) {
+ 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
+ */
+ async doesServerSupportLazyLoading() {
+ 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
+ */
+ async doesServerRequireIdServerParam() {
+ 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
+ */
+ async doesServerAcceptIdentityAccessToken() {
+ 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
+ */
+ async doesServerSupportSeparateAddAndBind() {
+ 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
+ */
+ async doesServerSupportUnstableFeature(feature) {
+ 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.
+ */
+ async doesServerForceEncryptionForPreset(presetName) {
+ 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}`];
+ }
+ async doesServerSupportThread() {
+ if (await this.isVersionSupported("v1.4")) {
+ return {
+ threads: _thread.FeatureSupport.Stable,
+ list: _thread.FeatureSupport.Stable,
+ fwdPagination: _thread.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: (0, _thread.determineFeatureSupport)(threadStable, threadUnstable),
+ list: (0, _thread.determineFeatureSupport)(listStable, listUnstable),
+ fwdPagination: (0, _thread.determineFeatureSupport)(fwdPaginationStable, fwdPaginationUnstable)
+ };
+ } catch (e) {
+ return {
+ threads: _thread.FeatureSupport.None,
+ list: _thread.FeatureSupport.None,
+ fwdPagination: _thread.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
+ */
+ doesServerSupportLogoutDevices() {
+ 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
+ */
+ hasLazyLoadMembersEnabled() {
+ 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.
+ */
+ setCanResetTimelineCallback(cb) {
+ this.canResetTimelineCallback = cb;
+ }
+
+ /**
+ * Get the callback set via `setCanResetTimelineCallback`.
+ * @returns The callback or null
+ */
+ getCanResetTimelineCallback() {
+ 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.
+ */
+ async relations(roomId, eventId, relationType, eventType, opts = {
+ dir: _eventTimeline.Direction.Backward
+ }) {
+ 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 === _event2.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 === _event2.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.
+ */
+ getCrossSigningCacheCallbacks() {
+ // 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
+ */
+ generateClientSecret() {
+ return (0, _randomstring.randomString)(32);
+ }
+
+ /**
+ * Attempts to decrypt an event
+ * @param event - The event to decrypt
+ * @returns A decryption promise
+ */
+ decryptEventIfNeeded(event, options) {
+ if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) {
+ event.attemptDecryption(this.cryptoBackend, options);
+ }
+ if (event.isBeingDecrypted()) {
+ return event.getDecryptionPromise();
+ } else {
+ return Promise.resolve();
+ }
+ }
+ termsUrlForService(serviceType, baseUrl) {
+ switch (serviceType) {
+ case _serviceTypes.SERVICE_TYPES.IS:
+ return this.http.getUrl("/terms", undefined, _httpApi.IdentityPrefix.V2, baseUrl);
+ case _serviceTypes.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
+ */
+ getHomeserverUrl() {
+ 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
+ */
+ getIdentityServerUrl(stripProto = false) {
+ 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
+ */
+ setIdentityServerUrl(url) {
+ this.idBaseUrl = utils.ensureNoTrailingSlash(url);
+ this.http.setIdBaseUrl(this.idBaseUrl);
+ }
+
+ /**
+ * Get the access token associated with this account.
+ * @returns The access_token or null
+ */
+ getAccessToken() {
+ return this.http.opts.accessToken || null;
+ }
+
+ /**
+ * Set the access token associated with this account.
+ * @param token - The new access token.
+ */
+ setAccessToken(token) {
+ this.http.opts.accessToken = token;
+ }
+
+ /**
+ * @returns true if there is a valid access_token for this client.
+ */
+ isLoggedIn() {
+ return this.http.opts.accessToken !== undefined;
+ }
+
+ /**
+ * Make up a new transaction id
+ *
+ * @returns a new, unique, transaction id
+ */
+ makeTxnId() {
+ 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.
+ */
+ isUsernameAvailable(username) {
+ return this.http.authedRequest(_httpApi.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.
+ */
+ register(username, password, sessionId, auth, bindThreepids, guestAccessToken, inhibitLogin) {
+ // backwards compat
+ if (bindThreepids === true) {
+ bindThreepids = {
+ email: true
+ };
+ } else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) {
+ bindThreepids = {};
+ }
+ if (sessionId) {
+ auth.session = sessionId;
+ }
+ const params = {
+ 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.
+ */
+ registerGuest({
+ body
+ } = {}) {
+ // 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.
+ */
+ registerRequest(data, kind) {
+ const params = {};
+ if (kind) {
+ params.kind = kind;
+ }
+ return this.http.request(_httpApi.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.
+ */
+ refreshToken(refreshToken) {
+ return this.http.authedRequest(_httpApi.Method.Post, "/refresh", undefined, {
+ refresh_token: refreshToken
+ }, {
+ prefix: _httpApi.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.
+ */
+ loginFlows() {
+ return this.http.request(_httpApi.Method.Get, "/login");
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ login(loginType, data) {
+ // TODO: Types
+ const loginData = {
+ type: loginType
+ };
+
+ // merge data into loginData
+ Object.assign(loginData, data);
+ return this.http.authedRequest(_httpApi.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.
+ */
+ loginWithPassword(user, password) {
+ // 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.
+ */
+ loginWithSAML2(relayState) {
+ // 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.
+ */
+ getCasLoginUrl(redirectUrl) {
+ 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.
+ */
+ getSsoLoginUrl(redirectUrl, loginType = "sso", idpId, action) {
+ let url = "/login/" + loginType + "/redirect";
+ if (idpId) {
+ url += "/" + idpId;
+ }
+ const params = {
+ redirectUrl,
+ [SSO_ACTION_PARAM.unstable]: action
+ };
+ return this.http.getUrl(url, params, _httpApi.ClientPrefix.R0).href;
+ }
+
+ /**
+ * @param token - Login token previously received from homeserver
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ loginWithToken(token) {
+ // 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 `{}`
+ */
+ async logout(stopClient = false) {
+ if (this.crypto?.backupManager?.getKeyBackupEnabled()) {
+ try {
+ while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0);
+ } catch (err) {
+ _logger.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(_httpApi.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
+ */
+ deactivateAccount(auth, erase) {
+ const body = {};
+ if (auth) {
+ body.auth = auth;
+ }
+ if (erase !== undefined) {
+ body.erase = erase;
+ }
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async requestLoginToken(auth) {
+ // use capabilities to determine which revision of the MSC is being used
+ const capabilities = await this.getCapabilities();
+ // use r1 endpoint if capability is exposed otherwise use old r0 endpoint
+ const endpoint = UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities) ? "/org.matrix.msc3882/login/get_token" // r1 endpoint
+ : "/org.matrix.msc3882/login/token"; // r0 endpoint
+
+ const body = {
+ auth
+ };
+ const res = await this.http.authedRequest(_httpApi.Method.Post, endpoint, undefined,
+ // no query params
+ body, {
+ prefix: _httpApi.ClientPrefix.Unstable
+ });
+
+ // the representation of expires_in changed from revision 0 to revision 1 so we populate
+ if ("login_token" in res) {
+ if (typeof res.expires_in_ms === "number") {
+ res.expires_in = Math.floor(res.expires_in_ms / 1000);
+ } else if (typeof res.expires_in === "number") {
+ res.expires_in_ms = res.expires_in * 1000;
+ }
+ }
+ return res;
+ }
+
+ /**
+ * 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
+ */
+ getFallbackAuthUrl(loginType, authSessionId) {
+ const path = utils.encodeUri("/auth/$loginType/fallback/web", {
+ $loginType: loginType
+ });
+ return this.http.getUrl(path, {
+ session: authSessionId
+ }, _httpApi.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.
+ */
+ async createRoom(options) {
+ // 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(_httpApi.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.
+ */
+ fetchRelations(roomId, eventId, relationType, eventType, opts = {
+ dir: _eventTimeline.Direction.Backward
+ }) {
+ let params = opts;
+ if (_thread.Thread.hasServerSideFwdPaginationSupport === _thread.FeatureSupport.Experimental) {
+ params = (0, utils.replaceParam)("dir", "org.matrix.msc3715.dir", params);
+ }
+ if (this.canSupport.get(_feature.Feature.RelationsRecursion) === _feature.ServerSupport.Unstable) {
+ params = (0, utils.replaceParam)("recurse", "org.matrix.msc3981.recurse", 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.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(_httpApi.Method.Get, path, undefined, undefined, {
+ prefix: _httpApi.ClientPrefix.V1
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ roomState(roomId) {
+ const path = utils.encodeUri("/rooms/$roomId/state", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ fetchRoomEvent(roomId, eventId) {
+ const path = utils.encodeUri("/rooms/$roomId/event/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ members(roomId, includeMembership, excludeMembership, atEventId) {
+ const queryParams = {};
+ 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(_httpApi.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.
+ */
+ upgradeRoom(roomId, newVersion) {
+ // eslint-disable-line camelcase
+ const path = utils.encodeUri("/rooms/$roomId/upgrade", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, {
+ new_version: newVersion
+ });
+ }
+
+ /**
+ * Retrieve a state event.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ getStateEvent(roomId, eventType, stateKey) {
+ 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(_httpApi.Method.Get, path);
+ }
+
+ /**
+ * @param opts - Options for the request function.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ sendStateEvent(roomId, eventType, content, stateKey = "", opts = {}) {
+ 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(_httpApi.Method.Put, path, undefined, content, opts);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ roomInitialSync(roomId, limit) {
+ const path = utils.encodeUri("/rooms/$roomId/initialSync", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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, `{}`.
+ */
+ async setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId) {
+ const path = utils.encodeUri("/rooms/$roomId/read_markers", {
+ $roomId: roomId
+ });
+ const content = {
+ [_read_receipts.ReceiptType.FullyRead]: rmEventId,
+ [_read_receipts.ReceiptType.Read]: rrEventId
+ };
+ if ((await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) || (await this.isVersionSupported("v1.4"))) {
+ content[_read_receipts.ReceiptType.ReadPrivate] = rpEventId;
+ }
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, content);
+ }
+
+ /**
+ * @returns Promise which resolves: A list of the user's current rooms
+ * @returns Rejects: with an error response.
+ */
+ getJoinedRooms() {
+ const path = utils.encodeUri("/joined_rooms", {});
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getJoinedRoomMembers(roomId) {
+ const path = utils.encodeUri("/rooms/$roomId/joined_members", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ publicRooms(_ref = {}) {
+ let {
+ server,
+ limit,
+ since
+ } = _ref,
+ options = _objectWithoutProperties(_ref, _excluded);
+ const queryParams = {
+ server,
+ limit,
+ since
+ };
+ if (Object.keys(options).length === 0) {
+ return this.http.authedRequest(_httpApi.Method.Get, "/publicRooms", queryParams);
+ } else {
+ return this.http.authedRequest(_httpApi.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.
+ */
+ createAlias(alias, roomId) {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias
+ });
+ const data = {
+ room_id: roomId
+ };
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deleteAlias(alias) {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getLocalAliases(roomId) {
+ const path = utils.encodeUri("/rooms/$roomId/aliases", {
+ $roomId: roomId
+ });
+ const prefix = _httpApi.ClientPrefix.V3;
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getRoomIdForAlias(alias) {
+ // eslint-disable-line camelcase
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias
+ });
+ return this.http.authedRequest(_httpApi.Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves: Object with room_id and servers.
+ * @returns Rejects: with an error response.
+ * @deprecated use `getRoomIdForAlias` instead
+ */
+ // eslint-disable-next-line camelcase
+ resolveRoomAlias(roomAlias) {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: roomAlias
+ });
+ return this.http.request(_httpApi.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.
+ */
+ getRoomDirectoryVisibility(roomId) {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setRoomDirectoryVisibility(roomId, visibility) {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setRoomDirectoryVisibilityAppService(networkId, roomId, visibility) {
+ // TODO: Types
+ const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
+ $networkId: networkId,
+ $roomId: roomId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ searchUserDirectory({
+ term,
+ limit
+ }) {
+ const body = {
+ search_term: term
+ };
+ if (limit !== undefined) {
+ body.limit = limit;
+ }
+ return this.http.authedRequest(_httpApi.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).
+ */
+ uploadContent(file, opts) {
+ 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
+ */
+ cancelUpload(upload) {
+ 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
+ */
+ getCurrentUploads() {
+ 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.
+ */
+ getProfileInfo(userId, info
+ // eslint-disable-next-line camelcase
+ ) {
+ const path = info ? utils.encodeUri("/profile/$userId/$info", {
+ $userId: userId,
+ $info: info
+ }) : utils.encodeUri("/profile/$userId", {
+ $userId: userId
+ });
+ return this.http.authedRequest(_httpApi.Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves to a list of the user's threepids.
+ * @returns Rejects: with an error response.
+ */
+ getThreePids() {
+ return this.http.authedRequest(_httpApi.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.
+ */
+ addThreePid(creds, bind) {
+ // TODO: Types
+ const path = "/account/3pid";
+ const data = {
+ threePidCreds: creds,
+ bind: bind
+ };
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async addThreePidOnly(data) {
+ const path = "/account/3pid/add";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable;
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async bindThreePid(data) {
+ const path = "/account/3pid/bind";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable;
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async unbindThreePid(medium, address
+ // eslint-disable-next-line camelcase
+ ) {
+ const path = "/account/3pid/unbind";
+ const data = {
+ medium,
+ address,
+ id_server: this.getIdentityServerUrl(true)
+ };
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? _httpApi.ClientPrefix.R0 : _httpApi.ClientPrefix.Unstable;
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deleteThreePid(medium, address
+ // eslint-disable-next-line camelcase
+ ) {
+ const path = "/account/3pid/delete";
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setPassword(authDict, newPassword, logoutDevices) {
+ const path = "/account/password";
+ const data = {
+ auth: authDict,
+ new_password: newPassword,
+ logout_devices: logoutDevices
+ };
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getDevices() {
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getDevice(deviceId) {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId
+ });
+ return this.http.authedRequest(_httpApi.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
+ setDeviceDetails(deviceId, body) {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deleteDevice(deviceId, auth) {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId
+ });
+ const body = {};
+ if (auth) {
+ body.auth = auth;
+ }
+ return this.http.authedRequest(_httpApi.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.
+ */
+ deleteMultipleDevices(devices, auth) {
+ const body = {
+ devices
+ };
+ if (auth) {
+ body.auth = auth;
+ }
+ const path = "/delete_devices";
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async getPushers() {
+ const response = await this.http.authedRequest(_httpApi.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(_event2.PUSHER_ENABLED.name)) {
+ pusher[_event2.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.
+ */
+ setPusher(pusher) {
+ const path = "/pushers/set";
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, pusher);
+ }
+
+ /**
+ * Removes an existing pusher
+ * @param pushKey - pushkey of pusher to remove
+ * @param appId - app_id of pusher to remove
+ * @returns Promise which resolves: Empty json object on success
+ * @returns Rejects: with an error response.
+ */
+ removePusher(pushKey, appId) {
+ const path = "/pushers/set";
+ const body = {
+ pushkey: pushKey,
+ app_id: appId,
+ kind: null // marks pusher for removal
+ };
+
+ return this.http.authedRequest(_httpApi.Method.Post, path, undefined, body);
+ }
+
+ /**
+ * Persists local notification settings
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ setLocalNotificationSettings(deviceId, notificationSettings) {
+ const key = `${_event2.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.
+ */
+ getPushRules() {
+ return this.http.authedRequest(_httpApi.Method.Get, "/pushrules/").then(rules => {
+ this.setPushRules(rules);
+ return this.pushRules;
+ });
+ }
+
+ /**
+ * Update the push rules for the account. This should be called whenever
+ * updated push rules are available.
+ */
+ setPushRules(rules) {
+ // Fix-up defaults, if applicable.
+ this.pushRules = _pushprocessor.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.
+ */
+ addPushRule(scope, kind, ruleId, body) {
+ // 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(_httpApi.Method.Put, path, undefined, body);
+ }
+
+ /**
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ deletePushRule(scope, kind, ruleId) {
+ // 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(_httpApi.Method.Delete, path);
+ }
+
+ /**
+ * Enable or disable a push notification rule.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ setPushRuleEnabled(scope, kind, ruleId, enabled) {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", {
+ $kind: kind,
+ $ruleId: ruleId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ setPushRuleActions(scope, kind, ruleId, actions) {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", {
+ $kind: kind,
+ $ruleId: ruleId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ search({
+ body,
+ next_batch: nextBatch
+ }, abortSignal) {
+ const queryParams = {};
+ if (nextBatch) {
+ queryParams.next_batch = nextBatch;
+ }
+ return this.http.authedRequest(_httpApi.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}).
+ */
+ uploadKeysRequest(content, opts) {
+ return this.http.authedRequest(_httpApi.Method.Post, "/keys/upload", undefined, content);
+ }
+ uploadKeySignatures(content) {
+ return this.http.authedRequest(_httpApi.Method.Post, "/keys/signatures/upload", undefined, content, {
+ prefix: _httpApi.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}).
+ */
+ downloadKeysForUsers(userIds, {
+ token
+ } = {}) {
+ const content = {
+ device_keys: {}
+ };
+ if (token !== undefined) {
+ content.token = token;
+ }
+ userIds.forEach(u => {
+ content.device_keys[u] = [];
+ });
+ return this.http.authedRequest(_httpApi.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}).
+ */
+ claimOneTimeKeys(devices, keyAlgorithm = "signed_curve25519", timeout) {
+ const queries = {};
+ if (keyAlgorithm === undefined) {
+ keyAlgorithm = "signed_curve25519";
+ }
+ for (const [userId, deviceId] of devices) {
+ const query = queries[userId] || {};
+ (0, utils.safeSet)(queries, userId, query);
+ (0, utils.safeSet)(query, deviceId, keyAlgorithm);
+ }
+ const content = {
+ one_time_keys: queries
+ };
+ if (timeout) {
+ content.timeout = timeout;
+ }
+ const path = "/keys/claim";
+ return this.http.authedRequest(_httpApi.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}).
+ */
+ getKeyChanges(oldToken, newToken) {
+ const qps = {
+ from: oldToken,
+ to: newToken
+ };
+ return this.http.authedRequest(_httpApi.Method.Get, "/keys/changes", qps);
+ }
+ uploadDeviceSigningKeys(auth, keys) {
+ // API returns empty object
+ const data = Object.assign({}, keys);
+ if (auth) Object.assign(data, {
+ auth
+ });
+ return this.http.authedRequest(_httpApi.Method.Post, "/keys/device_signing/upload", undefined, data, {
+ prefix: _httpApi.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.
+ */
+ registerWithIdentityServer(hsOpenIdToken) {
+ if (!this.idBaseUrl) {
+ throw new Error("No identity server base URL set");
+ }
+ const uri = this.http.getUrl("/account/register", undefined, _httpApi.IdentityPrefix.V2, this.idBaseUrl);
+ return this.http.requestOtherUrl(_httpApi.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
+ */
+ requestEmailToken(email, clientSecret, sendAttempt, nextLink, identityAccessToken) {
+ const params = {
+ client_secret: clientSecret,
+ email: email,
+ send_attempt: sendAttempt?.toString()
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+ return this.http.idServerRequest(_httpApi.Method.Post, "/validate/email/requestToken", params, _httpApi.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
+ */
+ requestMsisdnToken(phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink, identityAccessToken) {
+ const params = {
+ client_secret: clientSecret,
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ send_attempt: sendAttempt?.toString()
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+ return this.http.idServerRequest(_httpApi.Method.Post, "/validate/msisdn/requestToken", params, _httpApi.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, containing success boolean.
+ * @returns Rejects: with an error response.
+ * @throws Error if No identity server is set
+ */
+ submitMsisdnToken(sid, clientSecret, msisdnToken, identityAccessToken) {
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken
+ };
+ return this.http.idServerRequest(_httpApi.Method.Post, "/validate/msisdn/submitToken", params, _httpApi.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, containing success boolean.
+ * @returns Rejects: with an error response.
+ */
+ submitMsisdnTokenOtherUrl(url, sid, clientSecret, msisdnToken) {
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken
+ };
+ return this.http.requestOtherUrl(_httpApi.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.
+ */
+ getIdentityHashDetails(identityAccessToken) {
+ // TODO: Types
+ return this.http.idServerRequest(_httpApi.Method.Get, "/hash_details", undefined, _httpApi.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.
+ */
+ async identityHashedLookup(addressPairs, identityAccessToken) {
+ const params = {
+ // 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 = {
+ // 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(_httpApi.Method.Post, "/lookup", params, _httpApi.IdentityPrefix.V2, identityAccessToken);
+ if (!response?.["mappings"]) return []; // no results
+
+ const foundAddresses = [];
+ 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.
+ */
+ async lookupThreePid(medium, address, identityAccessToken) {
+ // 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.
+ */
+ async bulkLookupThreePids(query, identityAccessToken) {
+ // 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 = [];
+ 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.
+ */
+ getIdentityAccount(identityAccessToken) {
+ // TODO: Types
+ return this.http.idServerRequest(_httpApi.Method.Get, "/account", undefined, _httpApi.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 `{}`
+ */
+ sendToDevice(eventType, contentMap, txnId) {
+ const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
+ $eventType: eventType,
+ $txnId: txnId ? txnId : this.makeTxnId()
+ });
+ const body = {
+ messages: utils.recursiveMapToObject(contentMap)
+ };
+ const targets = new Map();
+ for (const [userId, deviceMessages] of contentMap) {
+ targets.set(userId, Array.from(deviceMessages.keys()));
+ }
+ _logger.logger.log(`PUT ${path}`, targets);
+ return this.http.authedRequest(_httpApi.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
+ */
+ queueToDevice(batch) {
+ 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
+ */
+ getThirdpartyProtocols() {
+ return this.http.authedRequest(_httpApi.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
+ */
+ getThirdpartyLocation(protocol, params) {
+ const path = utils.encodeUri("/thirdparty/location/$protocol", {
+ $protocol: protocol
+ });
+ return this.http.authedRequest(_httpApi.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
+ */
+ getThirdpartyUser(protocol, params) {
+ // TODO: Types
+ const path = utils.encodeUri("/thirdparty/user/$protocol", {
+ $protocol: protocol
+ });
+ return this.http.authedRequest(_httpApi.Method.Get, path, params);
+ }
+ getTerms(serviceType, baseUrl) {
+ // TODO: Types
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ return this.http.requestOtherUrl(_httpApi.Method.Get, url);
+ }
+ agreeToTerms(serviceType, baseUrl, accessToken, termsUrls) {
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ const headers = {
+ Authorization: "Bearer " + accessToken
+ };
+ return this.http.requestOtherUrl(_httpApi.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
+ */
+ reportEvent(roomId, eventId, score, reason) {
+ const path = utils.encodeUri("/rooms/$roomId/report/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId
+ });
+ return this.http.authedRequest(_httpApi.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.
+ */
+ getRoomHierarchy(roomId, limit, maxDepth, suggestedOnly = false, fromToken) {
+ const path = utils.encodeUri("/rooms/$roomId/hierarchy", {
+ $roomId: roomId
+ });
+ const queryParams = {
+ suggested_only: String(suggestedOnly),
+ max_depth: maxDepth?.toString(),
+ from: fromToken,
+ limit: limit?.toString()
+ };
+ return this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, {
+ prefix: _httpApi.ClientPrefix.V1
+ }).catch(e => {
+ if (e.errcode === "M_UNRECOGNIZED") {
+ // fall back to the prefixed hierarchy API.
+ return this.http.authedRequest(_httpApi.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.
+ */
+ async unstableCreateFileTree(name) {
+ const {
+ room_id: roomId
+ } = await this.createRoom({
+ name: name,
+ preset: _partials.Preset.PrivateChat,
+ power_level_content_override: _objectSpread(_objectSpread({}, _MSC3089TreeSpace.DEFAULT_TREE_POWER_LEVELS_TEMPLATE), {}, {
+ users: {
+ [this.getUserId()]: 100
+ }
+ }),
+ creation_content: {
+ [_event2.RoomCreateTypeField]: _event2.RoomType.Space
+ },
+ initial_state: [{
+ type: _event2.UNSTABLE_MSC3088_PURPOSE.name,
+ state_key: _event2.UNSTABLE_MSC3089_TREE_SUBTYPE.name,
+ content: {
+ [_event2.UNSTABLE_MSC3088_ENABLED.name]: true
+ }
+ }, {
+ type: _event2.EventType.RoomEncryption,
+ state_key: "",
+ content: {
+ algorithm: olmlib.MEGOLM_ALGORITHM
+ }
+ }]
+ });
+ return new _MSC3089TreeSpace.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.
+ */
+ unstableGetFileTreeSpace(roomId) {
+ const room = this.getRoom(roomId);
+ if (room?.getMyMembership() !== "join") return null;
+ const createEvent = room.currentState.getStateEvents(_event2.EventType.RoomCreate, "");
+ const purposeEvent = room.currentState.getStateEvents(_event2.UNSTABLE_MSC3088_PURPOSE.name, _event2.UNSTABLE_MSC3089_TREE_SUBTYPE.name);
+ if (!createEvent) throw new Error("Expected single room create event");
+ if (!purposeEvent?.getContent()?.[_event2.UNSTABLE_MSC3088_ENABLED.name]) return null;
+ if (createEvent.getContent()?.[_event2.RoomCreateTypeField] !== _event2.RoomType.Space) return null;
+ return new _MSC3089TreeSpace.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.
+ */
+ slidingSync(req, proxyBaseUrl, abortSignal) {
+ const qps = {};
+ 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(_httpApi.Method.Post, "/sync", qps, req, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3575",
+ baseUrl: proxyBaseUrl,
+ localTimeoutMs: clientTimeout,
+ abortSignal
+ });
+ }
+
+ /**
+ * @deprecated use supportsThreads() instead
+ */
+ supportsExperimentalThreads() {
+ _logger.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
+ */
+ supportsThreads() {
+ return this.clientOpts?.threadSupport || false;
+ }
+
+ /**
+ * A helper to determine intentional mentions support
+ * @returns a boolean to determine if intentional mentions are enabled
+ * @experimental
+ */
+ supportsIntentionalMentions() {
+ 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.
+ */
+ async getRoomSummary(roomIdOrAlias, via) {
+ const path = utils.encodeUri("/rooms/$roomid/summary", {
+ $roomid: roomIdOrAlias
+ });
+ return this.http.authedRequest(_httpApi.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
+ */
+ processThreadEvents(room, threadedEvents, toStartOfTimeline) {
+ 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
+ */
+ processThreadRoots(room, threadedEvents, toStartOfTimeline) {
+ room.processThreadRoots(threadedEvents, toStartOfTimeline);
+ }
+ processBeaconEvents(room, events) {
+ 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
+ */
+ processAggregatedTimelineEvents(room, events) {
+ 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.
+ */
+ async whoami() {
+ return this.http.authedRequest(_httpApi.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)
+ */
+ async timestampToEvent(roomId, timestamp, dir) {
+ const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
+ $roomId: roomId
+ });
+ const queryParams = {
+ ts: timestamp.toString(),
+ dir: dir
+ };
+ try {
+ return await this.http.authedRequest(_httpApi.Method.Get, path, queryParams, undefined, {
+ prefix: _httpApi.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 (err.errcode === "M_UNRECOGNIZED" && (
+ // XXX: The 400 status code check should be removed in the future
+ // when Synapse is compliant with MSC3743.
+ 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.
+ err.httpStatus === 404 || err.httpStatus === 405)) {
+ return await this.http.authedRequest(_httpApi.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
+ */
+exports.MatrixClient = MatrixClient;
+_defineProperty(MatrixClient, "RESTORE_BACKUP_ERROR_BAD_KEY", "RESTORE_BACKUP_ERROR_BAD_KEY");
+function fixNotificationCountOnDecryption(cli, event) {
+ 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(_room.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, _room.NotificationCountType.Highlight, newCount);
+ } else {
+ room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, newCount);
+ }
+ }
+
+ // Total count is used to typically increment a room notification counter, but not loudly highlight it.
+ const currentTotalCount = room.getUnreadCountForEventContext(_room.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, _room.NotificationCountType.Total, currentTotalCount + 1);
+ } else {
+ room.setUnreadNotificationCount(_room.NotificationCountType.Total, currentTotalCount + 1);
+ }
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js b/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js
new file mode 100644
index 0000000000..0ca2390b43
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js
@@ -0,0 +1,266 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.makeBeaconInfoContent = exports.makeBeaconContent = exports.getTextForLocationEvent = void 0;
+exports.makeEmoteMessage = makeEmoteMessage;
+exports.makeHtmlEmote = makeHtmlEmote;
+exports.makeHtmlMessage = makeHtmlMessage;
+exports.makeHtmlNotice = makeHtmlNotice;
+exports.makeLocationContent = void 0;
+exports.makeNotice = makeNotice;
+exports.makeTextMessage = makeTextMessage;
+exports.parseTopicContent = exports.parseLocationEvent = exports.parseBeaconInfoContent = exports.parseBeaconContent = exports.makeTopicContent = void 0;
+var _event = require("./@types/event");
+var _extensible_events = require("./@types/extensible_events");
+var _utilities = require("./extensible_events_v1/utilities");
+var _location = require("./@types/location");
+var _topic = require("./@types/topic");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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
+ */
+function makeHtmlMessage(body, htmlBody) {
+ return {
+ msgtype: _event.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
+ */
+function makeHtmlNotice(body, htmlBody) {
+ return {
+ msgtype: _event.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
+ */
+function makeHtmlEmote(body, htmlBody) {
+ return {
+ msgtype: _event.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
+ */
+function makeTextMessage(body) {
+ return {
+ msgtype: _event.MsgType.Text,
+ body: body
+ };
+}
+
+/**
+ * Generates the content for a Plaintext Notice event
+ * @param body - the plaintext body of the notice
+ * @returns
+ */
+function makeNotice(body) {
+ return {
+ msgtype: _event.MsgType.Notice,
+ body: body
+ };
+}
+
+/**
+ * Generates the content for a Plaintext Emote event
+ * @param body - the plaintext body of the emote
+ * @returns
+ */
+function makeEmoteMessage(body) {
+ return {
+ msgtype: _event.MsgType.Emote,
+ body: body
+ };
+}
+
+/** Location content helpers */
+
+const getTextForLocationEvent = (uri, assetType, timestamp, description) => {
+ const date = `at ${new Date(timestamp).toISOString()}`;
+ const assetName = assetType === _location.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
+ */
+exports.getTextForLocationEvent = getTextForLocationEvent;
+const makeLocationContent = (text, uri, timestamp, description, assetType) => {
+ const defaultedText = text ?? getTextForLocationEvent(uri, assetType || _location.LocationAssetType.Self, timestamp, description);
+ const timestampEvent = timestamp ? {
+ [_location.M_TIMESTAMP.name]: timestamp
+ } : {};
+ return _objectSpread({
+ msgtype: _event.MsgType.Location,
+ body: defaultedText,
+ geo_uri: uri,
+ [_location.M_LOCATION.name]: {
+ description,
+ uri
+ },
+ [_location.M_ASSET.name]: {
+ type: assetType || _location.LocationAssetType.Self
+ },
+ [_extensible_events.M_TEXT.name]: defaultedText
+ }, timestampEvent);
+};
+
+/**
+ * Parse location event content and transform to
+ * a backwards compatible modern m.location event format
+ */
+exports.makeLocationContent = makeLocationContent;
+const parseLocationEvent = wireEventContent => {
+ const location = _location.M_LOCATION.findIn(wireEventContent);
+ const asset = _location.M_ASSET.findIn(wireEventContent);
+ const timestamp = _location.M_TIMESTAMP.findIn(wireEventContent);
+ const text = _extensible_events.M_TEXT.findIn(wireEventContent);
+ const geoUri = location?.uri ?? wireEventContent?.geo_uri;
+ const description = location?.description;
+ const assetType = asset?.type ?? _location.LocationAssetType.Self;
+ const fallbackText = text ?? wireEventContent.body;
+ return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType);
+};
+
+/**
+ * Topic event helpers
+ */
+exports.parseLocationEvent = parseLocationEvent;
+const makeTopicContent = (topic, htmlTopic) => {
+ const renderings = [{
+ body: topic,
+ mimetype: "text/plain"
+ }];
+ if ((0, _utilities.isProvided)(htmlTopic)) {
+ renderings.push({
+ body: htmlTopic,
+ mimetype: "text/html"
+ });
+ }
+ return {
+ topic,
+ [_topic.M_TOPIC.name]: renderings
+ };
+};
+exports.makeTopicContent = makeTopicContent;
+const parseTopicContent = content => {
+ const mtopic = _topic.M_TOPIC.findIn(content);
+ if (!Array.isArray(mtopic)) {
+ return {
+ text: content.topic
+ };
+ }
+ const text = mtopic?.find(r => !(0, _utilities.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
+ */
+exports.parseTopicContent = parseTopicContent;
+const makeBeaconInfoContent = (timeout, isLive, description, assetType, timestamp) => ({
+ description,
+ timeout,
+ live: isLive,
+ [_location.M_TIMESTAMP.name]: timestamp || Date.now(),
+ [_location.M_ASSET.name]: {
+ type: assetType ?? _location.LocationAssetType.Self
+ }
+});
+exports.makeBeaconInfoContent = makeBeaconInfoContent;
+/**
+ * Flatten beacon info event content
+ */
+const parseBeaconInfoContent = content => {
+ const {
+ description,
+ timeout,
+ live
+ } = content;
+ const timestamp = _location.M_TIMESTAMP.findIn(content) ?? undefined;
+ const asset = _location.M_ASSET.findIn(content);
+ return {
+ description,
+ timeout,
+ live,
+ assetType: asset?.type,
+ timestamp
+ };
+};
+exports.parseBeaconInfoContent = parseBeaconInfoContent;
+const makeBeaconContent = (uri, timestamp, beaconInfoEventId, description) => ({
+ [_location.M_LOCATION.name]: {
+ description,
+ uri
+ },
+ [_location.M_TIMESTAMP.name]: timestamp,
+ "m.relates_to": {
+ rel_type: _extensible_events.REFERENCE_RELATION.name,
+ event_id: beaconInfoEventId
+ }
+});
+exports.makeBeaconContent = makeBeaconContent;
+const parseBeaconContent = content => {
+ const location = _location.M_LOCATION.findIn(content);
+ const timestamp = _location.M_TIMESTAMP.findIn(content) ?? undefined;
+ return {
+ description: location?.description,
+ uri: location?.uri,
+ timestamp
+ };
+};
+exports.parseBeaconContent = parseBeaconContent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js b/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js
new file mode 100644
index 0000000000..2e17f8c71c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js
@@ -0,0 +1,74 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.getHttpUriForMxc = getHttpUriForMxc;
+var _utils = require("./utils");
+/*
+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.
+*/
+
+/**
+ * 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.
+ */
+function getHttpUriForMxc(baseUrl, mxc, width, height, resizeMethod, allowDirectLinks = false) {
+ 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 = {};
+ 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 ? "" : "?" + (0, _utils.encodeParams)(params);
+ return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js
new file mode 100644
index 0000000000..cb1d0427e9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js
@@ -0,0 +1,105 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _exportNames = {
+ CrossSigningKey: true,
+ DeviceVerificationStatus: true
+};
+exports.DeviceVerificationStatus = exports.CrossSigningKey = void 0;
+var _verification = require("./crypto-api/verification");
+Object.keys(_verification).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _verification[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _verification[key];
+ }
+ });
+});
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+/** Types of cross-signing key */
+let CrossSigningKey = /*#__PURE__*/function (CrossSigningKey) {
+ CrossSigningKey["Master"] = "master";
+ CrossSigningKey["SelfSigning"] = "self_signing";
+ CrossSigningKey["UserSigning"] = "user_signing";
+ return CrossSigningKey;
+}({});
+/**
+ * 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.
+ */
+/**
+ * Options object for `CryptoApi.bootstrapCrossSigning`.
+ */
+exports.CrossSigningKey = CrossSigningKey;
+class DeviceVerificationStatus {
+ constructor(opts) {
+ /**
+ * True if this device has been signed by its owner (and that signature verified).
+ *
+ * This doesn't necessarily mean that we have verified the device, since we may not have verified the
+ * owner's cross-signing key.
+ */
+ _defineProperty(this, "signedByOwner", void 0);
+ /**
+ * True if this device has been verified via cross signing.
+ *
+ * This does *not* take into account `trustCrossSignedDevices`.
+ */
+ _defineProperty(this, "crossSigningVerified", void 0);
+ /**
+ * TODO: tofu magic wtf does this do?
+ */
+ _defineProperty(this, "tofu", void 0);
+ /**
+ * True if the device has been marked as locally verified.
+ */
+ _defineProperty(this, "localVerified", void 0);
+ /**
+ * True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
+ */
+ _defineProperty(this, "trustCrossSignedDevices", void 0);
+ this.signedByOwner = opts.signedByOwner ?? false;
+ this.crossSigningVerified = opts.crossSigningVerified ?? false;
+ this.tofu = opts.tofu ?? false;
+ this.localVerified = opts.localVerified ?? false;
+ this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false;
+ }
+
+ /**
+ * Check if we should consider this device "verified".
+ *
+ * A device is "verified" if either:
+ * * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
+ * * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
+ * cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
+ *
+ * @returns true if this device is verified via any means.
+ */
+ isVerified() {
+ return this.localVerified || this.trustCrossSignedDevices && this.crossSigningVerified;
+ }
+}
+exports.DeviceVerificationStatus = DeviceVerificationStatus; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js
new file mode 100644
index 0000000000..0f1f97e7ef
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js
@@ -0,0 +1,46 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.VerifierEvent = void 0;
+/*
+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.
+*/
+/** Events emitted by `Verifier`. */
+let VerifierEvent = /*#__PURE__*/function (VerifierEvent) {
+ VerifierEvent["Cancel"] = "cancel";
+ VerifierEvent["ShowSas"] = "show_sas";
+ VerifierEvent["ShowReciprocateQr"] = "show_reciprocate_qr";
+ return VerifierEvent;
+}({});
+/** Listener type map for {@link VerifierEvent}s. */
+/**
+ * Callbacks for user actions while a QR code is displayed.
+ *
+ * This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
+ * verifier as `reciprocateQREvent`.
+ */
+/**
+ * Callbacks for user actions while a SAS is displayed.
+ *
+ * This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`.
+ */
+/** A generated SAS to be shown to the user, in alternative formats */
+/**
+ * An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the
+ * English name.
+ */
+exports.VerifierEvent = VerifierEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js
new file mode 100644
index 0000000000..be8c9607f4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js
@@ -0,0 +1,703 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UserTrustLevel = exports.DeviceTrustLevel = exports.CrossSigningLevel = exports.CrossSigningInfo = void 0;
+exports.createCryptoStoreCacheCallbacks = createCryptoStoreCacheCallbacks;
+exports.requestKeysDuringVerification = requestKeysDuringVerification;
+var _olmlib = require("./olmlib");
+var _logger = require("../logger");
+var _indexeddbCryptoStore = require("../crypto/store/indexeddb-crypto-store");
+var _aes = require("./aes");
+var _cryptoApi = require("../crypto-api");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
+function publicKeyFromKeyInfo(keyInfo) {
+ // `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];
+}
+class CrossSigningInfo {
+ /**
+ * 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
+ */
+ constructor(userId, callbacks = {}, cacheCallbacks = {}) {
+ this.userId = userId;
+ this.callbacks = callbacks;
+ this.cacheCallbacks = cacheCallbacks;
+ _defineProperty(this, "keys", {});
+ _defineProperty(this, "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.
+ _defineProperty(this, "crossSigningVerifiedBefore", false);
+ }
+ static fromStorage(obj, userId) {
+ 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;
+ }
+ toStorage() {
+ 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 ]
+ */
+ async getCrossSigningKey(type, expectedPubkey) {
+ 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) {
+ 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 = 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
+ */
+ async isStoredInSecretStorage(secretStorage) {
+ // 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) {
+ 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
+ */
+ static async storeInSecretStorage(keys, secretStorage) {
+ for (const [type, privateKey] of keys) {
+ const encodedKey = (0, _olmlib.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
+ */
+ static async getFromSecretStorage(type, secretStorage) {
+ const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
+ if (!encodedKey) {
+ return null;
+ }
+ return (0, _olmlib.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.
+ */
+ async isStoredInKeyCache(type) {
+ 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)
+ */
+ async getCrossSigningKeysFromCache() {
+ 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
+ */
+ getId(type = "master") {
+ 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
+ */
+ async resetKeys(level) {
+ 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) {
+ return;
+ }
+ const privateKeys = {};
+ const keys = {};
+ 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
+ }
+ };
+ (0, _olmlib.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
+ }
+ };
+ (0, _olmlib.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
+ */
+ clearKeys() {
+ this.keys = {};
+ }
+ setKeys(keys) {
+ const signingKeys = {};
+ 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.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.logger.error(error);
+ throw new Error(error);
+ }
+ try {
+ (0, _olmlib.pkVerify)(keys.user_signing, masterKey, this.userId);
+ } catch (e) {
+ _logger.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.logger.error(error);
+ throw new Error(error);
+ }
+ try {
+ (0, _olmlib.pkVerify)(keys.self_signing, masterKey, this.userId);
+ } catch (e) {
+ _logger.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;
+ }
+ }
+ updateCrossSigningVerifiedBefore(isCrossSigningVerified) {
+ // 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;
+ }
+ }
+ async signObject(data, type) {
+ 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 {
+ (0, _olmlib.pkSign)(data, signing, this.userId, pubkey);
+ return data;
+ } finally {
+ signing.free();
+ }
+ }
+ async signUser(key) {
+ if (!this.keys.user_signing) {
+ _logger.logger.info("No user signing key: not signing user");
+ return;
+ }
+ return this.signObject(key.keys.master, "user_signing");
+ }
+ async signDevice(userId, device) {
+ 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.logger.info("No self signing key: not signing device");
+ return;
+ }
+ return this.signObject({
+ 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
+ */
+ checkUserTrust(userCrossSigning) {
+ // 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;
+ const userMaster = userCrossSigning.keys.master;
+ const uskId = this.getId("user_signing");
+ try {
+ (0, _olmlib.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
+ */
+ checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) {
+ 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...
+ (0, _olmlib.pkVerify)(userSSK, userCrossSigning.getId(), userCrossSigning.userId);
+ // ...and this device's key from their SSK...
+ (0, _olmlib.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
+ */
+ getCacheCallbacks() {
+ return this.cacheCallbacks;
+ }
+}
+exports.CrossSigningInfo = CrossSigningInfo;
+function deviceToObject(device, userId) {
+ return {
+ algorithms: device.algorithms,
+ keys: device.keys,
+ device_id: device.deviceId,
+ user_id: userId,
+ signatures: device.signatures
+ };
+}
+let CrossSigningLevel = /*#__PURE__*/function (CrossSigningLevel) {
+ CrossSigningLevel[CrossSigningLevel["MASTER"] = 4] = "MASTER";
+ CrossSigningLevel[CrossSigningLevel["USER_SIGNING"] = 2] = "USER_SIGNING";
+ CrossSigningLevel[CrossSigningLevel["SELF_SIGNING"] = 1] = "SELF_SIGNING";
+ return CrossSigningLevel;
+}({});
+/**
+ * Represents the ways in which we trust a user
+ */
+exports.CrossSigningLevel = CrossSigningLevel;
+class UserTrustLevel {
+ constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) {
+ this.crossSigningVerified = crossSigningVerified;
+ this.crossSigningVerifiedBefore = crossSigningVerifiedBefore;
+ this.tofu = tofu;
+ }
+
+ /**
+ * @returns true if this user is verified via any means
+ */
+ isVerified() {
+ return this.isCrossSigningVerified();
+ }
+
+ /**
+ * @returns true if this user is verified via cross signing
+ */
+ isCrossSigningVerified() {
+ return this.crossSigningVerified;
+ }
+
+ /**
+ * @returns true if we ever verified this user before (at least for
+ * the history of verifications observed by this device).
+ */
+ wasCrossSigningVerified() {
+ return this.crossSigningVerifiedBefore;
+ }
+
+ /**
+ * @returns true if this user's key is trusted on first use
+ */
+ isTofu() {
+ return this.tofu;
+ }
+}
+
+/**
+ * Represents the ways in which we trust a device.
+ *
+ * @deprecated Use {@link DeviceVerificationStatus}.
+ */
+exports.UserTrustLevel = UserTrustLevel;
+class DeviceTrustLevel extends _cryptoApi.DeviceVerificationStatus {
+ constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner = false) {
+ super({
+ crossSigningVerified,
+ tofu,
+ localVerified,
+ trustCrossSignedDevices,
+ signedByOwner
+ });
+ }
+ static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) {
+ return new DeviceTrustLevel(userTrustLevel.isCrossSigningVerified(), userTrustLevel.isTofu(), localVerified, trustCrossSignedDevices, true);
+ }
+
+ /**
+ * @returns true if this device is verified via cross signing
+ */
+ isCrossSigningVerified() {
+ return this.crossSigningVerified;
+ }
+
+ /**
+ * @returns true if this device is verified locally
+ */
+ isLocallyVerified() {
+ return this.localVerified;
+ }
+
+ /**
+ * @returns true if this device is trusted from a user's key
+ * that is trusted on first use
+ */
+ isTofu() {
+ return this.tofu;
+ }
+}
+exports.DeviceTrustLevel = DeviceTrustLevel;
+function createCryptoStoreCacheCallbacks(store, olmDevice) {
+ return {
+ getCrossSigningKeyCache: async function (type, _expectedPublicKey) {
+ const key = await new Promise(resolve => {
+ store.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ store.getSecretStorePrivateKey(txn, resolve, type);
+ });
+ });
+ if (key && key.ciphertext) {
+ const pickleKey = Buffer.from(olmDevice.pickleKey);
+ const decrypted = await (0, _aes.decryptAES)(key, pickleKey, type);
+ return (0, _olmlib.decodeBase64)(decrypted);
+ } else {
+ return key;
+ }
+ },
+ storeCrossSigningKeyCache: async function (type, key) {
+ if (!(key instanceof Uint8Array)) {
+ throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`);
+ }
+ const pickleKey = Buffer.from(olmDevice.pickleKey);
+ const encryptedKey = await (0, _aes.encryptAES)((0, _olmlib.encodeBase64)(key), pickleKey, type);
+ return store.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ store.storeSecretStorePrivateKey(txn, type, encryptedKey);
+ });
+ }
+ };
+}
+/**
+ * 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
+ */
+async function requestKeysDuringVerification(baseApis, userId, deviceId) {
+ // If this is a self-verification, ask the other party for keys
+ if (baseApis.getUserId() !== userId) {
+ return;
+ }
+ _logger.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((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 => {
+ _logger.logger.debug("Cross-signing: requesting secret", type, deviceId);
+ const {
+ promise
+ } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]);
+ const result = await promise;
+ const decoded = (0, _olmlib.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(resolve => {
+ setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout"));
+ });
+
+ // also request and cache the key backup key
+ const backupKeyPromise = (async () => {
+ const cachedKey = await client.crypto.getSessionBackupPrivateKey();
+ if (!cachedKey) {
+ _logger.logger.info("No cached backup key found. Requesting...");
+ const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]);
+ const base64Key = await secretReq.promise;
+ _logger.logger.info("Got key backup key, decoding...");
+ const decodedKey = (0, _olmlib.decodeBase64)(base64Key);
+ _logger.logger.info("Decoded backup key, storing...");
+ await client.crypto.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey));
+ _logger.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.logger.info("Backup restored.");
+ });
+ }
+ })();
+
+ // We call getCrossSigningKey() for its side-effects
+ Promise.race([Promise.all([crossSigning.getCrossSigningKey("master"), crossSigning.getCrossSigningKey("self_signing"), crossSigning.getCrossSigningKey("user_signing"), backupKeyPromise]), timeout]).then(resolve, reject);
+ }).catch(e => {
+ _logger.logger.warn("Cross-signing: failure while requesting keys:", e);
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js
new file mode 100644
index 0000000000..31b8537428
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js
@@ -0,0 +1,860 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TrackingStatus = exports.DeviceList = void 0;
+var _logger = require("../logger");
+var _deviceinfo = require("./deviceinfo");
+var _CrossSigning = require("./CrossSigning");
+var olmlib = _interopRequireWildcard(require("./olmlib"));
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+var _utils = require("../utils");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _index = require("./index");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+/* 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
+let TrackingStatus = /*#__PURE__*/function (TrackingStatus) {
+ TrackingStatus[TrackingStatus["NotTracked"] = 0] = "NotTracked";
+ TrackingStatus[TrackingStatus["PendingDownload"] = 1] = "PendingDownload";
+ TrackingStatus[TrackingStatus["DownloadInProgress"] = 2] = "DownloadInProgress";
+ TrackingStatus[TrackingStatus["UpToDate"] = 3] = "UpToDate";
+ return TrackingStatus;
+}({}); // user-Id → device-Id → DeviceInfo
+exports.TrackingStatus = TrackingStatus;
+class DeviceList extends _typedEventEmitter.TypedEventEmitter {
+ constructor(baseApis, cryptoStore, olmDevice,
+ // Maximum number of user IDs per request to prevent server overload (#1619)
+ keyDownloadChunkSize = 250) {
+ super();
+ this.cryptoStore = cryptoStore;
+ this.keyDownloadChunkSize = keyDownloadChunkSize;
+ _defineProperty(this, "devices", {});
+ _defineProperty(this, "crossSigningInfo", {});
+ // map of identity keys to the user who owns it
+ _defineProperty(this, "userByIdentityKey", {});
+ // which users we are tracking device status for.
+ _defineProperty(this, "deviceTrackingStatus", {});
+ // 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.
+ _defineProperty(this, "syncToken", null);
+ _defineProperty(this, "keyDownloadsInProgressByUser", new Map());
+ // Set whenever changes are made other than setting the sync token
+ _defineProperty(this, "dirty", false);
+ // Promise resolved when device data is saved
+ _defineProperty(this, "savePromise", null);
+ // Function that resolves the save promise
+ _defineProperty(this, "resolveSavePromise", null);
+ // The time the save is scheduled for
+ _defineProperty(this, "savePromiseTime", null);
+ // The timer used to delay the save
+ _defineProperty(this, "saveTimer", null);
+ // True if we have fetched data from the server or loaded a non-empty
+ // set of device data from the store
+ _defineProperty(this, "hasFetched", null);
+ _defineProperty(this, "serialiser", void 0);
+ this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this);
+ }
+
+ /**
+ * Load the device tracking state from storage
+ */
+ async load() {
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_DEVICE_DATA], txn => {
+ this.cryptoStore.getEndToEndDeviceData(txn, deviceData => {
+ this.hasFetched = Boolean(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;
+ }
+ }
+ }
+ stop() {
+ 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.
+ */
+ async saveIfDirty(delay = 500) {
+ 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.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.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.logger.error("Failed to save device tracking data", this.syncToken);
+ _logger.logger.error(err);
+ });
+ }, delay);
+ }
+ return savePromise;
+ }
+
+ /**
+ * Gets the sync token last set with setSyncToken
+ *
+ * @returns The sync token
+ */
+ getSyncToken() {
+ 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
+ */
+ setSyncToken(st) {
+ 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}.
+ */
+ downloadKeys(userIds, forceDownload) {
+ const usersToDownload = [];
+ const promises = [];
+ 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.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.logger.log("downloadKeys: downloading for", usersToDownload);
+ const downloadPromise = this.doKeyDownload(usersToDownload);
+ promises.push(downloadPromise);
+ }
+ if (promises.length === 0) {
+ _logger.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}.
+ */
+ getDevicesFromStore(userIds) {
+ const stored = 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
+ */
+ getKnownUserIds() {
+ 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.
+ */
+ getStoredDevicesForUser(userId) {
+ const devs = this.devices[userId];
+ if (!devs) {
+ return null;
+ }
+ const res = [];
+ for (const deviceId in devs) {
+ if (devs.hasOwnProperty(deviceId)) {
+ res.push(_deviceinfo.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.
+ */
+ getRawStoredDevicesForUser(userId) {
+ return this.devices[userId];
+ }
+ getStoredCrossSigningForUser(userId) {
+ if (!this.crossSigningInfo[userId]) return null;
+ return _CrossSigning.CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId);
+ }
+ storeCrossSigningForUser(userId, info) {
+ 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
+ */
+ getStoredDevice(userId, deviceId) {
+ const devs = this.devices[userId];
+ if (!devs?.[deviceId]) {
+ return undefined;
+ }
+ return _deviceinfo.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
+ */
+ getUserByIdentityKey(algorithm, senderKey) {
+ 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
+ */
+ getDeviceByIdentityKey(algorithm, senderKey) {
+ 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.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
+ */
+ storeDevicesForUser(userId, devices) {
+ 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.
+ *
+ */
+ startTrackingDeviceList(userId) {
+ // 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.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.
+ *
+ */
+ stopTrackingDeviceList(userId) {
+ if (this.deviceTrackingStatus[userId]) {
+ _logger.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.
+ */
+ stopTrackingAllDeviceLists() {
+ 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.
+ *
+ */
+ invalidateUserDeviceList(userId) {
+ if (this.deviceTrackingStatus[userId]) {
+ _logger.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).
+ */
+ refreshOutdatedDeviceLists() {
+ this.saveIfDirty();
+ const usersToDownload = [];
+ 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
+ */
+ setRawStoredDevicesForUser(userId, devices) {
+ // 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;
+ }
+ }
+ setRawStoredCrossSigningForUser(userId, info) {
+ 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.
+ */
+ doKeyDownload(users) {
+ if (users.length === 0) {
+ // nothing to do
+ return Promise.resolve();
+ }
+ const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken).then(() => {
+ finished(true);
+ }, e => {
+ _logger.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 => {
+ this.emit(_index.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.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.logger.log("Device list for", u, "now up to date");
+ } else {
+ this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload;
+ }
+ }
+ });
+ this.saveIfDirty();
+ this.emit(_index.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).
+ */
+exports.DeviceList = DeviceList;
+class DeviceListUpdateSerialiser {
+ // 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
+ */
+ constructor(baseApis, olmDevice, deviceList) {
+ this.baseApis = baseApis;
+ this.olmDevice = olmDevice;
+ this.deviceList = deviceList;
+ _defineProperty(this, "downloadInProgress", false);
+ // users which are queued for download
+ // userId -> true
+ _defineProperty(this, "keyDownloadsQueuedByUser", {});
+ // deferred which is resolved when the queued users are downloaded.
+ // non-null indicates that we have users queued for download.
+ _defineProperty(this, "queuedQueryDeferred", void 0);
+ _defineProperty(this, "syncToken", void 0);
+ }
+
+ /**
+ * 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.
+ */
+ updateDevicesForUsers(users, syncToken) {
+ users.forEach(u => {
+ this.keyDownloadsQueuedByUser[u] = true;
+ });
+ if (!this.queuedQueryDeferred) {
+ this.queuedQueryDeferred = (0, _utils.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.logger.log("Queued key download for", users);
+ return this.queuedQueryDeferred.promise;
+ }
+
+ // start a new download.
+ return this.doQueuedQueries();
+ }
+ doQueuedQueries() {
+ 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.logger.log("Starting key download for", downloadUsers);
+ this.downloadInProgress = true;
+ const opts = {};
+ if (this.syncToken) {
+ opts.token = this.syncToken;
+ }
+ const factories = [];
+ 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));
+ }
+ (0, _utils.chunkPromises)(factories, 3).then(async responses => {
+ const dk = Object.assign({}, ...responses.map(res => res.device_keys || {}));
+ const masterKeys = Object.assign({}, ...responses.map(res => res.master_keys || {}));
+ const ssks = Object.assign({}, ...responses.map(res => res.self_signing_keys || {}));
+ const usks = 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 (0, _utils.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.logger.error(`Error processing keys for ${userId}:`, e);
+ }
+ }
+ }).then(() => {
+ _logger.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.logger.warn("Error downloading keys for " + downloadUsers + ":", e);
+ this.downloadInProgress = false;
+ deferred?.reject(e);
+ });
+ return deferred.promise;
+ }
+ async processQueryResponseForUser(userId, dkResponse, crossSigningResponse) {
+ _logger.logger.log("got device keys for " + userId + ":", dkResponse);
+ _logger.logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse);
+ {
+ // map from deviceid -> deviceinfo for this user
+ const userStore = {};
+ const devs = this.deviceList.getRawStoredDevicesForUser(userId);
+ if (devs) {
+ Object.keys(devs).forEach(deviceId => {
+ const d = _deviceinfo.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 = {};
+ 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 _CrossSigning.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(_index.CryptoEvent.UserCrossSigningUpdated, userId);
+ }
+ }
+ }
+}
+async function updateStoredDeviceKeysForUser(olmDevice, userId, userStore, userResult, localUserId, localDeviceId) {
+ 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.logger.warn(`Local device ${deviceId} missing from sync, skipping removal`);
+ continue;
+ }
+ _logger.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.logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId);
+ continue;
+ }
+ if (deviceResult.device_id !== deviceId) {
+ _logger.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, userStore, deviceResult) {
+ 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.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.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.logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed");
+ return false;
+ }
+ } else {
+ userStore[deviceId] = deviceStore = new _deviceinfo.DeviceInfo(deviceId);
+ }
+ deviceStore.keys = deviceResult.keys || {};
+ deviceStore.algorithms = deviceResult.algorithms || [];
+ deviceStore.unsigned = unsigned;
+ deviceStore.signatures = signatures;
+ return true;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js
new file mode 100644
index 0000000000..7bc39b0d92
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js
@@ -0,0 +1,342 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EncryptionSetupOperation = exports.EncryptionSetupBuilder = void 0;
+var _logger = require("../logger");
+var _event = require("../models/event");
+var _CrossSigning = require("./CrossSigning");
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+var _httpApi = require("../http-api");
+var _client = require("../client");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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.
+ */
+class EncryptionSetupBuilder {
+ /**
+ * @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
+ */
+ constructor(accountData, delegateCryptoCallbacks) {
+ _defineProperty(this, "accountDataClientAdapter", void 0);
+ _defineProperty(this, "crossSigningCallbacks", void 0);
+ _defineProperty(this, "ssssCryptoCallbacks", void 0);
+ _defineProperty(this, "crossSigningKeys", void 0);
+ _defineProperty(this, "keySignatures", void 0);
+ _defineProperty(this, "keyBackupInfo", void 0);
+ _defineProperty(this, "sessionBackupPrivateKey", void 0);
+ 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
+ */
+ addCrossSigningKeys(authUpload, keys) {
+ 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
+ */
+ addSessionBackup(keyBackupInfo) {
+ this.keyBackupInfo = keyBackupInfo;
+ }
+
+ /**
+ * Adds the session backup private key to be updated in the local cache
+ *
+ * Used after fixing the format of the key
+ *
+ */
+ addSessionBackupPrivateKeyToCache(privateKey) {
+ 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
+ *
+ */
+ addKeySignature(userId, deviceId, signature) {
+ if (!this.keySignatures) {
+ this.keySignatures = {};
+ }
+ const userSignatures = this.keySignatures[userId] || {};
+ this.keySignatures[userId] = userSignatures;
+ userSignatures[deviceId] = signature;
+ }
+ async setAccountData(type, content) {
+ await this.accountDataClientAdapter.setAccountData(type, content);
+ }
+
+ /**
+ * builds the operation containing all the parts that have been added to the builder
+ */
+ buildOperation() {
+ 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.
+ */
+ async persist(crypto) {
+ // store private keys in cache
+ if (this.crossSigningKeys) {
+ const cacheCallbacks = (0, _CrossSigning.createCryptoStoreCacheCallbacks)(crypto.cryptoStore, crypto.olmDevice);
+ for (const type of ["master", "self_signing", "user_signing"]) {
+ _logger.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.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.
+ */
+exports.EncryptionSetupBuilder = EncryptionSetupBuilder;
+class EncryptionSetupOperation {
+ /**
+ */
+ constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) {
+ this.accountData = accountData;
+ this.crossSigningKeys = crossSigningKeys;
+ this.keyBackupInfo = keyBackupInfo;
+ this.keySignatures = keySignatures;
+ }
+
+ /**
+ * Runs the (remaining part of, in the future) operation by sending requests to the server.
+ */
+ async apply(crypto) {
+ const baseApis = crypto.baseApis;
+ // upload cross-signing keys
+ if (this.crossSigningKeys) {
+ const keys = {};
+ for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) {
+ keys[name + "_key"] = 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);
+ });
+
+ // 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(_httpApi.Method.Put, "/room_keys/version/" + this.keyBackupInfo.version, undefined, {
+ algorithm: this.keyBackupInfo.algorithm,
+ auth_data: this.keyBackupInfo.auth_data
+ }, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ } else {
+ // add new key backup
+ await baseApis.http.authedRequest(_httpApi.Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, {
+ prefix: _httpApi.ClientPrefix.V3
+ });
+ }
+ }
+ }
+}
+
+/**
+ * Catches account data set by SecretStorage during bootstrapping by
+ * implementing the methods related to account data in MatrixClient
+ */
+exports.EncryptionSetupOperation = EncryptionSetupOperation;
+class AccountDataClientAdapter extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * @param existingValues - existing account data
+ */
+ constructor(existingValues) {
+ super();
+ this.existingValues = existingValues;
+ //
+ _defineProperty(this, "values", new Map());
+ }
+
+ /**
+ * @returns the content of the account data
+ */
+ getAccountDataFromServer(type) {
+ return Promise.resolve(this.getAccountData(type));
+ }
+
+ /**
+ * @returns the content of the account data
+ */
+ getAccountData(type) {
+ const modifiedValue = this.values.get(type);
+ if (modifiedValue) {
+ return modifiedValue;
+ }
+ const existingValue = this.existingValues.get(type);
+ if (existingValue) {
+ return existingValue.getContent();
+ }
+ return null;
+ }
+ setAccountData(type, content) {
+ 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 _event.MatrixEvent({
+ type,
+ content
+ });
+ this.emit(_client.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 {
+ constructor() {
+ _defineProperty(this, "privateKeys", new Map());
+ }
+ // cache callbacks
+ getCrossSigningKeyCache(type, expectedPublicKey) {
+ return this.getCrossSigningKey(type, expectedPublicKey);
+ }
+ storeCrossSigningKeyCache(type, key) {
+ this.privateKeys.set(type, key);
+ return Promise.resolve();
+ }
+
+ // non-cache callbacks
+ getCrossSigningKey(type, expectedPubkey) {
+ return Promise.resolve(this.privateKeys.get(type) ?? null);
+ }
+ saveCrossSigningKeys(privateKeys) {
+ 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 {
+ constructor(delegateCryptoCallbacks) {
+ this.delegateCryptoCallbacks = delegateCryptoCallbacks;
+ _defineProperty(this, "privateKeys", new Map());
+ }
+ async getSecretStorageKey({
+ keys
+ }, name) {
+ 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;
+ }
+ addPrivateKey(keyId, keyInfo, privKey) {
+ this.privateKeys.set(keyId, privKey);
+ // Also pass along to application to cache if it wishes
+ this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey);
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js
new file mode 100644
index 0000000000..1114de97d9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js
@@ -0,0 +1,1162 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WITHHELD_MESSAGES = exports.PayloadTooLargeError = exports.OlmDevice = void 0;
+var _logger = require("../logger");
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+var algorithms = _interopRequireWildcard(require("./algorithms"));
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// 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;
+class PayloadTooLargeError extends Error {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "data", {
+ errcode: "M_TOO_LARGE",
+ error: "Payload too large for encrypted message"
+ });
+ }
+}
+exports.PayloadTooLargeError = PayloadTooLargeError;
+function checkPayloadLength(payloadString) {
+ 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.`);
+ }
+}
+
+/** data stored in the session store about an inbound group session */
+
+/* eslint-disable camelcase */
+
+/* eslint-enable camelcase */
+
+/**
+ * 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.
+ */
+class OlmDevice {
+ // set by consumers
+
+ constructor(cryptoStore) {
+ this.cryptoStore = cryptoStore;
+ _defineProperty(this, "pickleKey", "DEFAULT_KEY");
+ // set by consumers
+ /** Curve25519 key for the account, unknown until we load the account from storage in init() */
+ _defineProperty(this, "deviceCurve25519Key", null);
+ /** Ed25519 key for the account, unknown until we load the account from storage in init() */
+ _defineProperty(this, "deviceEd25519Key", null);
+ _defineProperty(this, "maxOneTimeKeys", null);
+ // we don't bother stashing outboundgroupsessions in the cryptoStore -
+ // instead we keep them here.
+ _defineProperty(this, "outboundGroupSessionStore", {});
+ // 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>}"
+ _defineProperty(this, "inboundGroupSessionMessageIndexes", {});
+ // Keep track of sessions that we're starting, so that we don't start
+ // multiple sessions for the same device at the same time.
+ _defineProperty(this, "sessionsInProgress", {});
+ // set by consumers
+ // Used by olm to serialise prekey message decryptions
+ _defineProperty(this, "olmPrekeyPromise", Promise.resolve());
+ }
+
+ /**
+ * @returns The version of Olm.
+ */
+ static getOlmVersion() {
+ 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
+ */
+ async init({
+ pickleKey,
+ fromExportedDevice
+ } = {}) {
+ let e2eKeys;
+ const account = new global.Olm.Account();
+ try {
+ if (fromExportedDevice) {
+ if (pickleKey) {
+ _logger.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
+ */
+ async initialiseFromExportedDevice(exportedData, account) {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.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);
+ }
+ async initialiseAccount(account) {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.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
+ */
+ getAccount(txn, func) {
+ this.cryptoStore.getAccount(txn, pickledAccount => {
+ 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
+ */
+ storeAccount(txn, account) {
+ 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
+ */
+ async export() {
+ const result = {
+ pickleKey: this.pickleKey
+ };
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.cryptoStore.getAccount(txn, pickledAccount => {
+ 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;
+ }
+
+ /**
+ * 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
+ */
+ getSession(deviceKey, sessionId, txn, func) {
+ this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, sessionInfo => {
+ 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
+ */
+ unpickleSession(sessionInfo, func) {
+ const session = new global.Olm.Session();
+ try {
+ session.unpickle(this.pickleKey, sessionInfo.session);
+ const unpickledSessInfo = 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
+ */
+ saveSession(deviceKey, sessionInfo, txn) {
+ const sessionId = sessionInfo.session.session_id();
+ _logger.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
+ */
+ getUtility(func) {
+ 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
+ */
+ async sign(message) {
+ let result;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.getAccount(txn, 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.
+ */
+ async getOneTimeKeys() {
+ let result;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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
+ */
+ maxNumberOfOneTimeKeys() {
+ return this.maxOneTimeKeys ?? -1;
+ }
+
+ /**
+ * Marks all of the one-time keys as published.
+ */
+ async markKeysAsPublished() {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.getAccount(txn, 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
+ */
+ generateOneTimeKeys(numKeys) {
+ return this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.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
+ */
+ async generateFallbackKey() {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.getAccount(txn, account => {
+ account.generate_fallback_key();
+ this.storeAccount(txn, account);
+ });
+ });
+ }
+ async getFallbackKey() {
+ let result;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.getAccount(txn, account => {
+ result = JSON.parse(account.unpublished_fallback_key());
+ });
+ });
+ return result;
+ }
+ async forgetOldFallbackKey() {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.getAccount(txn, 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.
+ */
+ async createOutboundSession(theirIdentityKey, theirOneTimeKey) {
+ let newSessionId;
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.getAccount(txn, account => {
+ const session = new global.Olm.Session();
+ try {
+ session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
+ newSessionId = session.session_id();
+ this.storeAccount(txn, account);
+ const sessionInfo = {
+ 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.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).
+ */
+ async createInboundSession(theirDeviceIdentityKey, messageType, ciphertext) {
+ if (messageType !== 0) {
+ throw new Error("Need messageType == 0 to create inbound session");
+ }
+ let result; // eslint-disable-line camelcase
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.getAccount(txn, 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 = {
+ 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.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
+ */
+ async getSessionIdsForDevice(theirDeviceIdentityKey) {
+ const log = _logger.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;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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
+ */
+ async getSessionIdForDevice(theirDeviceIdentityKey, nowait = false, log) {
+ 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
+ */
+ async getSessionInfoForDevice(deviceIdentityKey, nowait = false, log = _logger.logger) {
+ 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 = [];
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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 => {
+ 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
+ */
+ async encryptMessage(theirDeviceIdentityKey, sessionId, payloadString) {
+ checkPayloadLength(payloadString);
+ let res;
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+ const sessionDesc = sessionInfo.session.describe();
+ _logger.logger.log("encryptMessage: Olm Session ID " + sessionId + " to " + theirDeviceIdentityKey + ": " + sessionDesc);
+ res = sessionInfo.session.encrypt(payloadString);
+ this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+ });
+ }, _logger.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.
+ */
+ async decryptMessage(theirDeviceIdentityKey, sessionId, messageType, ciphertext) {
+ let payloadString;
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+ const sessionDesc = sessionInfo.session.describe();
+ _logger.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.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.
+ */
+ async matchesSession(theirDeviceIdentityKey, sessionId, messageType, ciphertext) {
+ if (messageType !== 0) {
+ return false;
+ }
+ let matches;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SESSIONS], txn => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+ matches = sessionInfo.session.matches_inbound(ciphertext);
+ });
+ }, _logger.logger.withPrefix("[matchesSession]"));
+ return matches;
+ }
+ async recordSessionProblem(deviceKey, type, fixed) {
+ _logger.logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`);
+ await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed);
+ }
+ sessionMayHaveProblems(deviceKey, timestamp) {
+ return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp);
+ }
+ filterOutNotifiedErrorDevices(devices) {
+ return this.cryptoStore.filterOutNotifiedErrorDevices(devices);
+ }
+
+ // Outbound group session
+ // ======================
+
+ /**
+ * store an OutboundGroupSession in outboundGroupSessionStore
+ *
+ * @internal
+ */
+ saveOutboundGroupSession(session) {
+ this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey);
+ }
+
+ /**
+ * extract an OutboundGroupSession from outboundGroupSessionStore and call the
+ * given function
+ *
+ * @returns result of func
+ * @internal
+ */
+ getOutboundGroupSession(sessionId, func) {
+ 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.
+ */
+ createOutboundGroupSession() {
+ 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
+ */
+ encryptGroupMessage(sessionId, payloadString) {
+ _logger.logger.log(`encrypting msg with megolm session ${sessionId}`);
+ checkPayloadLength(payloadString);
+ return this.getOutboundGroupSession(sessionId, session => {
+ 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.
+ */
+ getOutboundGroupSessionKey(sessionId) {
+ return this.getOutboundGroupSession(sessionId, function (session) {
+ 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
+ */
+ unpickleInboundGroupSession(sessionData, func) {
+ 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
+ */
+ getInboundGroupSession(roomId, senderKey, sessionId, txn, func) {
+ this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData, withheld) => {
+ 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 => {
+ 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
+ */
+ async addInboundGroupSession(roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat, extraSessionData = {}) {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, _indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], txn => {
+ /* if we already have this session, consider updating it */
+ this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (existingSession, existingSessionData) => {
+ // 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.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.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.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.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.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.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`
+ */
+ async addInboundGroupSessionWithheld(roomId, senderKey, sessionId, code, reason) {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.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
+ */
+ async decryptGroupMessage(roomId, senderKey, sessionId, body, eventId, timestamp) {
+ let result = 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;
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.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;
+ try {
+ res = session.decrypt(body);
+ } catch (e) {
+ if (e?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) {
+ error = new algorithms.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", calculateWithheldMessage(withheld), {
+ session: senderKey + "|" + sessionId
+ });
+ } else {
+ error = e;
+ }
+ return;
+ }
+ let plaintext = res.plaintext;
+ if (plaintext === undefined) {
+ // @ts-ignore - Compatibility for older olm versions.
+ plaintext = res;
+ } 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.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
+ */
+ async hasInboundSessionKeys(roomId, senderKey, sessionId) {
+ let result;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.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.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.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).
+ */
+ async getInboundGroupSessionKey(roomId, senderKey, sessionId, chainIndex) {
+ let result = null;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.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.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
+ */
+ exportInboundGroupSession(senderKey, sessionId, sessionData) {
+ 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
+ };
+ });
+ }
+ async getSharedHistoryInboundGroupSessions(roomId) {
+ let result;
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], txn => {
+ result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn);
+ }, _logger.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".
+ */
+ verifySignature(key, message, signature) {
+ this.getUtility(function (util) {
+ util.ed25519_verify(key, message, signature);
+ });
+ }
+}
+exports.OlmDevice = OlmDevice;
+const WITHHELD_MESSAGES = {
+ "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
+ */
+exports.WITHHELD_MESSAGES = WITHHELD_MESSAGES;
+function calculateWithheldMessage(withheld) {
+ 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";
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js
new file mode 100644
index 0000000000..a9d056c5ea
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js
@@ -0,0 +1,406 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomKeyRequestState = exports.OutgoingRoomKeyRequestManager = void 0;
+var _uuid = require("uuid");
+var _logger = require("../logger");
+var _event = require("../@types/event");
+var _utils = require("../utils");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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) <---------------------------+
+ * ```
+ */
+let RoomKeyRequestState = /*#__PURE__*/function (RoomKeyRequestState) {
+ RoomKeyRequestState[RoomKeyRequestState["Unsent"] = 0] = "Unsent";
+ RoomKeyRequestState[RoomKeyRequestState["Sent"] = 1] = "Sent";
+ RoomKeyRequestState[RoomKeyRequestState["CancellationPending"] = 2] = "CancellationPending";
+ RoomKeyRequestState[RoomKeyRequestState["CancellationPendingAndWillResend"] = 3] = "CancellationPendingAndWillResend";
+ return RoomKeyRequestState;
+}({});
+exports.RoomKeyRequestState = RoomKeyRequestState;
+class OutgoingRoomKeyRequestManager {
+ constructor(baseApis, deviceId, cryptoStore) {
+ this.baseApis = baseApis;
+ this.deviceId = deviceId;
+ this.cryptoStore = cryptoStore;
+ // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
+ // if the callback has been set, or if it is still running.
+ _defineProperty(this, "sendOutgoingRoomKeyRequestsTimer", void 0);
+ // sanity check to ensure that we don't end up with two concurrent runs
+ // of sendOutgoingRoomKeyRequests
+ _defineProperty(this, "sendOutgoingRoomKeyRequestsRunning", false);
+ _defineProperty(this, "clientRunning", true);
+ }
+
+ /**
+ * Called when the client is stopped. Stops any running background processes.
+ */
+ stop() {
+ _logger.logger.log("stopping OutgoingRoomKeyRequestManager");
+ // stop the timer on the next run
+ this.clientRunning = false;
+ }
+
+ /**
+ * Send any requests that have been queued
+ */
+ sendQueuedRequests() {
+ 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)
+ */
+ async queueRoomKeyRequest(requestBody, recipients, resend = false) {
+ 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.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.
+ */
+ cancelRoomKeyRequest(requestBody) {
+ return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then(req => {
+ 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.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.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.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}
+ */
+ getOutgoingSentRoomKeyRequest(userId, deviceId) {
+ 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.
+ */
+ async cancelAndResendAllOutgoingRequests() {
+ 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
+ startTimer() {
+ if (this.sendOutgoingRoomKeyRequestsTimer) {
+ return;
+ }
+ const startSendingOutgoingRoomKeyRequests = () => {
+ 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.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).
+ async sendOutgoingRoomKeyRequests() {
+ 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.logger.error("Error sending room key request; will retry later.", e);
+ this.sendOutgoingRoomKeyRequestsTimer = undefined;
+ }
+ }
+
+ // given a RoomKeyRequest, send it and update the request record
+ sendOutgoingRoomKeyRequest(req) {
+ _logger.logger.log(`Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + `(id ${req.requestId})`);
+ const 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.
+ sendOutgoingRoomKeyRequestCancellation(req, andResend = false) {
+ _logger.logger.log(`Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + `${stringifyRecipientList(req.recipients)} ` + `(cancellation id ${req.cancellationTxnId})`);
+ const 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
+ sendMessageToDevices(message, recipients, txnId) {
+ const contentMap = new _utils.MapWithDefault(() => new Map());
+ for (const recip of recipients) {
+ const userDeviceMap = contentMap.getOrCreate(recip.userId);
+ userDeviceMap.set(recip.deviceId, _objectSpread(_objectSpread({}, message), {}, {
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ }));
+ }
+ return this.baseApis.sendToDevice(_event.EventType.RoomKeyRequest, contentMap, txnId);
+ }
+}
+exports.OutgoingRoomKeyRequestManager = OutgoingRoomKeyRequestManager;
+function stringifyRequestBody(requestBody) {
+ // 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) {
+ return `[${recipients.map(r => `${r.userId}:${r.deviceId}`).join(",")}]`;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js
new file mode 100644
index 0000000000..24dd53ed3c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js
@@ -0,0 +1,60 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomList = void 0;
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+/* eslint-disable camelcase */
+
+/* eslint-enable camelcase */
+
+class RoomList {
+ constructor(cryptoStore) {
+ this.cryptoStore = cryptoStore;
+ // Object of roomId -> room e2e info object (body of the m.room.encryption event)
+ _defineProperty(this, "roomEncryption", {});
+ }
+ async init() {
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ROOMS], txn => {
+ this.cryptoStore.getEndToEndRooms(txn, result => {
+ this.roomEncryption = result;
+ });
+ });
+ }
+ getRoomEncryption(roomId) {
+ return this.roomEncryption[roomId] || null;
+ }
+ isRoomEncrypted(roomId) {
+ return Boolean(this.getRoomEncryption(roomId));
+ }
+ async setRoomEncryption(roomId, roomInfo) {
+ // 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.IndexedDBCryptoStore.STORE_ROOMS], txn => {
+ this.cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn);
+ });
+ }
+}
+exports.RoomList = RoomList; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js
new file mode 100644
index 0000000000..805fd64471
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js
@@ -0,0 +1,199 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SecretSharing = void 0;
+var _uuid = require("uuid");
+var _utils = require("../utils");
+var _event = require("../@types/event");
+var _logger = require("../logger");
+var olmlib = _interopRequireWildcard(require("./olmlib"));
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ Copyright 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.
+ */
+class SecretSharing {
+ constructor(baseApis, cryptoCallbacks) {
+ this.baseApis = baseApis;
+ this.cryptoCallbacks = cryptoCallbacks;
+ _defineProperty(this, "requests", new Map());
+ }
+
+ /**
+ * Request a secret from another device
+ *
+ * @param name - the name of the secret to request
+ * @param devices - the devices to request the secret from
+ */
+ request(name, devices) {
+ const requestId = this.baseApis.makeTxnId();
+ const deferred = (0, _utils.defer)();
+ this.requests.set(requestId, {
+ name,
+ devices,
+ deferred
+ });
+ const cancel = reason => {
+ // send cancellation event
+ const cancelData = {
+ action: "request_cancellation",
+ requesting_device_id: this.baseApis.deviceId,
+ request_id: requestId
+ };
+ const toDevice = 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,
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ const toDevice = new Map();
+ for (const device of devices) {
+ toDevice.set(device, requestData);
+ }
+ _logger.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
+ };
+ }
+ async onRequestReceived(event) {
+ 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.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.logger.info(`Preparing ${content.name} secret for ${deviceId}`);
+ const payload = {
+ type: "m.secret.send",
+ content: {
+ request_id: content.request_id,
+ secret: secret
+ }
+ };
+ const encryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.baseApis.crypto.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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.logger.info(`Sending ${content.name} secret for ${deviceId}`);
+ this.baseApis.sendToDevice("m.room.encrypted", contentMap);
+ } else {
+ _logger.logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
+ }
+ }
+ }
+ onSecretReceived(event) {
+ 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.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.logger.error("sending device does not belong to the user it claims to be from");
+ return;
+ }
+ _logger.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.logger.log("secret share from unknown device with key", event.getSenderKey());
+ return;
+ }
+ if (!requestControl.devices.includes(deviceInfo.deviceId)) {
+ _logger.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.logger.log("secret share from unverified device");
+ return;
+ }
+ _logger.logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
+ requestControl.deferred.resolve(content.secret);
+ }
+ }
+}
+exports.SecretSharing = SecretSharing; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js
new file mode 100644
index 0000000000..9b363f359c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js
@@ -0,0 +1,119 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SecretStorage = void 0;
+var _secretStorage = require("../secret-storage");
+var _SecretSharing = require("./SecretSharing");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/* re-exports for backwards compatibility */
+
+/**
+ * Implements Secure Secret Storage and Sharing (MSC1946)
+ *
+ * @deprecated This is just a backwards-compatibility hack which will be removed soon.
+ * Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`.
+ */
+class SecretStorage {
+ // In its 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, we added
+ // an extra, optional param for a real matrix client, so you can not pass it as long
+ // as you don't request any secrets.
+ //
+ // Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy
+ // Crypto impl that exposes it.
+ constructor(accountDataAdapter, cryptoCallbacks, baseApis) {
+ _defineProperty(this, "storageImpl", void 0);
+ _defineProperty(this, "sharingImpl", void 0);
+ this.storageImpl = new _secretStorage.ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks);
+ this.sharingImpl = new _SecretSharing.SecretSharing(baseApis, cryptoCallbacks);
+ }
+ getDefaultKeyId() {
+ return this.storageImpl.getDefaultKeyId();
+ }
+ setDefaultKeyId(keyId) {
+ return this.storageImpl.setDefaultKeyId(keyId);
+ }
+
+ /**
+ * Add a key for encrypting secrets.
+ */
+ addKey(algorithm, opts = {}, keyId) {
+ return this.storageImpl.addKey(algorithm, opts, keyId);
+ }
+
+ /**
+ * Get the key information for a given ID.
+ */
+ getKey(keyId) {
+ return this.storageImpl.getKey(keyId);
+ }
+
+ /**
+ * Check whether we have a key with a given ID.
+ */
+ hasKey(keyId) {
+ return this.storageImpl.hasKey(keyId);
+ }
+
+ /**
+ * Check whether a key matches what we expect based on the key info
+ */
+ checkKey(key, info) {
+ return this.storageImpl.checkKey(key, info);
+ }
+
+ /**
+ * Store an encrypted secret on the server
+ */
+ store(name, secret, keys) {
+ return this.storageImpl.store(name, secret, keys);
+ }
+
+ /**
+ * Get a secret from storage.
+ */
+ get(name) {
+ return this.storageImpl.get(name);
+ }
+
+ /**
+ * Check if a secret is stored on the server.
+ */
+ async isStored(name) {
+ return this.storageImpl.isStored(name);
+ }
+
+ /**
+ * Request a secret from another device
+ */
+ request(name, devices) {
+ return this.sharingImpl.request(name, devices);
+ }
+ onRequestReceived(event) {
+ return this.sharingImpl.onRequestReceived(event);
+ }
+ onSecretReceived(event) {
+ this.sharingImpl.onSecretReceived(event);
+ }
+}
+exports.SecretStorage = SecretStorage; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js
new file mode 100644
index 0000000000..e48c59446c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js
@@ -0,0 +1,127 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.calculateKeyCheck = calculateKeyCheck;
+exports.decryptAES = decryptAES;
+exports.encryptAES = encryptAES;
+var _olmlib = require("./olmlib");
+var _crypto = require("./crypto");
+/*
+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.
+*/
+
+// salt for HKDF, with 8 bytes of zeros
+const zeroSalt = new Uint8Array(8);
+/**
+ * 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
+ */
+async function encryptAES(data, key, name, ivStr) {
+ let iv;
+ if (ivStr) {
+ iv = (0, _olmlib.decodeBase64)(ivStr);
+ } else {
+ iv = new Uint8Array(16);
+ _crypto.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 _crypto.TextEncoder().encode(data);
+ const ciphertext = await _crypto.subtleCrypto.encrypt({
+ name: "AES-CTR",
+ counter: iv,
+ length: 64
+ }, aesKey, encodedData);
+ const hmac = await _crypto.subtleCrypto.sign({
+ name: "HMAC"
+ }, hmacKey, ciphertext);
+ return {
+ iv: (0, _olmlib.encodeBase64)(iv),
+ ciphertext: (0, _olmlib.encodeBase64)(ciphertext),
+ mac: (0, _olmlib.encodeBase64)(hmac)
+ };
+}
+
+/**
+ * decrypt a string
+ *
+ * @param data - the encrypted data
+ * @param key - the encryption key to use
+ * @param name - the name of the secret
+ */
+async function decryptAES(data, key, name) {
+ const [aesKey, hmacKey] = await deriveKeys(key, name);
+ const ciphertext = (0, _olmlib.decodeBase64)(data.ciphertext);
+ if (!(await _crypto.subtleCrypto.verify({
+ name: "HMAC"
+ }, hmacKey, (0, _olmlib.decodeBase64)(data.mac), ciphertext))) {
+ throw new Error(`Error decrypting secret ${name}: bad MAC`);
+ }
+ const plaintext = await _crypto.subtleCrypto.decrypt({
+ name: "AES-CTR",
+ counter: (0, _olmlib.decodeBase64)(data.iv),
+ length: 64
+ }, aesKey, ciphertext);
+ return new TextDecoder().decode(new Uint8Array(plaintext));
+}
+async function deriveKeys(key, name) {
+ const hkdfkey = await _crypto.subtleCrypto.importKey("raw", key, {
+ name: "HKDF"
+ }, false, ["deriveBits"]);
+ const keybits = await _crypto.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 _crypto.TextEncoder().encode(name),
+ hash: "SHA-256"
+ }, hkdfkey, 512);
+ const aesKey = keybits.slice(0, 32);
+ const hmacKey = keybits.slice(32);
+ const aesProm = _crypto.subtleCrypto.importKey("raw", aesKey, {
+ name: "AES-CTR"
+ }, false, ["encrypt", "decrypt"]);
+ const hmacProm = _crypto.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.
+ */
+function calculateKeyCheck(key, iv) {
+ return encryptAES(ZERO_STR, key, "", iv);
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js
new file mode 100644
index 0000000000..803b5cf8fd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js
@@ -0,0 +1,226 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UnknownDeviceError = exports.EncryptionAlgorithm = exports.ENCRYPTION_CLASSES = exports.DecryptionError = exports.DecryptionAlgorithm = exports.DECRYPTION_CLASSES = void 0;
+exports.registerAlgorithm = registerAlgorithm;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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
+ */
+
+/**
+ * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class
+ */
+const ENCRYPTION_CLASSES = new Map();
+exports.ENCRYPTION_CLASSES = ENCRYPTION_CLASSES;
+/**
+ * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class
+ */
+const DECRYPTION_CLASSES = new Map();
+exports.DECRYPTION_CLASSES = DECRYPTION_CLASSES;
+/**
+ * base type for encryption implementations
+ */
+class EncryptionAlgorithm {
+ /**
+ * @param params - parameters
+ */
+ constructor(params) {
+ _defineProperty(this, "userId", void 0);
+ _defineProperty(this, "deviceId", void 0);
+ _defineProperty(this, "crypto", void 0);
+ _defineProperty(this, "olmDevice", void 0);
+ _defineProperty(this, "baseApis", void 0);
+ _defineProperty(this, "roomId", void 0);
+ 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
+ */
+ prepareToEncrypt(room) {}
+
+ /**
+ * Encrypt a message event
+ *
+ * @public
+ *
+ * @param content - event content
+ *
+ * @returns Promise which resolves to the new event body
+ */
+
+ /**
+ * 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
+ */
+ onRoomMembership(event, member, oldMembership) {}
+}
+
+/**
+ * base type for decryption implementations
+ */
+exports.EncryptionAlgorithm = EncryptionAlgorithm;
+class DecryptionAlgorithm {
+ constructor(params) {
+ _defineProperty(this, "userId", void 0);
+ _defineProperty(this, "crypto", void 0);
+ _defineProperty(this, "olmDevice", void 0);
+ _defineProperty(this, "baseApis", void 0);
+ _defineProperty(this, "roomId", void 0);
+ 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.
+ */
+
+ /**
+ * Handle a key event
+ *
+ * @param params - event key event
+ */
+ async onRoomKeyEvent(params) {
+ // ignore by default
+ }
+
+ /**
+ * Import a room key
+ *
+ * @param opts - object
+ */
+ async importRoomKey(session, opts) {
+ // 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.
+ */
+ hasKeysForKeyRequest(keyRequest) {
+ return Promise.resolve(false);
+ }
+
+ /**
+ * Send the response to a room key request
+ *
+ */
+ shareKeysWithDevice(keyRequest) {
+ 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
+ */
+ async retryDecryptionFromSender(senderKey) {
+ // ignore by default
+ return false;
+ }
+}
+
+/**
+ * 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.
+ */
+exports.DecryptionAlgorithm = DecryptionAlgorithm;
+class DecryptionError extends Error {
+ constructor(code, msg, details) {
+ super(msg);
+ this.code = code;
+ _defineProperty(this, "detailedString", void 0);
+ this.code = code;
+ this.name = "DecryptionError";
+ this.detailedString = detailedStringForDecryptionError(this, details);
+ }
+}
+exports.DecryptionError = DecryptionError;
+function detailedStringForDecryptionError(err, details) {
+ let result = err.name + "[msg: " + err.message;
+ if (details) {
+ result += ", " + Object.keys(details).map(k => k + ": " + details[k]).join(", ");
+ }
+ result += "]";
+ return result;
+}
+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
+ */
+ constructor(msg, devices, event) {
+ super(msg);
+ this.devices = devices;
+ this.event = event;
+ 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
+ */
+exports.UnknownDeviceError = UnknownDeviceError;
+function registerAlgorithm(algorithm, encryptor, decryptor) {
+ ENCRYPTION_CLASSES.set(algorithm, encryptor);
+ DECRYPTION_CLASSES.set(algorithm, decryptor);
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js
new file mode 100644
index 0000000000..c49d64cef4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js
@@ -0,0 +1,18 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+require("./olm");
+require("./megolm");
+var _base = require("./base");
+Object.keys(_base).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _base[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _base[key];
+ }
+ });
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js
new file mode 100644
index 0000000000..a1f5c4fe72
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js
@@ -0,0 +1,1682 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MegolmEncryption = exports.MegolmDecryption = void 0;
+exports.isRoomSharedHistory = isRoomSharedHistory;
+var _uuid = require("uuid");
+var _logger = require("../../logger");
+var olmlib = _interopRequireWildcard(require("../olmlib"));
+var _base = require("./base");
+var _OlmDevice = require("../OlmDevice");
+var _event = require("../../@types/event");
+var _OutgoingRoomKeyRequestManager = require("../OutgoingRoomKeyRequestManager");
+var _utils = require("../../utils");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+// determine whether the key can be shared with invitees
+function isRoomSharedHistory(room) {
+ 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);
+}
+
+// map user Id → device Id → IBlockedDevice
+
+/**
+ * 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 => {
+ 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 */
+
+/**
+ * @internal
+ */
+class OutboundSessionInfo {
+ /**
+ * @param sharedHistory - whether the session can be freely shared with
+ * other group members, according to the room history visibility settings
+ */
+ constructor(sessionId, sharedHistory = false) {
+ this.sessionId = sessionId;
+ this.sharedHistory = sharedHistory;
+ /** number of times this session has been used */
+ _defineProperty(this, "useCount", 0);
+ /** when the session was created (ms since the epoch) */
+ _defineProperty(this, "creationTime", void 0);
+ /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */
+ _defineProperty(this, "sharedWithDevices", new _utils.MapWithDefault(() => new Map()));
+ _defineProperty(this, "blockedDevicesNotified", new _utils.MapWithDefault(() => new Map()));
+ this.creationTime = new Date().getTime();
+ }
+
+ /**
+ * Check if it's time to rotate the session
+ */
+ needsRotation(rotationPeriodMsgs, rotationPeriodMs) {
+ const sessionLifetime = new Date().getTime() - this.creationTime;
+ if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) {
+ _logger.logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms");
+ return true;
+ }
+ return false;
+ }
+ markSharedWithDevice(userId, deviceId, deviceKey, chainIndex) {
+ this.sharedWithDevices.getOrCreate(userId).set(deviceId, {
+ deviceKey,
+ messageIndex: chainIndex
+ });
+ }
+ markNotifiedBlockedDevice(userId, deviceId) {
+ 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.
+ */
+ sharedWithTooManyDevices(devicesInRoom) {
+ for (const [userId, devices] of this.sharedWithDevices) {
+ if (!devicesInRoom.has(userId)) {
+ _logger.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.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}
+ */
+class MegolmEncryption extends _base.EncryptionAlgorithm {
+ constructor(params) {
+ super(params);
+ // 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).
+ _defineProperty(this, "setupPromise", Promise.resolve(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).
+ _defineProperty(this, "outboundSessions", {});
+ _defineProperty(this, "sessionRotationPeriodMsgs", void 0);
+ _defineProperty(this, "sessionRotationPeriodMs", void 0);
+ _defineProperty(this, "encryptionPreparation", void 0);
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "prefixedLogger", void 0);
+ this.roomId = params.roomId;
+ this.prefixedLogger = _logger.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.
+ */
+ async ensureOutboundSession(room, devicesInRoom, blocked, singleOlmCreationPhase = false) {
+ // 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 => {
+ 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;
+ }
+ async prepareSession(devicesInRoom, sharedHistory, session) {
+ // 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;
+ }
+ async shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session) {
+ // now check if we need to share with any devices
+ const shareMap = {};
+ 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 = {
+ 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 () => {
+ // 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 () => {
+ 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 = [];
+
+ // 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 = [];
+ 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 () => {
+ // 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 = new _utils.MapWithDefault(() => []);
+ const failedServerMap = new Set();
+ for (const server of failedServers) {
+ failedServerMap.add(server);
+ }
+ const failedDevices = [];
+ 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 () => {
+ 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 = new _utils.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
+ */
+ async prepareNewSession(sharedHistory) {
+ 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.
+ */
+ getDevicesWithoutSessions(deviceMap, devicesByUser, noOlmDevices = []) {
+ 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
+ */
+ splitDevices(devicesByUser) {
+ const maxDevicesPerRequest = 20;
+
+ // use an array where the slices of a content map gets stored
+ let currentSlice = [];
+ 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.
+ */
+ encryptAndSendKeysToDevices(session, chainIndex, devices, payload) {
+ 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.
+ */
+ async sendBlockedNotificationsToDevices(session, userDeviceMap, payload) {
+ const contentMap = new _utils.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 = _objectSpread(_objectSpread({}, payload), {}, {
+ code: blockedInfo.code,
+ reason: blockedInfo.reason,
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ });
+ 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
+ */
+ async reshareKeyWithDevice(senderKey, sessionId, userId, device) {
+ 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 = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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.
+ */
+ async shareKeyWithDevices(session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers) {
+ 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);
+ }
+ async shareKeyWithOlmSessions(session, key, payload, deviceMap) {
+ 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
+ */
+ async notifyFailedOlmDevices(session, key, failedDevices) {
+ 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 = new _utils.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: _OlmDevice.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
+ */
+ async notifyBlockedDevices(session, devicesByUser) {
+ const payload = {
+ 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
+ */
+ prepareToEncrypt(room) {
+ 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 = () => cancelled;
+ this.encryptionPreparation = {
+ startTime: Date.now(),
+ promise: (async () => {
+ 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: () => {
+ // 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
+ */
+ async encryptMessage(room, eventType, content) {
+ 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 = {
+ 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;
+ }
+ isVerificationEvent(eventType, content) {
+ switch (eventType) {
+ case _event.EventType.KeyVerificationCancel:
+ case _event.EventType.KeyVerificationDone:
+ case _event.EventType.KeyVerificationMac:
+ case _event.EventType.KeyVerificationStart:
+ case _event.EventType.KeyVerificationKey:
+ case _event.EventType.KeyVerificationReady:
+ case _event.EventType.KeyVerificationAccept:
+ {
+ return true;
+ }
+ case _event.EventType.RoomMessage:
+ {
+ return content["msgtype"] === _event.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.
+ */
+ forceDiscardSession() {
+ 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.
+ */
+ checkForUnknownDevices(devicesInRoom) {
+ const unknownDevices = new _utils.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 _base.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.
+ */
+ removeUnknownDevices(devicesInRoom) {
+ 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`.
+ */
+
+ async getDevicesInRoom(room, forceDistributeToUnverified = false, isCancelled) {
+ 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 _utils.MapWithDefault(() => 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 (0, _utils.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: _OlmDevice.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}
+ */
+exports.MegolmEncryption = MegolmEncryption;
+class MegolmDecryption extends _base.DecryptionAlgorithm {
+ constructor(params) {
+ super(params);
+ // 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
+ _defineProperty(this, "pendingEvents", new Map());
+ // this gets stubbed out by the unit tests.
+ _defineProperty(this, "olmlib", olmlib);
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "prefixedLogger", void 0);
+ this.roomId = params.roomId;
+ this.prefixedLogger = _logger.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.
+ */
+ async decryptEvent(event) {
+ const content = event.getWireContent();
+ if (!content.sender_key || !content.session_id || !content.ciphertext) {
+ throw new _base.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;
+ try {
+ res = await this.olmDevice.decryptGroupMessage(event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, event.getId(), event.getTs());
+ } catch (e) {
+ if (e.name === "DecryptionError") {
+ // re-throw decryption errors as-is
+ throw e;
+ }
+ let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
+ if (e?.message === "OLM.UNKNOWN_MESSAGE_INDEX") {
+ this.requestKeysForEvent(event);
+ errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
+ }
+ throw new _base.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] || PROBLEM_DESCRIPTIONS.unknown;
+ if (problem.fixed) {
+ problemDescription += " Trying to create a new secure channel and re-requesting the keys.";
+ }
+ throw new _base.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, {
+ session: content.sender_key + "|" + content.session_id
+ });
+ }
+ throw new _base.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 _base.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
+ };
+ }
+ requestKeysForEvent(event) {
+ 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
+ *
+ */
+ addEventToPendingList(event) {
+ 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());
+ }
+ 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
+ *
+ */
+ removeEventFromPendingList(event) {
+ 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
+ *
+ */
+ roomKeyFromEvent(event) {
+ const senderKey = event.getSenderKey();
+ const content = event.getContent();
+ const extraSessionData = {};
+ 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 = {
+ 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
+ *
+ */
+ forwardedRoomKeyFromEvent(event) {
+ // 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();
+ 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
+ *
+ */
+ async shouldAcceptForwardedKey(event, roomKey) {
+ 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
+ *
+ */
+ async wasRoomKeyRequested(event, roomKey) {
+ // 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(), "*", [_OutgoingRoomKeyRequestManager.RoomKeyRequestState.Sent]);
+ return outgoingRequests.some(req => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId);
+ }
+ wasRoomKeyForwardedByInviter(event, roomKey) {
+ // 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;
+ }
+ }
+ wasRoomKeyForwardedAsHistory(roomKey) {
+ 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.
+ */
+ shouldParkForwardedKey(roomKey) {
+ 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
+ *
+ */
+ async parkForwardedKey(event, roomKey) {
+ 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.logger.withPrefix("[addParkedSharedHistory]"));
+ }
+
+ /**
+ * Add the given room key to our store.
+ *
+ * @param roomKey - The room key that should be added to the store.
+ *
+ * @internal
+ *
+ */
+ async addRoomKey(roomKey) {
+ 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
+ *
+ */
+ async onForwardedRoomKey(event) {
+ 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);
+ }
+ }
+ async onRoomKeyEvent(event) {
+ 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
+ */
+ async onRoomKeyWithheldEvent(event) {
+ 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);
+ }
+ }
+ async onNoOlmWithheldEvent(event) {
+ 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 = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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]])]]));
+ }
+ hasKeysForKeyRequest(keyRequest) {
+ const body = keyRequest.requestBody;
+ return this.olmDevice.hasInboundSessionKeys(body.room_id, body.sender_key, body.session_id
+ // TODO: ratchet index
+ );
+ }
+
+ shareKeysWithDevice(keyRequest) {
+ 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 = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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]])]]));
+ });
+ });
+ }
+ async buildKeyForwardingMessage(roomId, senderKey, sessionId) {
+ 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
+ */
+ importRoomKey(session, {
+ untrusted,
+ source
+ } = {}) {
+ const extraSessionData = {};
+ 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
+ */
+ async retryDecryption(senderKey, sessionId, forceRedecryptIfUntrusted) {
+ 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);
+ }
+ async retryDecryptionFromSender(senderKey) {
+ 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);
+ }
+ async sendSharedHistoryInboundSessions(devicesByUser) {
+ 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 = [];
+ const contentMap = new Map();
+ for (const [userId, devices] of devicesByUser) {
+ const deviceMessages = new Map();
+ contentMap.set(userId, deviceMessages);
+ for (const deviceInfo of devices) {
+ const encryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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);
+ }
+ }
+}
+exports.MegolmDecryption = MegolmDecryption;
+const PROBLEM_DESCRIPTIONS = {
+ no_olm: "The sender was unable to establish a secure channel.",
+ unknown: "The secure channel with the sender was corrupted."
+};
+(0, _base.registerAlgorithm)(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js
new file mode 100644
index 0000000000..6f72b95375
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js
@@ -0,0 +1,276 @@
+"use strict";
+
+var _logger = require("../../logger");
+var olmlib = _interopRequireWildcard(require("../olmlib"));
+var _deviceinfo = require("../deviceinfo");
+var _base = require("./base");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+const DeviceVerification = _deviceinfo.DeviceInfo.DeviceVerification;
+/**
+ * Olm encryption implementation
+ *
+ * @param params - parameters, as per {@link EncryptionAlgorithm}
+ */
+class OlmEncryption extends _base.EncryptionAlgorithm {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "sessionPrepared", false);
+ _defineProperty(this, "prepPromise", null);
+ }
+ /**
+ * @internal
+ * @param roomMembers - list of currently-joined users in the room
+ * @returns Promise which resolves when setup is complete
+ */
+ ensureSession(roomMembers) {
+ 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
+ */
+ async encryptMessage(room, eventType, content) {
+ // 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 = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {}
+ };
+ const promises = [];
+ 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 _base.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.
+ */
+ async decryptEvent(event) {
+ const content = event.getWireContent();
+ const deviceKey = content.sender_key;
+ const ciphertext = content.ciphertext;
+ if (!ciphertext) {
+ throw new _base.DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
+ }
+ if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) {
+ throw new _base.DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
+ }
+ const message = ciphertext[this.olmDevice.deviceCurve25519Key];
+ let payloadString;
+ try {
+ payloadString = await this.decryptMessage(deviceKey, message);
+ } catch (e) {
+ throw new _base.DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
+ sender: deviceKey,
+ err: e
+ });
+ }
+ 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 _base.DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
+ }
+ if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
+ throw new _base.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.
+ //
+ // To do this, 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 and accept it anyway. Some event handlers, such as secret sharing, may be
+ // more strict and reject events that come from unknown devices.
+ //
+ // This is a defence against the following scenario:
+ //
+ // * Alice has verified Bob and Mallory.
+ // * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
+ // senderkey, but claiming to be from Bob.
+ // * Mallory sends more events using that session, claiming to be from Bob.
+ // * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
+ // events as verified even though the sender is forged.
+ //
+ // In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
+
+ 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 _base.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 _base.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 _base.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.
+ */
+ decryptMessage(theirDeviceIdentityKey, message) {
+ // 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;
+ }
+ }
+ async reallyDecryptMessage(theirDeviceIdentityKey, message) {
+ const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
+
+ // try each session in turn.
+ const decryptionErrors = {};
+ for (const sessionId of sessionIds) {
+ try {
+ const payload = await this.olmDevice.decryptMessage(theirDeviceIdentityKey, sessionId, message.type, message.body);
+ _logger.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 + ": " + e.message);
+ }
+
+ // otherwise it's probably a message for another session; carry on, but
+ // keep a record of the error
+ decryptionErrors[sessionId] = 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;
+ try {
+ res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body);
+ } catch (e) {
+ decryptionErrors["(new)"] = e.message;
+ throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors));
+ }
+ _logger.logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey);
+ return res.payload;
+ }
+}
+(0, _base.registerAlgorithm)(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js
new file mode 100644
index 0000000000..aeed6bb466
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js
@@ -0,0 +1,12 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+Object.defineProperty(exports, "CrossSigningKey", {
+ enumerable: true,
+ get: function () {
+ return _cryptoApi.CrossSigningKey;
+ }
+});
+var _cryptoApi = require("../crypto-api"); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js
new file mode 100644
index 0000000000..554563213b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js
@@ -0,0 +1,651 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.algorithmsByName = exports.DefaultAlgorithm = exports.Curve25519 = exports.BackupManager = exports.Aes256 = void 0;
+var _client = require("../client");
+var _logger = require("../logger");
+var _olmlib = require("./olmlib");
+var _key_passphrase = require("./key_passphrase");
+var _utils = require("../utils");
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+var _recoverykey = require("./recoverykey");
+var _aes = require("./aes");
+var _NamespacedValue = require("../NamespacedValue");
+var _index = require("./index");
+var _crypto = require("./crypto");
+var _httpApi = require("../http-api");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const KEY_BACKUP_KEYS_PER_REQUEST = 200;
+const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
+
+/* eslint-disable camelcase */
+
+/* eslint-enable camelcase */
+/** A function used to get the secret key for a backup.
+ */
+/**
+ * Manages the key backup.
+ */
+class BackupManager {
+ // When did we last try to check the server for a given session id?
+
+ constructor(baseApis, getKey) {
+ this.baseApis = baseApis;
+ this.getKey = getKey;
+ _defineProperty(this, "algorithm", void 0);
+ _defineProperty(this, "backupInfo", void 0);
+ // The info dict from /room_keys/version
+ _defineProperty(this, "checkedForBackup", void 0);
+ // Have we checked the server for a backup we can use?
+ _defineProperty(this, "sendingBackups", void 0);
+ // Are we currently sending backups?
+ _defineProperty(this, "sessionLastCheckAttemptedTime", {});
+ this.checkedForBackup = false;
+ this.sendingBackups = false;
+ }
+ get version() {
+ 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
+ */
+ static checkBackupVersion(info) {
+ 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);
+ }
+ static makeAlgorithm(info, getKey) {
+ const Algorithm = algorithmsByName[info.algorithm];
+ if (!Algorithm) {
+ throw new Error("Unknown backup algorithm");
+ }
+ return Algorithm.init(info.auth_data, getKey);
+ }
+ async enableKeyBackup(info) {
+ this.backupInfo = info;
+ if (this.algorithm) {
+ this.algorithm.free();
+ }
+ this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
+ this.baseApis.emit(_index.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.
+ */
+ disableKeyBackup() {
+ if (this.algorithm) {
+ this.algorithm.free();
+ }
+ this.algorithm = undefined;
+ this.backupInfo = undefined;
+ this.baseApis.emit(_index.CryptoEvent.KeyBackupStatus, false);
+ }
+ getKeyBackupEnabled() {
+ if (!this.checkedForBackup) {
+ return null;
+ }
+ return Boolean(this.algorithm);
+ }
+ async prepareKeyBackupVersion(key, algorithm) {
+ const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
+ if (!Algorithm) {
+ throw new Error("Unknown backup algorithm");
+ }
+ const [privateKey, authData] = await Algorithm.prepare(key);
+ const recoveryKey = (0, _recoverykey.encodeRecoveryKey)(privateKey);
+ return {
+ algorithm: Algorithm.algorithmName,
+ auth_data: authData,
+ recovery_key: recoveryKey,
+ privateKey
+ };
+ }
+ async createKeyBackupVersion(info) {
+ 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.
+ */
+ async checkAndStart() {
+ _logger.logger.log("Checking key backup status...");
+ if (this.baseApis.isGuest()) {
+ _logger.logger.log("Skipping key backup check since user is guest");
+ this.checkedForBackup = true;
+ return null;
+ }
+ let backupInfo;
+ try {
+ backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined;
+ } catch (e) {
+ _logger.logger.log("Error checking for active key backup", e);
+ if (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.logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`);
+ await this.enableKeyBackup(backupInfo);
+ } else if (!trustInfo.usable && this.backupInfo) {
+ _logger.logger.log("No usable key backup: disabling key backup");
+ this.disableKeyBackup();
+ } else if (!trustInfo.usable && !this.backupInfo) {
+ _logger.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.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.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.
+ */
+ async checkKeyBackup() {
+ 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.
+ */
+ async queryKeyBackupRateLimited(targetRoomId, targetSessionId) {
+ 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
+ */
+ async isKeyBackupTrusted(backupInfo) {
+ const ret = {
+ usable: false,
+ trusted_locally: false,
+ sigs: []
+ };
+ if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) {
+ _logger.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 = null;
+ try {
+ algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey);
+ if (await algorithm.keyMatches(privKey)) {
+ _logger.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.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 = {
+ 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 (0, _olmlib.verifySignature)(this.baseApis.crypto.olmDevice, backupInfo.auth_data, userId, sigInfo.deviceId, crossSigningId);
+ sigInfo.valid = true;
+ } catch (e) {
+ _logger.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 (0, _olmlib.verifySignature)(this.baseApis.crypto.olmDevice, backupInfo.auth_data, userId, device.deviceId, device.getFingerprint());
+ sigInfo.valid = true;
+ } catch (e) {
+ _logger.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.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.
+ */
+ async scheduleKeyBackupSend(maxDelay = 10000) {
+ 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 (0, _utils.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.logger.log("Key backup request failed", err);
+ if (err.data) {
+ if (err.data.errcode == "M_NOT_FOUND" || 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(_index.CryptoEvent.KeyBackupFailed, err.data.errcode);
+ throw err;
+ }
+ }
+ }
+ if (numFailures) {
+ // exponential backoff if we have failures
+ await (0, _utils.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
+ */
+ async backupPendingKeys(limit) {
+ 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(_index.CryptoEvent.KeyBackupSessionsRemaining, remaining);
+ const rooms = {};
+ for (const session of sessions) {
+ const roomId = session.sessionData.room_id;
+ (0, _utils.safeSet)(rooms, roomId, rooms[roomId] || {
+ sessions: {}
+ });
+ const sessionData = this.baseApis.crypto.olmDevice.exportInboundGroupSession(session.senderKey, session.sessionId, session.sessionData);
+ sessionData.algorithm = _olmlib.MEGOLM_ALGORITHM;
+ const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length;
+ const userId = this.baseApis.crypto.deviceList.getUserByIdentityKey(_olmlib.MEGOLM_ALGORITHM, session.senderKey);
+ const device = this.baseApis.crypto.deviceList.getDeviceByIdentityKey(_olmlib.MEGOLM_ALGORITHM, session.senderKey) ?? undefined;
+ const verified = this.baseApis.crypto.checkDeviceInfoTrust(userId, device).isVerified();
+ (0, _utils.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(_index.CryptoEvent.KeyBackupSessionsRemaining, remaining);
+ return sessions.length;
+ }
+ async backupGroupSession(senderKey, sessionId) {
+ 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.
+ */
+ async scheduleAllGroupSessionsForBackup() {
+ 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).
+ */
+ async flagAllGroupSessionsForBackup() {
+ await this.baseApis.crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore.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(_index.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
+ */
+ countSessionsNeedingBackup() {
+ return this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup();
+ }
+}
+exports.BackupManager = BackupManager;
+class Curve25519 {
+ constructor(authData, publicKey,
+ // FIXME: PkEncryption
+ getKey) {
+ this.authData = authData;
+ this.publicKey = publicKey;
+ this.getKey = getKey;
+ }
+ static async init(authData, getKey) {
+ 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, publicKey, getKey);
+ }
+ static async prepare(key) {
+ const decryption = new global.Olm.PkDecryption();
+ try {
+ const authData = {};
+ 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 (0, _key_passphrase.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];
+ } finally {
+ decryption.free();
+ }
+ }
+ static checkBackupVersion(info) {
+ if (!("public_key" in info.auth_data)) {
+ throw new Error("Invalid backup data returned");
+ }
+ }
+ get untrusted() {
+ return true;
+ }
+ async encryptSession(data) {
+ const plainText = Object.assign({}, data);
+ delete plainText.session_id;
+ delete plainText.room_id;
+ delete plainText.first_known_index;
+ return this.publicKey.encrypt(JSON.stringify(plainText));
+ }
+ async decryptSessions(sessions) {
+ 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 _httpApi.MatrixError({
+ errcode: _client.MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY
+ });
+ }
+ const keys = [];
+ 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.logger.log("Failed to decrypt megolm session from backup", e, sessionData);
+ }
+ }
+ return keys;
+ } finally {
+ decryption.free();
+ }
+ }
+ async keyMatches(key) {
+ const decryption = new global.Olm.PkDecryption();
+ let pubKey;
+ try {
+ pubKey = decryption.init_with_private_key(key);
+ } finally {
+ decryption.free();
+ }
+ return pubKey === this.authData.public_key;
+ }
+ free() {
+ this.publicKey.free();
+ }
+}
+exports.Curve25519 = Curve25519;
+_defineProperty(Curve25519, "algorithmName", "m.megolm_backup.v1.curve25519-aes-sha2");
+function randomBytes(size) {
+ const buf = new Uint8Array(size);
+ _crypto.crypto.getRandomValues(buf);
+ return buf;
+}
+const UNSTABLE_MSC3270_NAME = new _NamespacedValue.UnstableValue("m.megolm_backup.v1.aes-hmac-sha2", "org.matrix.msc3270.v1.aes-hmac-sha2");
+class Aes256 {
+ constructor(authData, key) {
+ this.authData = authData;
+ this.key = key;
+ }
+ static async init(authData, getKey) {
+ if (!authData) {
+ throw new Error("auth_data missing");
+ }
+ const key = await getKey();
+ if (authData.mac) {
+ const {
+ mac
+ } = await (0, _aes.calculateKeyCheck)(key, authData.iv);
+ if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) {
+ throw new Error("Key does not match");
+ }
+ }
+ return new Aes256(authData, key);
+ }
+ static async prepare(key) {
+ let outKey;
+ const authData = {};
+ if (!key) {
+ outKey = randomBytes(32);
+ } else if (key instanceof Uint8Array) {
+ outKey = new Uint8Array(key);
+ } else {
+ const derivation = await (0, _key_passphrase.keyFromPassphrase)(key);
+ authData.private_key_salt = derivation.salt;
+ authData.private_key_iterations = derivation.iterations;
+ outKey = derivation.key;
+ }
+ const {
+ iv,
+ mac
+ } = await (0, _aes.calculateKeyCheck)(outKey);
+ authData.iv = iv;
+ authData.mac = mac;
+ return [outKey, authData];
+ }
+ static checkBackupVersion(info) {
+ if (!("iv" in info.auth_data && "mac" in info.auth_data)) {
+ throw new Error("Invalid backup data returned");
+ }
+ }
+ get untrusted() {
+ return false;
+ }
+ encryptSession(data) {
+ const plainText = Object.assign({}, data);
+ delete plainText.session_id;
+ delete plainText.room_id;
+ delete plainText.first_known_index;
+ return (0, _aes.encryptAES)(JSON.stringify(plainText), this.key, data.session_id);
+ }
+ async decryptSessions(sessions) {
+ const keys = [];
+ for (const [sessionId, sessionData] of Object.entries(sessions)) {
+ try {
+ const decrypted = JSON.parse(await (0, _aes.decryptAES)(sessionData.session_data, this.key, sessionId));
+ decrypted.session_id = sessionId;
+ keys.push(decrypted);
+ } catch (e) {
+ _logger.logger.log("Failed to decrypt megolm session from backup", e, sessionData);
+ }
+ }
+ return keys;
+ }
+ async keyMatches(key) {
+ if (this.authData.mac) {
+ const {
+ mac
+ } = await (0, _aes.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;
+ }
+ }
+ free() {
+ this.key.fill(0);
+ }
+}
+exports.Aes256 = Aes256;
+_defineProperty(Aes256, "algorithmName", UNSTABLE_MSC3270_NAME.name);
+const algorithmsByName = {
+ [Curve25519.algorithmName]: Curve25519,
+ [Aes256.algorithmName]: Aes256
+};
+exports.algorithmsByName = algorithmsByName;
+const DefaultAlgorithm = Curve25519;
+exports.DefaultAlgorithm = DefaultAlgorithm; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js
new file mode 100644
index 0000000000..f4a47c9ca7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js
@@ -0,0 +1,60 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.crypto = exports.TextEncoder = void 0;
+exports.setCrypto = setCrypto;
+exports.setTextEncoder = setTextEncoder;
+exports.subtleCrypto = void 0;
+var _logger = require("../logger");
+/*
+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 crypto = global.window?.crypto;
+exports.crypto = crypto;
+let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
+exports.subtleCrypto = subtleCrypto;
+let TextEncoder = global.window?.TextEncoder;
+
+/* eslint-disable @typescript-eslint/no-var-requires */
+exports.TextEncoder = TextEncoder;
+if (!crypto) {
+ try {
+ exports.crypto = crypto = require("crypto").webcrypto;
+ } catch (e) {
+ _logger.logger.error("Failed to load webcrypto", e);
+ }
+}
+if (!subtleCrypto) {
+ exports.subtleCrypto = subtleCrypto = crypto?.subtle;
+}
+if (!TextEncoder) {
+ try {
+ exports.TextEncoder = TextEncoder = require("util").TextEncoder;
+ } catch (e) {
+ _logger.logger.error("Failed to load TextEncoder util", e);
+ }
+}
+/* eslint-enable @typescript-eslint/no-var-requires */
+
+function setCrypto(_crypto) {
+ exports.crypto = crypto = _crypto;
+ exports.subtleCrypto = subtleCrypto = _crypto.subtle ?? _crypto.webkitSubtle;
+}
+function setTextEncoder(_TextEncoder) {
+ exports.TextEncoder = TextEncoder = _TextEncoder;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js
new file mode 100644
index 0000000000..8ee568ae8c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js
@@ -0,0 +1,237 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.DehydrationManager = exports.DEHYDRATION_ALGORITHM = void 0;
+var _anotherJson = _interopRequireDefault(require("another-json"));
+var _olmlib = require("./olmlib");
+var _indexeddbCryptoStore = require("../crypto/store/indexeddb-crypto-store");
+var _aes = require("./aes");
+var _logger = require("../logger");
+var _httpApi = require("../http-api");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
+exports.DEHYDRATION_ALGORITHM = DEHYDRATION_ALGORITHM;
+const oneweek = 7 * 24 * 60 * 60 * 1000;
+class DehydrationManager {
+ constructor(crypto) {
+ this.crypto = crypto;
+ _defineProperty(this, "inProgress", false);
+ _defineProperty(this, "timeoutId", void 0);
+ _defineProperty(this, "key", void 0);
+ _defineProperty(this, "keyInfo", void 0);
+ _defineProperty(this, "deviceDisplayName", void 0);
+ this.getDehydrationKeyFromCache();
+ }
+ getDehydrationKeyFromCache() {
+ return this.crypto.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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 (0, _aes.decryptAES)(key, pickleKey, DEHYDRATION_ALGORITHM);
+ this.key = (0, _olmlib.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 */
+ async setKeyAndQueueDehydration(key, keyInfo = {}, deviceDisplayName) {
+ const matches = await this.setKey(key, keyInfo, deviceDisplayName);
+ if (!matches) {
+ // start dehydration in the background
+ this.dehydrateDevice();
+ }
+ }
+ async setKey(key, keyInfo = {}, deviceDisplayName) {
+ 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.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 = !!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 */
+ async dehydrateDevice() {
+ if (this.inProgress) {
+ _logger.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 (0, _aes.encryptAES)((0, _olmlib.encodeBase64)(this.key), pickleKey, DEHYDRATION_ALGORITHM);
+ await this.crypto.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.IndexedDBCryptoStore.STORE_ACCOUNT], txn => {
+ this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", {
+ keyInfo: this.keyInfo,
+ key,
+ deviceDisplayName: this.deviceDisplayName,
+ time: Date.now()
+ });
+ });
+ _logger.logger.log("Attempting to dehydrate device");
+ _logger.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 = JSON.parse(account.one_time_keys());
+ const fallbacks = 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 = {
+ algorithm: DEHYDRATION_ALGORITHM,
+ account: pickledAccount
+ };
+ if (this.keyInfo.passphrase) {
+ deviceData.passphrase = this.keyInfo.passphrase;
+ }
+ _logger.logger.log("Uploading account to server");
+ // eslint-disable-next-line camelcase
+ const dehydrateResult = await this.crypto.baseApis.http.authedRequest(_httpApi.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.logger.log("Preparing device keys", deviceId);
+ const deviceKeys = {
+ 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.default.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.logger.log("Preparing one-time keys");
+ const oneTimeKeys = {};
+ for (const [keyId, key] of Object.entries(otks.curve25519)) {
+ const k = {
+ key
+ };
+ const signature = account.sign(_anotherJson.default.stringify(k));
+ k.signatures = {
+ [this.crypto.userId]: {
+ [`ed25519:${deviceId}`]: signature
+ }
+ };
+ oneTimeKeys[`signed_curve25519:${keyId}`] = k;
+ }
+ _logger.logger.log("Preparing fallback keys");
+ const fallbackKeys = {};
+ for (const [keyId, key] of Object.entries(fallbacks.curve25519)) {
+ const k = {
+ key,
+ fallback: true
+ };
+ const signature = account.sign(_anotherJson.default.stringify(k));
+ k.signatures = {
+ [this.crypto.userId]: {
+ [`ed25519:${deviceId}`]: signature
+ }
+ };
+ fallbackKeys[`signed_curve25519:${keyId}`] = k;
+ }
+ _logger.logger.log("Uploading keys to server");
+ await this.crypto.baseApis.http.authedRequest(_httpApi.Method.Post, "/keys/upload/" + encodeURI(deviceId), undefined, {
+ "device_keys": deviceKeys,
+ "one_time_keys": oneTimeKeys,
+ "org.matrix.msc2732.fallback_keys": fallbackKeys
+ });
+ _logger.logger.log("Done dehydrating");
+
+ // dehydrate again in a week
+ this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek);
+ return deviceId;
+ } finally {
+ this.inProgress = false;
+ }
+ }
+ stop() {
+ if (this.timeoutId) {
+ global.clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ }
+}
+exports.DehydrationManager = DehydrationManager; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js
new file mode 100644
index 0000000000..9a14d49d66
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js
@@ -0,0 +1,47 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.deviceInfoToDevice = deviceInfoToDevice;
+var _device = require("../models/device");
+/*
+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.
+*/
+
+/**
+ * Convert a {@link DeviceInfo} to a {@link Device}.
+ * @param deviceInfo - deviceInfo to convert
+ * @param userId - id of the user that owns the device.
+ */
+function deviceInfoToDevice(deviceInfo, userId) {
+ const keys = new Map(Object.entries(deviceInfo.keys));
+ const displayName = deviceInfo.getDisplayName() || undefined;
+ const signatures = new Map();
+ if (deviceInfo.signatures) {
+ for (const userId in deviceInfo.signatures) {
+ signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
+ }
+ }
+ return new _device.Device({
+ deviceId: deviceInfo.deviceId,
+ userId: userId,
+ keys,
+ algorithms: deviceInfo.algorithms,
+ verified: deviceInfo.verified,
+ signatures,
+ displayName
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js
new file mode 100644
index 0000000000..7dc2035303
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js
@@ -0,0 +1,152 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.DeviceInfo = void 0;
+var _device = require("../models/device");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Information about a user's device
+ */
+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
+ */
+ static fromStorage(obj, deviceId) {
+ 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] = obj[prop];
+ }
+ }
+ return res;
+ }
+ /**
+ * @param deviceId - id of the device
+ */
+ constructor(deviceId) {
+ this.deviceId = deviceId;
+ /** list of algorithms supported by this device */
+ _defineProperty(this, "algorithms", []);
+ /** a map from `<key type>:<id> -> <base64-encoded key>` */
+ _defineProperty(this, "keys", {});
+ /** whether the device has been verified/blocked by the user */
+ _defineProperty(this, "verified", _device.DeviceVerification.Unverified);
+ /**
+ * whether the user knows of this device's existence
+ * (useful when warning the user that a user has added new devices)
+ */
+ _defineProperty(this, "known", false);
+ /** additional data from the homeserver */
+ _defineProperty(this, "unsigned", {});
+ _defineProperty(this, "signatures", {});
+ }
+
+ /**
+ * Prepare a DeviceInfo for JSON serialisation in the session store
+ *
+ * @returns deviceinfo with non-serialised members removed
+ */
+ toStorage() {
+ 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
+ */
+ getFingerprint() {
+ 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
+ */
+ getIdentityKey() {
+ return this.keys["curve25519:" + this.deviceId];
+ }
+
+ /**
+ * Get the configured display name for this device, if any
+ *
+ * @returns displayname
+ */
+ getDisplayName() {
+ return this.unsigned.device_display_name || null;
+ }
+
+ /**
+ * Returns true if this device is blocked
+ *
+ * @returns true if blocked
+ */
+ isBlocked() {
+ return this.verified == _device.DeviceVerification.Blocked;
+ }
+
+ /**
+ * Returns true if this device is verified
+ *
+ * @returns true if verified
+ */
+ isVerified() {
+ return this.verified == _device.DeviceVerification.Verified;
+ }
+
+ /**
+ * Returns true if this device is unverified
+ *
+ * @returns true if unverified
+ */
+ isUnverified() {
+ return this.verified == _device.DeviceVerification.Unverified;
+ }
+
+ /**
+ * Returns true if the user knows about this device's existence
+ *
+ * @returns true if known
+ */
+ isKnown() {
+ return this.known === true;
+ }
+}
+exports.DeviceInfo = DeviceInfo;
+_defineProperty(DeviceInfo, "DeviceVerification", {
+ VERIFIED: _device.DeviceVerification.Verified,
+ UNVERIFIED: _device.DeviceVerification.Unverified,
+ BLOCKED: _device.DeviceVerification.Blocked
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js
new file mode 100644
index 0000000000..7d1a5a202c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js
@@ -0,0 +1,3427 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.IncomingRoomKeyRequest = exports.CryptoEvent = exports.Crypto = void 0;
+exports.fixBackupKey = fixBackupKey;
+exports.isCryptoAvailable = isCryptoAvailable;
+exports.verificationMethods = void 0;
+var _anotherJson = _interopRequireDefault(require("another-json"));
+var _uuid = require("uuid");
+var _event = require("../@types/event");
+var _ReEmitter = require("../ReEmitter");
+var _logger = require("../logger");
+var _OlmDevice = require("./OlmDevice");
+var olmlib = _interopRequireWildcard(require("./olmlib"));
+var _DeviceList = require("./DeviceList");
+var _deviceinfo = require("./deviceinfo");
+var algorithms = _interopRequireWildcard(require("./algorithms"));
+var _CrossSigning = require("./CrossSigning");
+var _EncryptionSetup = require("./EncryptionSetup");
+var _SecretStorage = require("./SecretStorage");
+var _api = require("./api");
+var _OutgoingRoomKeyRequestManager = require("./OutgoingRoomKeyRequestManager");
+var _indexeddbCryptoStore = require("./store/indexeddb-crypto-store");
+var _QRCode = require("./verification/QRCode");
+var _SAS = require("./verification/SAS");
+var _key_passphrase = require("./key_passphrase");
+var _recoverykey = require("./recoverykey");
+var _VerificationRequest = require("./verification/request/VerificationRequest");
+var _InRoomChannel = require("./verification/request/InRoomChannel");
+var _ToDeviceChannel = require("./verification/request/ToDeviceChannel");
+var _IllegalMethod = require("./verification/IllegalMethod");
+var _errors = require("../errors");
+var _aes = require("./aes");
+var _dehydration = require("./dehydration");
+var _backup = require("./backup");
+var _room = require("../models/room");
+var _roomMember = require("../models/room-member");
+var _event2 = require("../models/event");
+var _client = require("../client");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _roomState = require("../models/room-state");
+var _utils = require("../utils");
+var _secretStorage = require("../secret-storage");
+var _deviceConverter = require("./device-converter");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/* re-exports for backwards compatibility */
+
+const DeviceVerification = _deviceinfo.DeviceInfo.DeviceVerification;
+const defaultVerificationMethods = {
+ [_QRCode.ReciprocateQRCode.NAME]: _QRCode.ReciprocateQRCode,
+ [_SAS.SAS.NAME]: _SAS.SAS,
+ // 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.
+ [_QRCode.SHOW_QR_CODE_METHOD]: _IllegalMethod.IllegalMethod,
+ [_QRCode.SCAN_QR_CODE_METHOD]: _IllegalMethod.IllegalMethod
+};
+
+/**
+ * verification method names
+ */
+// legacy export identifier
+const verificationMethods = {
+ RECIPROCATE_QR_CODE: _QRCode.ReciprocateQRCode.NAME,
+ SAS: _SAS.SAS.NAME
+};
+exports.verificationMethods = verificationMethods;
+function isCryptoAvailable() {
+ return Boolean(global.Olm);
+}
+const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
+
+/* eslint-disable camelcase */
+
+/**
+ * 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.
+ */
+
+/* eslint-enable camelcase */
+
+/* eslint-disable camelcase */
+
+/* eslint-enable camelcase */
+let CryptoEvent = /*#__PURE__*/function (CryptoEvent) {
+ CryptoEvent["DeviceVerificationChanged"] = "deviceVerificationChanged";
+ CryptoEvent["UserTrustStatusChanged"] = "userTrustStatusChanged";
+ CryptoEvent["UserCrossSigningUpdated"] = "userCrossSigningUpdated";
+ CryptoEvent["RoomKeyRequest"] = "crypto.roomKeyRequest";
+ CryptoEvent["RoomKeyRequestCancellation"] = "crypto.roomKeyRequestCancellation";
+ CryptoEvent["KeyBackupStatus"] = "crypto.keyBackupStatus";
+ CryptoEvent["KeyBackupFailed"] = "crypto.keyBackupFailed";
+ CryptoEvent["KeyBackupSessionsRemaining"] = "crypto.keyBackupSessionsRemaining";
+ CryptoEvent["KeySignatureUploadFailure"] = "crypto.keySignatureUploadFailure";
+ CryptoEvent["VerificationRequest"] = "crypto.verification.request";
+ CryptoEvent["Warning"] = "crypto.warning";
+ CryptoEvent["WillUpdateDevices"] = "crypto.willUpdateDevices";
+ CryptoEvent["DevicesUpdated"] = "crypto.devicesUpdated";
+ CryptoEvent["KeysChanged"] = "crossSigning.keysChanged";
+ return CryptoEvent;
+}({});
+exports.CryptoEvent = CryptoEvent;
+class Crypto extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * @returns The version of Olm.
+ */
+ static getOlmVersion() {
+ return _OlmDevice.OlmDevice.getOlmVersion();
+ }
+ /**
+ * 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.
+ */
+ constructor(baseApis, userId, deviceId, clientStore, cryptoStore, roomList, verificationMethods) {
+ super();
+ this.baseApis = baseApis;
+ this.userId = userId;
+ this.deviceId = deviceId;
+ this.clientStore = clientStore;
+ this.cryptoStore = cryptoStore;
+ this.roomList = roomList;
+ _defineProperty(this, "backupManager", void 0);
+ _defineProperty(this, "crossSigningInfo", void 0);
+ _defineProperty(this, "olmDevice", void 0);
+ _defineProperty(this, "deviceList", void 0);
+ _defineProperty(this, "dehydrationManager", void 0);
+ _defineProperty(this, "secretStorage", void 0);
+ _defineProperty(this, "reEmitter", void 0);
+ _defineProperty(this, "verificationMethods", void 0);
+ _defineProperty(this, "supportedAlgorithms", void 0);
+ _defineProperty(this, "outgoingRoomKeyRequestManager", void 0);
+ _defineProperty(this, "toDeviceVerificationRequests", void 0);
+ _defineProperty(this, "inRoomVerificationRequests", void 0);
+ _defineProperty(this, "trustCrossSignedDevices", true);
+ // the last time we did a check for the number of one-time-keys on the server.
+ _defineProperty(this, "lastOneTimeKeyCheck", null);
+ _defineProperty(this, "oneTimeKeyCheckInProgress", false);
+ // EncryptionAlgorithm instance for each room
+ _defineProperty(this, "roomEncryptors", new Map());
+ // map from algorithm to DecryptionAlgorithm instance, for each room
+ _defineProperty(this, "roomDecryptors", new Map());
+ _defineProperty(this, "deviceKeys", {});
+ // type: key
+ _defineProperty(this, "globalBlacklistUnverifiedDevices", false);
+ _defineProperty(this, "globalErrorOnUnknownDevices", true);
+ // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
+ // we received in the current sync.
+ _defineProperty(this, "receivedRoomKeyRequests", []);
+ _defineProperty(this, "receivedRoomKeyRequestCancellations", []);
+ // true if we are currently processing received room key requests
+ _defineProperty(this, "processingRoomKeyRequests", false);
+ // controls whether device tracking is delayed
+ // until calling encryptEvent or trackRoomDevices,
+ // or done immediately upon enabling room encryption.
+ _defineProperty(this, "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.
+ _defineProperty(this, "roomDeviceTrackingState", {});
+ // 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
+ _defineProperty(this, "lastNewSessionForced", new _utils.MapWithDefault(() => new _utils.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.
+ _defineProperty(this, "sendKeyRequestsImmediately", false);
+ _defineProperty(this, "oneTimeKeyCount", void 0);
+ _defineProperty(this, "needsNewFallback", void 0);
+ _defineProperty(this, "fallbackCleanup", void 0);
+ /*
+ * Event handler for DeviceList's userNewDevices event
+ */
+ _defineProperty(this, "onDeviceListUserCrossSigningUpdated", async userId => {
+ 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));
+ }
+ });
+ _defineProperty(this, "onMembership", (event, member, oldMembership) => {
+ try {
+ this.onRoomMembership(event, member, oldMembership);
+ } catch (e) {
+ _logger.logger.error("Error handling membership change:", e);
+ }
+ });
+ _defineProperty(this, "onToDeviceEvent", event => {
+ try {
+ _logger.logger.log(`received to-device ${event.getType()} from: ` + `${event.getSender()} id: ${event.getContent()[_event.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(_event2.MatrixEventEvent.Decrypted, ev => {
+ this.onToDeviceEvent(ev);
+ });
+ }
+ } catch (e) {
+ _logger.logger.error("Error handling toDeviceEvent:", e);
+ }
+ });
+ /**
+ * 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
+ */
+ _defineProperty(this, "onTimelineEvent", (event, room, atStart, removed, {
+ liveEvent = true
+ } = {}) => {
+ if (!_InRoomChannel.InRoomChannel.validateEvent(event, this.baseApis)) {
+ return;
+ }
+ const createRequest = event => {
+ const channel = new _InRoomChannel.InRoomChannel(this.baseApis, event.getRoomId());
+ return new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ };
+ this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent);
+ });
+ this.reEmitter = new _ReEmitter.TypedReEmitter(this);
+ if (verificationMethods) {
+ this.verificationMethods = new Map();
+ for (const method of verificationMethods) {
+ if (typeof method === "string") {
+ if (defaultVerificationMethods[method]) {
+ this.verificationMethods.set(method, defaultVerificationMethods[method]);
+ }
+ } else if (method["NAME"]) {
+ this.verificationMethods.set(method["NAME"], method);
+ } else {
+ _logger.logger.warn(`Excluding unknown verification method ${method}`);
+ }
+ }
+ } else {
+ this.verificationMethods = new Map(Object.entries(defaultVerificationMethods));
+ }
+ this.backupManager = new _backup.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.secretStorage.get("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.secretStorage.getKey();
+ await this.secretStorage.store("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.OlmDevice(cryptoStore);
+ this.deviceList = new _DeviceList.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.OutgoingRoomKeyRequestManager(baseApis, this.deviceId, this.cryptoStore);
+ this.toDeviceVerificationRequests = new _ToDeviceChannel.ToDeviceRequests();
+ this.inRoomVerificationRequests = new _InRoomChannel.InRoomRequests();
+ const cryptoCallbacks = this.baseApis.cryptoCallbacks || {};
+ const cacheCallbacks = (0, _CrossSigning.createCryptoStoreCacheCallbacks)(cryptoStore, this.olmDevice);
+ this.crossSigningInfo = new _CrossSigning.CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks);
+ // Yes, we pass the client twice here: see SecretStorage
+ this.secretStorage = new _SecretStorage.SecretStorage(baseApis, cryptoCallbacks, baseApis);
+ this.dehydrationManager = new _dehydration.DehydrationManager(this);
+
+ // Assuming no app-supplied callback, default to getting from SSSS.
+ if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) {
+ cryptoCallbacks.getCrossSigningKey = async type => {
+ return _CrossSigning.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.
+ */
+ async init({
+ exportedOlmDevice,
+ pickleKey
+ } = {}) {
+ _logger.logger.log("Crypto: initialising Olm...");
+ await global.Olm.init();
+ _logger.logger.log(exportedOlmDevice ? "Crypto: initialising Olm device from exported device..." : "Crypto: initialising Olm device...");
+ await this.olmDevice.init({
+ fromExportedDevice: exportedOlmDevice,
+ pickleKey
+ });
+ _logger.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.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.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.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.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.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
+ */
+ getTrustCrossSignedDevices() {
+ return this.trustCrossSignedDevices;
+ }
+
+ /**
+ * @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}.
+ */
+ getCryptoTrustCrossSignedDevices() {
+ return this.trustCrossSignedDevices;
+ }
+
+ /**
+ * See getCryptoTrustCrossSignedDevices
+ *
+ * @param val - True to trust cross-signed devices
+ */
+ setTrustCrossSignedDevices(val) {
+ 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);
+ }
+ }
+ }
+ }
+
+ /**
+ * @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
+ */
+ setCryptoTrustCrossSignedDevices(val) {
+ this.setTrustCrossSignedDevices(val);
+ }
+
+ /**
+ * 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.
+ */
+ async createRecoveryKeyFromPassphrase(password) {
+ const decryption = new global.Olm.PkDecryption();
+ try {
+ const keyInfo = {};
+ if (password) {
+ const derivation = await (0, _key_passphrase.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 = (0, _recoverykey.encodeRecoveryKey)(privateKey);
+ return {
+ keyInfo: 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
+ */
+ async userHasCrossSigningKeys() {
+ 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
+ */
+ async isCrossSigningReady() {
+ 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
+ */
+ async isSecretStorageReady() {
+ 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.
+ */
+ async bootstrapCrossSigning({
+ authUploadDeviceSigningKeys,
+ setupNewCrossSigning
+ } = {}) {
+ _logger.logger.log("Bootstrapping cross-signing");
+ const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
+ const builder = new _EncryptionSetup.EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
+ const crossSigningInfo = new _CrossSigning.CrossSigningInfo(this.userId, builder.crossSigningCallbacks, builder.crossSigningCallbacks);
+
+ // Reset the cross-signing keys
+ const resetCrossSigning = async () => {
+ 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.logger.log({
+ setupNewCrossSigning,
+ publicKeysOnDevice,
+ privateKeysInCache,
+ privateKeysInStorage,
+ privateKeysExistSomewhere
+ });
+ if (!privateKeysExistSomewhere || setupNewCrossSigning) {
+ _logger.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.logger.log("Cross-signing public keys trusted and private keys found locally");
+ } else if (privateKeysInStorage) {
+ _logger.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.ServerSideSecretStorageImpl(builder.accountDataClientAdapter, builder.ssssCryptoCallbacks);
+ if (await secretStorage.hasKey()) {
+ _logger.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 _CrossSigning.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.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
+ async bootstrapSecretStorage({
+ createSecretStorageKey = async () => ({}),
+ keyBackupInfo,
+ setupNewKeyBackup,
+ setupNewSecretStorage,
+ getKeyBackupPassphrase
+ } = {}) {
+ _logger.logger.log("Bootstrapping Secure Secret Storage");
+ const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
+ const builder = new _EncryptionSetup.EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
+ const secretStorage = new _secretStorage.ServerSideSecretStorageImpl(builder.accountDataClientAdapter, builder.ssssCryptoCallbacks);
+
+ // the ID of the new SSSS key, if we create one
+ let newKeyId = null;
+
+ // create a new SSSS key and set it as default
+ const createSSSS = async (opts, privateKey) => {
+ if (privateKey) {
+ opts.key = privateKey;
+ }
+ const {
+ keyId,
+ keyInfo
+ } = await secretStorage.addKey(_secretStorage.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, keyInfo) => {
+ 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 (0, _aes.calculateKeyCheck)(privateKey);
+ keyInfo.iv = iv;
+ keyInfo.mac = mac;
+ await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
+ }
+ }
+ };
+ const signKeyBackupWithCrossSigning = async keyBackupAuthData => {
+ if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) {
+ try {
+ _logger.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.logger.error("Signing key backup with cross-signing keys failed", e);
+ }
+ } else {
+ _logger.logger.warn("Cross-signing keys not available, skipping signature on key backup");
+ }
+ };
+ const oldSSSSKey = await this.secretStorage.getKey();
+ const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null];
+ const storageExists = !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === _secretStorage.SECRET_STORAGE_ALGORITHM_V1_AES;
+
+ // Log all relevant state for easier parsing of debug logs.
+ _logger.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.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 = {},
+ privateKey
+ } = await createSecretStorageKey();
+ newKeyId = await createSSSS(keyInfo, privateKey);
+ } else if (!storageExists && keyBackupInfo) {
+ // we have an existing backup, but no SSSS
+ _logger.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 = {};
+ 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.logger.log("Secret storage exists");
+ if (oldKeyInfo && oldKeyInfo.algorithm === _secretStorage.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.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 _CrossSigning.CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage);
+ }
+ if (setupNewKeyBackup && !keyBackupInfo) {
+ _logger.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 = (0, _recoverykey.decodeRecoveryKey)(info.recovery_key);
+ await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
+
+ // create keyBackupInfo object to add to builder
+ const data = {
+ 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.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.logger.error("Key backup is enabled but couldn't get key backup key!");
+ return;
+ }
+ _logger.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.logger.log("Secure Secret Storage ready");
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
+ */
+ addSecretStorageKey(algorithm, opts, keyID) {
+ return this.secretStorage.addKey(algorithm, opts, keyID);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
+ */
+ hasSecretStorageKey(keyID) {
+ return this.secretStorage.hasKey(keyID);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getKey}.
+ */
+ getSecretStorageKey(keyID) {
+ return this.secretStorage.getKey(keyID);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
+ */
+ storeSecret(name, secret, keys) {
+ return this.secretStorage.store(name, secret, keys);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
+ */
+ getSecret(name) {
+ return this.secretStorage.get(name);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
+ */
+ isSecretStored(name) {
+ return this.secretStorage.isStored(name);
+ }
+ requestSecret(name, devices) {
+ if (!devices) {
+ devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId));
+ }
+ return this.secretStorage.request(name, devices);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
+ */
+ getDefaultSecretStorageKeyId() {
+ return this.secretStorage.getDefaultKeyId();
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
+ */
+ setDefaultSecretStorageKeyId(k) {
+ return this.secretStorage.setDefaultKeyId(k);
+ }
+
+ /**
+ * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
+ */
+ checkSecretStorageKey(key, info) {
+ 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
+ */
+ checkSecretStoragePrivateKey(privateKey, expectedPublicKey) {
+ let decryption = 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
+ */
+ async getSessionBackupPrivateKey() {
+ let key = await new Promise(resolve => {
+ // TODO types
+ this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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 (0, _aes.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
+ */
+ async storeSessionBackupPrivateKey(key) {
+ 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 (0, _aes.encryptAES)(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1");
+ return this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.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
+ */
+ checkCrossSigningPrivateKey(privateKey, expectedPublicKey) {
+ let signing = 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.
+ */
+ async afterCrossSigningLocalKeyChange() {
+ _logger.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.logger.info(`Starting background key sig upload for ${this.deviceId}`);
+ const upload = ({
+ shouldEmit = false
+ }) => {
+ 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 _errors.KeySignatureUploadError("Key upload failed", {
+ failures
+ });
+ }
+ _logger.logger.info(`Finished background key sig upload for ${this.deviceId}`);
+ }).catch(e => {
+ _logger.logger.error(`Error during background key sig upload for ${this.deviceId}`, e);
+ });
+ };
+ upload({
+ shouldEmit: true
+ });
+ const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications;
+ if (shouldUpgradeCb) {
+ _logger.logger.info("Starting device verification upgrade");
+
+ // Check all users for signatures if upgrade callback present
+ // FIXME: do this in batches
+ const users = {};
+ for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) {
+ const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, _CrossSigning.CrossSigningInfo.fromStorage(crossSigningInfo, userId));
+ if (upgradeInfo) {
+ users[userId] = upgradeInfo;
+ }
+ }
+ if (Object.keys(users).length > 0) {
+ _logger.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.logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e);
+ }
+ }
+ _logger.logger.info("Finished device verification upgrade");
+ }
+ _logger.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
+ */
+ async checkForDeviceVerificationUpgrade(userId, crossSigningInfo) {
+ // 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.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
+ */
+ async checkForValidDeviceSignature(userId, key, devices) {
+ const deviceIds = [];
+ 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
+ */
+ getCrossSigningKeyId(type = _api.CrossSigningKey.Master) {
+ return Promise.resolve(this.getCrossSigningId(type));
+ }
+
+ // old name, for backwards compatibility
+ getCrossSigningId(type) {
+ 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.
+ */
+ getStoredCrossSigningForUser(userId) {
+ return this.deviceList.getStoredCrossSigningForUser(userId);
+ }
+
+ /**
+ * Check whether a given user is trusted.
+ *
+ * @param userId - The ID of the user to check.
+ *
+ * @returns
+ */
+ checkUserTrust(userId) {
+ const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (!userCrossSigning) {
+ return new _CrossSigning.UserTrustLevel(false, false, false);
+ }
+ return this.crossSigningInfo.checkUserTrust(userCrossSigning);
+ }
+
+ /**
+ * Check whether a given device is trusted.
+ *
+ * @param userId - The ID of the user whose device is to be checked.
+ * @param deviceId - The ID of the device to check
+ */
+ async getDeviceVerificationStatus(userId, deviceId) {
+ const device = this.deviceList.getStoredDevice(userId, deviceId);
+ if (!device) {
+ return null;
+ }
+ return this.checkDeviceInfoTrust(userId, device);
+ }
+
+ /**
+ * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
+ */
+ checkDeviceTrust(userId, deviceId) {
+ 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
+ *
+ * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
+ */
+ checkDeviceInfoTrust(userId, device) {
+ 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 _CrossSigning.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
+ */
+ checkIfOwnDeviceCrossSigned(deviceId) {
+ 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;
+ }
+ /**
+ * 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.
+ */
+ async checkOwnCrossSigningTrust({
+ allowPrivateKeyRequests = false
+ } = {}) {
+ 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.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.logger.info("Got new master public key", seenPubkey);
+ }
+ if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) {
+ _logger.logger.info("Attempting to retrieve cross-signing master private key");
+ let signing = 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.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 = {};
+ if (selfSigningChanged) {
+ _logger.logger.info("Got new self-signing key", newCrossSigning.getId("self_signing"));
+ }
+ if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) {
+ _logger.logger.info("Attempting to retrieve cross-signing self-signing private key");
+ let signing = null;
+ try {
+ const ret = await this.crossSigningInfo.getCrossSigningKey("self_signing", newCrossSigning.getId("self_signing"));
+ signing = ret[1];
+ _logger.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.logger.info("Got new user-signing key", newCrossSigning.getId("user_signing"));
+ }
+ if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) {
+ _logger.logger.info("Attempting to retrieve cross-signing user-signing private key");
+ let signing = null;
+ try {
+ const ret = await this.crossSigningInfo.getCrossSigningKey("user_signing", newCrossSigning.getId("user_signing"));
+ signing = ret[1];
+ _logger.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({}, masterKey, {
+ signatures: {
+ [this.userId]: {
+ ["ed25519:" + this.deviceId]: deviceSig
+ }
+ }
+ });
+ }
+ const keysToUpload = Object.keys(keySignatures);
+ if (keysToUpload.length) {
+ const upload = ({
+ shouldEmit = false
+ }) => {
+ _logger.logger.info(`Starting background key sig upload for ${keysToUpload}`);
+ return this.baseApis.uploadKeySignatures({
+ [this.userId]: keySignatures
+ }).then(response => {
+ const {
+ failures
+ } = response || {};
+ _logger.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 _errors.KeySignatureUploadError("Key upload failed", {
+ failures
+ });
+ }
+ }).catch(e => {
+ _logger.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
+ */
+ async storeTrustedSelfKeys(keys) {
+ if (keys) {
+ this.crossSigningInfo.setKeys(keys);
+ } else {
+ this.crossSigningInfo.clearKeys();
+ }
+ await this.cryptoStore.doTxn("readwrite", [_indexeddbCryptoStore.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
+ */
+ async checkDeviceVerifications(userId) {
+ const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications;
+ if (!shouldUpgradeCb) {
+ // Upgrading skipped when callback is not present.
+ return;
+ }
+ _logger.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.logger.info(`Finished device verification upgrade for ${userId}`);
+ }
+
+ /**
+ */
+ enableLazyLoading() {
+ 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
+ */
+ registerEventHandlers(eventEmitter) {
+ eventEmitter.on(_roomMember.RoomMemberEvent.Membership, this.onMembership);
+ eventEmitter.on(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ eventEmitter.on(_room.RoomEvent.Timeline, this.onTimelineEvent);
+ eventEmitter.on(_event2.MatrixEventEvent.Decrypted, this.onTimelineEvent);
+ }
+
+ /**
+ * @deprecated this does nothing and will be removed in a future version
+ */
+ start() {
+ _logger.logger.warn("MatrixClient.crypto.start() is deprecated");
+ }
+
+ /** Stop background processes related to crypto */
+ stop() {
+ this.outgoingRoomKeyRequestManager.stop();
+ this.deviceList.stop();
+ this.dehydrationManager.stop();
+ }
+
+ /**
+ * Get the Ed25519 key for this device
+ *
+ * @returns base64-encoded ed25519 key.
+ */
+ getDeviceEd25519Key() {
+ return this.olmDevice.deviceEd25519Key;
+ }
+
+ /**
+ * Get the Curve25519 key for this device
+ *
+ * @returns base64-encoded curve25519 key.
+ */
+ getDeviceCurve25519Key() {
+ 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 Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
+ */
+ setGlobalBlacklistUnverifiedDevices(value) {
+ this.globalBlacklistUnverifiedDevices = value;
+ }
+
+ /**
+ * @returns whether to blacklist all unverified devices by default
+ *
+ * @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
+ */
+ getGlobalBlacklistUnverifiedDevices() {
+ return this.globalBlacklistUnverifiedDevices;
+ }
+
+ /**
+ * Upload the device keys to the homeserver.
+ * @returns A promise that will resolve when the keys are uploaded.
+ */
+ uploadDeviceKeys() {
+ 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
+ });
+ });
+ }
+ getNeedsNewFallback() {
+ return !!this.needsNewFallback;
+ }
+
+ // check if it's time to upload one-time keys, and do so if so.
+ maybeUploadOneTimeKeys() {
+ // 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 => {
+ while (keyLimit > keyCount || this.getNeedsNewFallback()) {
+ // Ask olm to generate new one time keys, then upload them to synapse.
+ if (keyLimit > keyCount) {
+ _logger.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.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.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.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
+ async uploadOneTimeKeys() {
+ const promises = [];
+ let fallbackJson;
+ 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.needsNewFallback = false;
+ }
+ const oneTimeKeys = await this.olmDevice.getOneTimeKeys();
+ const oneTimeJson = {};
+ 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 = {
+ 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}`.
+ */
+ downloadKeys(userIds, forceDownload) {
+ 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.
+ */
+ getStoredDevicesForUser(userId) {
+ return this.deviceList.getStoredDevicesForUser(userId);
+ }
+
+ /**
+ * Get the device information for the given list of users.
+ *
+ * @param userIds - The users to fetch.
+ * @param downloadUncached - If true, download the device list for users whose device list we are not
+ * currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
+ *
+ * @returns A map `{@link DeviceMap}`.
+ */
+ async getUserDeviceInfo(userIds, downloadUncached = false) {
+ const deviceMapByUserId = new Map();
+ // Keep the users without device to download theirs keys
+ const usersWithoutDeviceInfo = [];
+ for (const userId of userIds) {
+ const deviceInfos = await this.getStoredDevicesForUser(userId);
+ // If there are device infos for a userId, we transform it into a map
+ // Else, the keys will be downloaded after
+ if (deviceInfos) {
+ const deviceMap = new Map(
+ // Convert DeviceInfo to Device
+ deviceInfos.map(deviceInfo => [deviceInfo.deviceId, (0, _deviceConverter.deviceInfoToDevice)(deviceInfo, userId)]));
+ deviceMapByUserId.set(userId, deviceMap);
+ } else {
+ usersWithoutDeviceInfo.push(userId);
+ }
+ }
+
+ // Download device info for users without device infos
+ if (downloadUncached && usersWithoutDeviceInfo.length > 0) {
+ const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo);
+ newDeviceInfoMap.forEach((deviceInfoMap, userId) => {
+ const deviceMap = new Map();
+ // Convert DeviceInfo to Device
+ deviceInfoMap.forEach((deviceInfo, deviceId) => deviceMap.set(deviceId, (0, _deviceConverter.deviceInfoToDevice)(deviceInfo, userId)));
+
+ // Put the new device infos into the returned map
+ deviceMapByUserId.set(userId, deviceMap);
+ });
+ }
+ return deviceMapByUserId;
+ }
+
+ /**
+ * Get the stored keys for a single device
+ *
+ *
+ * @returns device, or undefined
+ * if we don't know about this device
+ */
+ getStoredDevice(userId, deviceId) {
+ 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.
+ */
+ saveDeviceList(delay) {
+ 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
+ */
+ async setDeviceVerification(userId, deviceId, verified = null, blocked = null, known = null, keys) {
+ // 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.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
+ }) => {
+ _logger.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 _errors.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; // 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.logger.info("Own device " + deviceId + " marked verified: signing");
+
+ // Signing only needed if other device not already signed
+ let device;
+ const deviceTrust = this.checkDeviceTrust(userId, deviceId);
+ if (deviceTrust.isCrossSigningVerified()) {
+ _logger.logger.log(`Own device ${deviceId} already cross-signing verified`);
+ } else {
+ device = await this.crossSigningInfo.signDevice(userId, _deviceinfo.DeviceInfo.fromStorage(dev, deviceId));
+ }
+ if (device) {
+ const upload = async ({
+ shouldEmit = false
+ }) => {
+ _logger.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 _errors.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.DeviceInfo.fromStorage(dev, deviceId);
+ this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj);
+ return deviceObj;
+ }
+ findVerificationRequestDMInProgress(roomId) {
+ return this.inRoomVerificationRequests.findRequestInProgress(roomId);
+ }
+ getVerificationRequestsToDeviceInProgress(userId) {
+ return this.toDeviceVerificationRequests.getRequestsInProgress(userId);
+ }
+ requestVerificationDM(userId, roomId) {
+ const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId);
+ if (existingRequest) {
+ return Promise.resolve(existingRequest);
+ }
+ const channel = new _InRoomChannel.InRoomChannel(this.baseApis, roomId, userId);
+ return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests);
+ }
+ requestVerification(userId, devices) {
+ 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.ToDeviceChannel(this.baseApis, userId, devices, _ToDeviceChannel.ToDeviceChannel.makeTransactionId());
+ return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests);
+ }
+ async requestVerificationWithChannel(userId, channel, requestsMap) {
+ let request = new _VerificationRequest.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.logger.log(`Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`);
+ requestsMap.setRequestByChannel(channel, request);
+ }
+ return request;
+ }
+ beginKeyVerification(method, userId, deviceId, transactionId = null) {
+ let request;
+ 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.ToDeviceChannel.makeTransactionId();
+ const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId);
+ request = new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request);
+ }
+ return request.beginKeyVerification(method, {
+ userId,
+ deviceId
+ });
+ }
+ async legacyDeviceVerification(userId, deviceId, method) {
+ const transactionId = _ToDeviceChannel.ToDeviceChannel.makeTransactionId();
+ const channel = new _ToDeviceChannel.ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId);
+ const request = new _VerificationRequest.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
+ */
+ async getOlmSessionsForUser(userId) {
+ const devices = this.getStoredDevicesForUser(userId) || [];
+ const result = {};
+ 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
+ */
+ getEventSenderDeviceInfo(event) {
+ 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.logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device");
+ return null;
+ }
+ if (claimedKey !== device.getFingerprint()) {
+ _logger.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)
+ */
+ getEventEncryptionInfo(event) {
+ const ret = {};
+ ret.senderKey = event.getSenderKey() ?? undefined;
+ ret.algorithm = event.getWireContent().algorithm;
+ if (!ret.senderKey || !ret.algorithm) {
+ ret.encrypted = false;
+ return ret;
+ }
+ 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.logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device");
+ ret.mismatchedSender = true;
+ }
+ if (ret.sender && claimedKey !== ret.sender.getFingerprint()) {
+ _logger.logger.warn("Event " + event.getId() + " claims ed25519 key " + claimedKey + "but sender device has key " + ret.sender.getFingerprint());
+ ret.mismatchedSender = true;
+ }
+ return ret;
+ }
+
+ /**
+ * 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.
+ */
+ forceDiscardSession(roomId) {
+ 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).
+ */
+ async setRoomEncryption(roomId, config, inhibitDeviceQuery) {
+ 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.
+ */
+ async setRoomEncryptionImpl(room, config) {
+ 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.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.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 = 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.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 => {
+ room.off(_roomState.RoomStateEvent.Update, onState);
+ if (room.membersLoaded()) {
+ this.trackRoomDevicesImpl(room).catch(e => {
+ _logger.logger.error(`Error enabling device tracking in ${roomId}`, e);
+ });
+ }
+ };
+ room.on(_roomState.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.
+ */
+ trackRoomDevices(roomId) {
+ 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
+ */
+ trackRoomDevicesImpl(room) {
+ const roomId = room.roomId;
+ const trackMembers = async () => {
+ // not an encrypted room
+ if (!this.roomEncryptors.has(roomId)) {
+ return;
+ }
+ _logger.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
+ * `IOlmSessionResult`
+ */
+ ensureOlmSessionsForUsers(users, force) {
+ // map user Id → DeviceInfo[]
+ const devicesByUser = new Map();
+ for (const userId of users) {
+ const userDevices = [];
+ 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
+ */
+ async exportRoomKeys() {
+ const exportedSessions = [];
+ await this.cryptoStore.doTxn("readonly", [_indexeddbCryptoStore.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
+ */
+ importRoomKeys(keys, opts = {}) {
+ let successes = 0;
+ let failures = 0;
+ const total = keys.length;
+ function updateProgress() {
+ opts.progressCallback?.({
+ stage: "load_keys",
+ successes,
+ failures,
+ total
+ });
+ }
+ return Promise.all(keys.map(key => {
+ if (!key.room_id || !key.algorithm) {
+ _logger.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
+ */
+ countSessionsNeedingBackup() {
+ 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
+ */
+ prepareToEncrypt(room) {
+ 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
+ */
+ async encryptEvent(event, room) {
+ 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);
+ 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.
+ */
+ async decryptEvent(event) {
+ 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 _event2.MatrixEvent(_objectSpread({
+ room_id: event.getRoomId()
+ }, event.getUnsigned().redacted_because));
+ let redactedBecause = event.getUnsigned().redacted_because;
+ if (redactionEvent.isEncrypted()) {
+ try {
+ const decryptedEvent = await this.decryptEvent(redactionEvent);
+ redactedBecause = decryptedEvent.clearEvent;
+ } catch (e) {
+ _logger.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 that device lists have
+ * been changed.
+ *
+ * @param deviceLists - device_lists field from /sync
+ */
+ async processDeviceLists(deviceLists) {
+ // 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(deviceLists);
+ }
+
+ /**
+ * 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
+ */
+ requestRoomKey(requestBody, recipients, resend = false) {
+ 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.logger.error("Error requesting key for event", e);
+ });
+ }
+
+ /**
+ * Cancel any earlier room key request
+ *
+ * @param requestBody - parameters to match for cancellation
+ */
+ cancelRoomKeyRequest(requestBody) {
+ this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch(e => {
+ _logger.logger.warn("Error clearing pending room key requests", e);
+ });
+ }
+
+ /**
+ * Re-send any outgoing key requests, eg after verification
+ * @returns
+ */
+ async cancelAndResendAllOutgoingKeyRequests() {
+ 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
+ */
+ async onCryptoEvent(room, event) {
+ const content = event.getContent();
+ await this.setRoomEncryptionImpl(room, content);
+ }
+
+ /**
+ * Called before the result of a sync is processed
+ *
+ * @param syncData - the data from the 'MatrixClient.sync' event
+ */
+ async onSyncWillProcess(syncData) {
+ 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.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
+ */
+ async onSyncCompleted(syncData) {
+ 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
+ */
+ async evalDeviceListChanges(deviceLists) {
+ 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
+ */
+ async getTrackedE2eUsers() {
+ const e2eUserIds = [];
+ 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
+ */
+ getTrackedE2eRooms() {
+ 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.
+ */
+ async encryptAndSendToDevices(userDeviceInfoArr, payload) {
+ const toDeviceBatch = {
+ eventType: _event.EventType.RoomMessageEncrypted,
+ batch: []
+ };
+ try {
+ await Promise.all(userDeviceInfoArr.map(async ({
+ userId,
+ deviceInfo
+ }) => {
+ const deviceId = deviceInfo.deviceId;
+ const encryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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.logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`);
+ return false;
+ }
+ });
+ try {
+ await this.baseApis.queueToDevice(toDeviceBatch);
+ } catch (e) {
+ _logger.logger.error("sendToDevice failed", e);
+ throw e;
+ }
+ } catch (e) {
+ _logger.logger.error("encryptAndSendToDevices promises failed", e);
+ throw e;
+ }
+ }
+ async preprocessToDeviceMessages(events) {
+ // 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 === _event.EventType.RoomMessageEncrypted && !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)) {
+ _logger.logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ /**
+ * Stores the current one_time_key count which will be handled later (in a call of
+ * onSyncCompleted).
+ *
+ * @param currentCount - The current count of one_time_keys to be stored
+ */
+ updateOneTimeKeyCount(currentCount) {
+ if (isFinite(currentCount)) {
+ this.oneTimeKeyCount = currentCount;
+ } else {
+ throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
+ }
+ }
+ processKeyCounts(oneTimeKeysCounts, unusedFallbackKeys) {
+ if (oneTimeKeysCounts !== undefined) {
+ this.updateOneTimeKeyCount(oneTimeKeysCounts["signed_curve25519"] || 0);
+ }
+ if (unusedFallbackKeys !== undefined) {
+ // If `unusedFallbackKeys` is defined, that means `device_unused_fallback_key_types`
+ // is present in the sync response, which indicates that the server supports fallback keys.
+ //
+ // If there's no unused signed_curve25519 fallback key, we need a new one.
+ this.needsNewFallback = !unusedFallbackKeys.includes("signed_curve25519");
+ }
+ return Promise.resolve();
+ }
+ /**
+ * Handle a key event
+ *
+ * @internal
+ * @param event - key event
+ */
+ onRoomKeyEvent(event) {
+ const content = event.getContent();
+ if (!content.room_id || !content.algorithm) {
+ _logger.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
+ */
+ onRoomKeyWithheldEvent(event) {
+ const content = event.getContent();
+ if (content.code !== "m.no_olm" && (!content.room_id || !content.session_id) || !content.algorithm || !content.sender_key) {
+ _logger.logger.error("key withheld event is missing fields");
+ return;
+ }
+ _logger.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
+ */
+ onKeyVerificationMessage(event) {
+ if (!_ToDeviceChannel.ToDeviceChannel.validateEvent(event, this.baseApis)) {
+ return;
+ }
+ const createRequest = event => {
+ if (!_ToDeviceChannel.ToDeviceChannel.canCreateRequest(_ToDeviceChannel.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.ToDeviceChannel(this.baseApis, userId, [deviceId]);
+ return new _VerificationRequest.VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ };
+ this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest);
+ }
+ async handleVerificationEvent(event, requestsMap, createRequest, isLiveEvent = true) {
+ // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it.
+ if (event.isSending() && event.status != _event2.EventStatus.SENT) {
+ let eventIdListener;
+ let statusListener;
+ try {
+ await new Promise((resolve, reject) => {
+ eventIdListener = resolve;
+ statusListener = () => {
+ if (event.status == _event2.EventStatus.CANCELLED) {
+ reject(new Error("Event status set to CANCELLED."));
+ }
+ };
+ event.once(_event2.MatrixEventEvent.LocalEventIdReplaced, eventIdListener);
+ event.on(_event2.MatrixEventEvent.Status, statusListener);
+ });
+ } catch (err) {
+ _logger.logger.error("error while waiting for the verification event to be sent: ", err);
+ return;
+ } finally {
+ event.removeListener(_event2.MatrixEventEvent.LocalEventIdReplaced, eventIdListener);
+ event.removeListener(_event2.MatrixEventEvent.Status, statusListener);
+ }
+ }
+ let request = 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.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.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
+ */
+ async onToDeviceBadEncrypted(event) {
+ const content = event.getWireContent();
+ const sender = event.getSender();
+ const algorithm = content.algorithm;
+ const deviceKey = content.sender_key;
+ this.baseApis.emit(_client.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 = () => {
+ 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.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.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 = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key,
+ ciphertext: {},
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ };
+ 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
+ */
+ onRoomMembership(event, member, oldMembership) {
+ // 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.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.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
+ */
+ onRoomKeyRequestEvent(event) {
+ 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
+ */
+ async processReceivedRoomKeyRequests() {
+ 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.logger.error(`Error processing room key requsts: ${e}`);
+ } finally {
+ this.processingRoomKeyRequests = false;
+ }
+ }
+
+ /**
+ * Helper for processReceivedRoomKeyRequests
+ *
+ */
+ async processReceivedRoomKeyRequest(req) {
+ const userId = req.userId;
+ const deviceId = req.deviceId;
+ const body = req.requestBody;
+ const roomId = body.room_id;
+ const alg = body.algorithm;
+ _logger.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.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.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.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.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.logger.log(`room key request for unencrypted room ${roomId}`);
+ return;
+ }
+ const decryptor = this.roomDecryptors.get(roomId).get(alg);
+ if (!decryptor) {
+ _logger.logger.log(`room key request for unknown alg ${alg} in room ${roomId}`);
+ return;
+ }
+ if (!(await decryptor.hasKeysForKeyRequest(req))) {
+ _logger.logger.log(`room key request for unknown session ${roomId} / ` + body.session_id);
+ return;
+ }
+ req.share = () => {
+ decryptor.shareKeysWithDevice(req);
+ };
+
+ // if the device is verified already, share the keys
+ if (this.checkDeviceTrust(userId, deviceId).isVerified()) {
+ _logger.logger.log("device is already verified: sharing keys");
+ req.share();
+ return;
+ }
+ this.emit(CryptoEvent.RoomKeyRequest, req);
+ }
+
+ /**
+ * Helper for processReceivedRoomKeyRequests
+ *
+ */
+ async processReceivedRoomKeyRequestCancellation(cancellation) {
+ _logger.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 `DecryptionError` if the algorithm is unknown
+ */
+ getRoomDecryptor(roomId, algorithm) {
+ let decryptors;
+ let alg;
+ if (roomId) {
+ decryptors = this.roomDecryptors.get(roomId);
+ if (!decryptors) {
+ decryptors = new Map();
+ 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
+ */
+ getRoomDecryptors(algorithm) {
+ const decryptors = [];
+ 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
+ */
+ async signObject(obj) {
+ 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.default.stringify(obj));
+ obj.signatures = (0, _utils.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.
+ *
+ */
+exports.Crypto = Crypto;
+function fixBackupKey(key) {
+ 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
+ */
+class IncomingRoomKeyRequest {
+ constructor(event) {
+ /** user requesting the key */
+ _defineProperty(this, "userId", void 0);
+ /** device requesting the key */
+ _defineProperty(this, "deviceId", void 0);
+ /** unique id for the request */
+ _defineProperty(this, "requestId", void 0);
+ _defineProperty(this, "requestBody", void 0);
+ /**
+ * callback which, when called, will ask
+ * the relevant crypto algorithm implementation to share the keys for
+ * this request.
+ */
+ _defineProperty(this, "share", void 0);
+ 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 = () => {
+ throw new Error("don't know how to share keys for this request yet");
+ };
+ }
+}
+
+/**
+ * Represents a received m.room_key_request cancellation
+ */
+exports.IncomingRoomKeyRequest = IncomingRoomKeyRequest;
+class IncomingRoomKeyRequestCancellation {
+ constructor(event) {
+ /** user requesting the cancellation */
+ _defineProperty(this, "userId", void 0);
+ /** device requesting the cancellation */
+ _defineProperty(this, "deviceId", void 0);
+ /** unique id for the request to be cancelled */
+ _defineProperty(this, "requestId", void 0);
+ 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. \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js
new file mode 100644
index 0000000000..3f4d3bbcba
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js
@@ -0,0 +1,69 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.deriveKey = deriveKey;
+exports.keyFromAuthData = keyFromAuthData;
+exports.keyFromPassphrase = keyFromPassphrase;
+var _randomstring = require("../randomstring");
+var _crypto = require("./crypto");
+/*
+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.
+*/
+
+const DEFAULT_ITERATIONS = 500000;
+const DEFAULT_BITSIZE = 256;
+
+/* eslint-disable camelcase */
+
+/* eslint-enable camelcase */
+
+function keyFromAuthData(authData, password) {
+ 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);
+}
+async function keyFromPassphrase(password) {
+ if (!global.Olm) {
+ throw new Error("Olm is not available");
+ }
+ const salt = (0, _randomstring.randomString)(32);
+ const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE);
+ return {
+ key,
+ salt,
+ iterations: DEFAULT_ITERATIONS
+ };
+}
+async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) {
+ if (!_crypto.subtleCrypto || !_crypto.TextEncoder) {
+ throw new Error("Password-based backup is not available on this platform");
+ }
+ const key = await _crypto.subtleCrypto.importKey("raw", new _crypto.TextEncoder().encode(password), {
+ name: "PBKDF2"
+ }, false, ["deriveBits"]);
+ const keybits = await _crypto.subtleCrypto.deriveBits({
+ name: "PBKDF2",
+ salt: new _crypto.TextEncoder().encode(salt),
+ iterations: iterations,
+ hash: "SHA-512"
+ }, key, numBits);
+ return new Uint8Array(keybits);
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js
new file mode 100644
index 0000000000..ea397f0c0e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js
@@ -0,0 +1,480 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.OLM_ALGORITHM = exports.MEGOLM_BACKUP_ALGORITHM = exports.MEGOLM_ALGORITHM = void 0;
+exports.decodeBase64 = decodeBase64;
+exports.encodeBase64 = encodeBase64;
+exports.encodeUnpaddedBase64 = encodeUnpaddedBase64;
+exports.encryptMessageForDevice = encryptMessageForDevice;
+exports.ensureOlmSessionsForDevices = ensureOlmSessionsForDevices;
+exports.getExistingOlmSessions = getExistingOlmSessions;
+exports.isOlmEncrypted = isOlmEncrypted;
+exports.pkSign = pkSign;
+exports.pkVerify = pkVerify;
+exports.verifySignature = verifySignature;
+var _anotherJson = _interopRequireDefault(require("another-json"));
+var _logger = require("../logger");
+var _event = require("../@types/event");
+var _utils = require("../utils");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+var Algorithm = /*#__PURE__*/function (Algorithm) {
+ Algorithm["Olm"] = "m.olm.v1.curve25519-aes-sha2";
+ Algorithm["Megolm"] = "m.megolm.v1.aes-sha2";
+ Algorithm["MegolmBackup"] = "m.megolm_backup.v1.curve25519-aes-sha2";
+ return Algorithm;
+}(Algorithm || {});
+/**
+ * matrix algorithm tag for olm
+ */
+const OLM_ALGORITHM = Algorithm.Olm;
+
+/**
+ * matrix algorithm tag for megolm
+ */
+exports.OLM_ALGORITHM = OLM_ALGORITHM;
+const MEGOLM_ALGORITHM = Algorithm.Megolm;
+
+/**
+ * matrix algorithm tag for megolm backups
+ */
+exports.MEGOLM_ALGORITHM = MEGOLM_ALGORITHM;
+const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup;
+exports.MEGOLM_BACKUP_ALGORITHM = MEGOLM_BACKUP_ALGORITHM;
+/**
+ * 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`
+ */
+async function encryptMessageForDevice(resultsObject, ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, payloadFields) {
+ 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.logger.log(`[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + `${recipientUserId}:${recipientDevice.deviceId}`);
+ return;
+ }
+ _logger.logger.log(`[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + `${recipientUserId}:${recipientDevice.deviceId}`);
+ const payload = _objectSpread({
+ 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));
+}
+/**
+ * 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}
+ */
+async function getExistingOlmSessions(olmDevice, baseApis, devicesByUser) {
+ // map user Id → DeviceInfo[]
+ const devicesWithoutSession = new _utils.MapWithDefault(() => []);
+ // map user Id → device Id → IExistingOlmSession
+ const sessions = new _utils.MapWithDefault(() => new Map());
+ const promises = [];
+ for (const [userId, devices] of Object.entries(devicesByUser)) {
+ for (const deviceInfo of devices) {
+ const deviceId = deviceInfo.deviceId;
+ const key = deviceInfo.getIdentityKey();
+ promises.push((async () => {
+ 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}
+ */
+async function ensureOlmSessionsForDevices(olmDevice, baseApis, devicesByUser, force = false, otkTimeout, failedServers, log = _logger.logger) {
+ const devicesWithoutSession = [
+ // [userId, deviceId], ...
+ ];
+ // map user Id → device Id → IExistingOlmSession
+ const result = new Map();
+ // map device key → resolve session fn
+ const resolveSession = 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 => {
+ 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;
+ 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 || {};
+ const promises = [];
+ 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 = 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, oneTimeKey, userId, deviceInfo) {
+ const deviceId = deviceInfo.deviceId;
+ try {
+ await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint());
+ } catch (e) {
+ _logger.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.logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e);
+ return null;
+ }
+ _logger.logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId);
+ return sid;
+}
+/**
+ * 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.
+ */
+async function verifySignature(olmDevice, obj, signingUserId, signingDeviceId, signingKey) {
+ 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.default.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
+ */
+function pkSign(obj, key, userId, pubKey) {
+ 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.default.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
+ */
+function pkVerify(obj, pubKey, userId) {
+ 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.default.stringify(obj), signature);
+ } finally {
+ obj.signatures = sigs;
+ if (unsigned) obj.unsigned = unsigned;
+ util.free();
+ }
+}
+
+/**
+ * Check that an event was encrypted using olm.
+ */
+function isOlmEncrypted(event) {
+ if (!event.getSenderKey()) {
+ _logger.logger.error("Event has no sender key (not encrypted?)");
+ return false;
+ }
+ if (event.getWireType() !== _event.EventType.RoomMessageEncrypted || !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm)) {
+ _logger.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.
+ */
+function encodeBase64(uint8Array) {
+ 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.
+ */
+function encodeUnpaddedBase64(uint8Array) {
+ 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.
+ */
+function decodeBase64(base64) {
+ return Buffer.from(base64, "base64");
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js
new file mode 100644
index 0000000000..a2a75618cb
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js
@@ -0,0 +1,60 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.decodeRecoveryKey = decodeRecoveryKey;
+exports.encodeRecoveryKey = encodeRecoveryKey;
+var bs58 = _interopRequireWildcard(require("bs58"));
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+// 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];
+function encodeRecoveryKey(key) {
+ 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(" ");
+}
+function decodeRecoveryKey(recoveryKey) {
+ 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));
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js
new file mode 100644
index 0000000000..e2d77f8af7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js
@@ -0,0 +1,913 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.VERSION = exports.Backend = void 0;
+exports.upgradeDatabase = upgradeDatabase;
+var _logger = require("../../logger");
+var _utils = require("../../utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+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.
+ */
+class Backend {
+ /**
+ */
+ constructor(db) {
+ this.db = db;
+ _defineProperty(this, "nextTxnId", 0);
+ // 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 = () => {
+ _logger.logger.log(`versionchange for indexeddb ${this.db.name}: closing`);
+ db.close();
+ };
+ }
+ async startup() {
+ // No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
+ // by passing us a ready IDBDatabase instance
+ return this;
+ }
+ async deleteAllData() {
+ 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.
+ */
+ getOrAddOutgoingRoomKeyRequest(request) {
+ 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.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.logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
+ txn.oncomplete = () => {
+ 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
+ */
+ getOutgoingRoomKeyRequest(requestBody) {
+ 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
+ _getOutgoingRoomKeyRequest(txn, requestBody, callback) {
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+ const idx = store.index("session");
+ const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
+ cursorReq.onsuccess = () => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ // no match found
+ callback(null);
+ return;
+ }
+ const existing = cursor.value;
+ if ((0, _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.
+ */
+ getOutgoingRoomKeyRequestByState(wantedStates) {
+ 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;
+ function onsuccess() {
+ 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.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
+ */
+ getAllOutgoingRoomKeyRequestsByState(wantedState) {
+ 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 = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ }
+ getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+ let stateIndex = 0;
+ const results = [];
+ function onsuccess() {
+ const cursor = this.result;
+ if (cursor) {
+ const keyReq = cursor.value;
+ if (keyReq.recipients.some(recipient => 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.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
+ */
+ updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+ let result = null;
+ function onsuccess() {
+ const cursor = this.result;
+ if (!cursor) {
+ return;
+ }
+ const data = cursor.value;
+ if (data.state != expectedState) {
+ _logger.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
+ */
+ deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
+ const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
+ cursorReq.onsuccess = () => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ return;
+ }
+ const data = cursor.value;
+ if (data.state != expectedState) {
+ _logger.logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
+ return;
+ }
+ cursor.delete();
+ };
+ return promiseifyTxn(txn);
+ }
+
+ // Olm Account
+
+ getAccount(txn, func) {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get("-");
+ getReq.onsuccess = function () {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ storeAccount(txn, accountPickle) {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(accountPickle, "-");
+ }
+ getCrossSigningKeys(txn, func) {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get("crossSigningKeys");
+ getReq.onsuccess = function () {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ getSecretStorePrivateKey(txn, func, type) {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get(`ssss_cache:${type}`);
+ getReq.onsuccess = function () {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ storeCrossSigningKeys(txn, keys) {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(keys, "crossSigningKeys");
+ }
+ storeSecretStorePrivateKey(txn, type, key) {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(key, `ssss_cache:${type}`);
+ }
+
+ // Olm Sessions
+
+ countEndToEndSessions(txn, func) {
+ const objectStore = txn.objectStore("sessions");
+ const countReq = objectStore.count();
+ countReq.onsuccess = function () {
+ try {
+ func(countReq.result);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ getEndToEndSessions(deviceKey, txn, func) {
+ const objectStore = txn.objectStore("sessions");
+ const idx = objectStore.index("deviceKey");
+ const getReq = idx.openCursor(deviceKey);
+ const results = {};
+ getReq.onsuccess = function () {
+ 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, e);
+ }
+ }
+ };
+ }
+ getEndToEndSession(deviceKey, sessionId, txn, func) {
+ const objectStore = txn.objectStore("sessions");
+ const getReq = objectStore.get([deviceKey, sessionId]);
+ getReq.onsuccess = function () {
+ try {
+ if (getReq.result) {
+ func({
+ session: getReq.result.session,
+ lastReceivedMessageTs: getReq.result.lastReceivedMessageTs
+ });
+ } else {
+ func(null);
+ }
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ getAllEndToEndSessions(txn, func) {
+ const objectStore = txn.objectStore("sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function () {
+ try {
+ const cursor = getReq.result;
+ if (cursor) {
+ func(cursor.value);
+ cursor.continue();
+ } else {
+ func(null);
+ }
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+ const objectStore = txn.objectStore("sessions");
+ objectStore.put({
+ deviceKey,
+ sessionId,
+ session: sessionInfo.session,
+ lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs
+ });
+ }
+ async storeEndToEndSessionProblem(deviceKey, type, fixed) {
+ 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);
+ }
+ async getEndToEndSessionProblem(deviceKey, timestamp) {
+ let result = 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 = () => {
+ 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
+ async filterOutNotifiedErrorDevices(devices) {
+ const txn = this.db.transaction("notified_error_devices", "readwrite");
+ const objectStore = txn.objectStore("notified_error_devices");
+ const ret = [];
+ await Promise.all(devices.map(device => {
+ return new Promise(resolve => {
+ const {
+ userId,
+ deviceInfo
+ } = device;
+ const getReq = objectStore.get([userId, deviceInfo.deviceId]);
+ getReq.onsuccess = function () {
+ if (!getReq.result) {
+ objectStore.put({
+ userId,
+ deviceId: deviceInfo.deviceId
+ });
+ ret.push(device);
+ }
+ resolve();
+ };
+ });
+ }));
+ return ret;
+ }
+
+ // Inbound group sessions
+
+ getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+ let session = false;
+ let withheld = false;
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.get([senderCurve25519Key, sessionId]);
+ getReq.onsuccess = function () {
+ try {
+ if (getReq.result) {
+ session = getReq.result.session;
+ } else {
+ session = null;
+ }
+ if (withheld !== false) {
+ func(session, withheld);
+ }
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld");
+ const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]);
+ withheldGetReq.onsuccess = function () {
+ try {
+ if (withheldGetReq.result) {
+ withheld = withheldGetReq.result.session;
+ } else {
+ withheld = null;
+ }
+ if (session !== false) {
+ func(session, withheld);
+ }
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ getAllEndToEndInboundGroupSessions(txn, func) {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function () {
+ const cursor = getReq.result;
+ if (cursor) {
+ try {
+ func({
+ senderKey: cursor.value.senderCurve25519Key,
+ sessionId: cursor.value.sessionId,
+ sessionData: cursor.value.session
+ });
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ cursor.continue();
+ } else {
+ try {
+ func(null);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ }
+ };
+ }
+ addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const addReq = objectStore.add({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData
+ });
+ addReq.onerror = ev => {
+ 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.logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
+ } else {
+ abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error));
+ }
+ };
+ }
+ storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ objectStore.put({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData
+ });
+ }
+ storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) {
+ const objectStore = txn.objectStore("inbound_group_sessions_withheld");
+ objectStore.put({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData
+ });
+ }
+ getEndToEndDeviceData(txn, func) {
+ const objectStore = txn.objectStore("device_data");
+ const getReq = objectStore.get("-");
+ getReq.onsuccess = function () {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ };
+ }
+ storeEndToEndDeviceData(deviceData, txn) {
+ const objectStore = txn.objectStore("device_data");
+ objectStore.put(deviceData, "-");
+ }
+ storeEndToEndRoom(roomId, roomInfo, txn) {
+ const objectStore = txn.objectStore("rooms");
+ objectStore.put(roomInfo, roomId);
+ }
+ getEndToEndRooms(txn, func) {
+ const rooms = {};
+ const objectStore = txn.objectStore("rooms");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function () {
+ const cursor = getReq.result;
+ if (cursor) {
+ rooms[cursor.key] = cursor.value;
+ cursor.continue();
+ } else {
+ try {
+ func(rooms);
+ } catch (e) {
+ abortWithException(txn, e);
+ }
+ }
+ };
+ }
+
+ // session backups
+
+ getSessionsNeedingBackup(limit) {
+ return new Promise((resolve, reject) => {
+ const sessions = [];
+ const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
+ txn.onerror = reject;
+ txn.oncomplete = function () {
+ resolve(sessions);
+ };
+ const objectStore = txn.objectStore("sessions_needing_backup");
+ const sessionStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function () {
+ const cursor = getReq.result;
+ if (cursor) {
+ const sessionGetReq = sessionStore.get(cursor.key);
+ sessionGetReq.onsuccess = function () {
+ sessions.push({
+ senderKey: sessionGetReq.result.senderCurve25519Key,
+ sessionId: sessionGetReq.result.sessionId,
+ sessionData: sessionGetReq.result.session
+ });
+ };
+ if (!limit || sessions.length < limit) {
+ cursor.continue();
+ }
+ }
+ };
+ });
+ }
+ countSessionsNeedingBackup(txn) {
+ 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 = () => resolve(req.result);
+ });
+ }
+ async unmarkSessionsNeedingBackup(sessions, txn) {
+ 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;
+ });
+ }));
+ }
+ async markSessionsNeedingBackup(sessions, txn) {
+ 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;
+ });
+ }));
+ }
+ addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) {
+ 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 = () => {
+ const {
+ sessions
+ } = req.result || {
+ sessions: []
+ };
+ sessions.push([senderKey, sessionId]);
+ objectStore.put({
+ roomId,
+ sessions
+ });
+ };
+ }
+ getSharedHistoryInboundGroupSessions(roomId, txn) {
+ 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 = () => {
+ const {
+ sessions
+ } = req.result || {
+ sessions: []
+ };
+ resolve(sessions);
+ };
+ req.onerror = reject;
+ });
+ }
+ addParkedSharedHistory(roomId, parkedData, txn) {
+ if (!txn) {
+ txn = this.db.transaction("parked_shared_history", "readwrite");
+ }
+ const objectStore = txn.objectStore("parked_shared_history");
+ const req = objectStore.get([roomId]);
+ req.onsuccess = () => {
+ const {
+ parked
+ } = req.result || {
+ parked: []
+ };
+ parked.push(parkedData);
+ objectStore.put({
+ roomId,
+ parked
+ });
+ };
+ }
+ takeParkedSharedHistory(roomId, txn) {
+ 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 = () => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ resolve([]);
+ return;
+ }
+ const data = cursor.value;
+ cursor.delete();
+ resolve(data);
+ };
+ cursorReq.onerror = reject;
+ });
+ }
+ doTxn(mode, stores, func, log = _logger.logger) {
+ let startTime;
+ let description;
+ 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;
+ });
+ }
+}
+exports.Backend = Backend;
+const DB_MIGRATIONS = [db => {
+ createDatabase(db);
+}, db => {
+ db.createObjectStore("account");
+}, db => {
+ const sessionsStore = db.createObjectStore("sessions", {
+ keyPath: ["deviceKey", "sessionId"]
+ });
+ sessionsStore.createIndex("deviceKey", "deviceKey");
+}, db => {
+ db.createObjectStore("inbound_group_sessions", {
+ keyPath: ["senderCurve25519Key", "sessionId"]
+ });
+}, db => {
+ db.createObjectStore("device_data");
+}, db => {
+ db.createObjectStore("rooms");
+}, db => {
+ db.createObjectStore("sessions_needing_backup", {
+ keyPath: ["senderCurve25519Key", "sessionId"]
+ });
+}, db => {
+ db.createObjectStore("inbound_group_sessions_withheld", {
+ keyPath: ["senderCurve25519Key", "sessionId"]
+ });
+}, db => {
+ const problemsStore = db.createObjectStore("session_problems", {
+ keyPath: ["deviceKey", "time"]
+ });
+ problemsStore.createIndex("deviceKey", "deviceKey");
+ db.createObjectStore("notified_error_devices", {
+ keyPath: ["userId", "deviceId"]
+ });
+}, db => {
+ db.createObjectStore("shared_history_inbound_group_sessions", {
+ keyPath: ["roomId"]
+ });
+}, db => {
+ db.createObjectStore("parked_shared_history", {
+ keyPath: ["roomId"]
+ });
+}
+// Expand as needed.
+];
+
+const VERSION = DB_MIGRATIONS.length;
+exports.VERSION = VERSION;
+function upgradeDatabase(db, oldVersion) {
+ _logger.logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`);
+ DB_MIGRATIONS.forEach((migration, index) => {
+ if (oldVersion <= index) migration(db);
+ });
+}
+function createDatabase(db) {
+ 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");
+}
+/*
+ * Aborts a transaction with a given exception
+ * The transaction promise will be rejected with this exception.
+ */
+function abortWithException(txn, e) {
+ // 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._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(txn) {
+ return new Promise((resolve, reject) => {
+ txn.oncomplete = () => {
+ if (txn._mx_abortexception !== undefined) {
+ reject(txn._mx_abortexception);
+ }
+ resolve(null);
+ };
+ txn.onerror = event => {
+ if (txn._mx_abortexception !== undefined) {
+ reject(txn._mx_abortexception);
+ } else {
+ _logger.logger.log("Error performing indexeddb txn", event);
+ reject(txn.error);
+ }
+ };
+ txn.onabort = event => {
+ if (txn._mx_abortexception !== undefined) {
+ reject(txn._mx_abortexception);
+ } else {
+ _logger.logger.log("Error performing indexeddb txn", event);
+ reject(txn.error);
+ }
+ };
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js
new file mode 100644
index 0000000000..dc48bd400f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js
@@ -0,0 +1,599 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.IndexedDBCryptoStore = void 0;
+var _logger = require("../../logger");
+var _localStorageCryptoStore = require("./localStorage-crypto-store");
+var _memoryCryptoStore = require("./memory-crypto-store");
+var IndexedDBCryptoStoreBackend = _interopRequireWildcard(require("./indexeddb-crypto-store-backend"));
+var _errors = require("../../errors");
+var IndexedDBHelpers = _interopRequireWildcard(require("../../indexeddb-helpers"));
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Internal module. indexeddb storage for e2e.
+ */
+
+/**
+ * An implementation of CryptoStore, which is normally backed by an indexeddb,
+ * but with fallback to MemoryCryptoStore.
+ */
+class IndexedDBCryptoStore {
+ static exists(indexedDB, dbName) {
+ return IndexedDBHelpers.exists(indexedDB, dbName);
+ }
+ /**
+ * Create a new IndexedDBCryptoStore
+ *
+ * @param indexedDB - global indexedDB instance
+ * @param dbName - name of db to connect to
+ */
+ constructor(indexedDB, dbName) {
+ this.indexedDB = indexedDB;
+ this.dbName = dbName;
+ _defineProperty(this, "backendPromise", void 0);
+ _defineProperty(this, "backend", void 0);
+ }
+
+ /**
+ * 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
+ */
+ startup() {
+ if (this.backendPromise) {
+ return this.backendPromise;
+ }
+ this.backendPromise = new Promise((resolve, reject) => {
+ if (!this.indexedDB) {
+ reject(new Error("no indexeddb support available"));
+ return;
+ }
+ _logger.logger.log(`connecting to indexeddb ${this.dbName}`);
+ const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION);
+ req.onupgradeneeded = ev => {
+ const db = req.result;
+ const oldVersion = ev.oldVersion;
+ IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion);
+ };
+ req.onblocked = () => {
+ _logger.logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`);
+ };
+ req.onerror = ev => {
+ _logger.logger.log("Error connecting to indexeddb", ev);
+ reject(req.error);
+ };
+ req.onsuccess = () => {
+ const db = req.result;
+ _logger.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.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 _errors.InvalidCryptoStoreError(_errors.InvalidCryptoStoreState.TooNew);
+ }
+ _logger.logger.warn(`unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`);
+ try {
+ return new _localStorageCryptoStore.LocalStorageCryptoStore(global.localStorage);
+ } catch (e) {
+ _logger.logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
+ return new _memoryCryptoStore.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.
+ */
+ deleteAllData() {
+ return new Promise((resolve, reject) => {
+ if (!this.indexedDB) {
+ reject(new Error("no indexeddb support available"));
+ return;
+ }
+ _logger.logger.log(`Removing indexeddb instance: ${this.dbName}`);
+ const req = this.indexedDB.deleteDatabase(this.dbName);
+ req.onblocked = () => {
+ _logger.logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`);
+ };
+ req.onerror = ev => {
+ _logger.logger.log("Error deleting data from indexeddb", ev);
+ reject(req.error);
+ };
+ req.onsuccess = () => {
+ _logger.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.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.
+ */
+ getOrAddOutgoingRoomKeyRequest(request) {
+ 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
+ */
+ getOutgoingRoomKeyRequest(requestBody) {
+ 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.
+ */
+ getOutgoingRoomKeyRequestByState(wantedStates) {
+ 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
+ */
+ getAllOutgoingRoomKeyRequestsByState(wantedState) {
+ 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}
+ */
+ getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+ 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
+ */
+ updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+ 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
+ */
+ deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+ 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
+ */
+ getAccount(txn, func) {
+ 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.
+ */
+ storeAccount(txn, accountPickle) {
+ 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
+ */
+ getCrossSigningKeys(txn, func) {
+ this.backend.getCrossSigningKeys(txn, func);
+ }
+
+ /**
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with the private key
+ * @param type - A key type
+ */
+ getSecretStorePrivateKey(txn, func, type) {
+ 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()
+ */
+ storeCrossSigningKeys(txn, keys) {
+ 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()
+ */
+ storeSecretStorePrivateKey(txn, type, key) {
+ 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
+ */
+ countEndToEndSessions(txn, func) {
+ 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.
+ */
+ getEndToEndSession(deviceKey, sessionId, txn, func) {
+ 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.
+ */
+ getEndToEndSessions(deviceKey, txn, func) {
+ 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.
+ */
+ getAllEndToEndSessions(txn, func) {
+ 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().
+ */
+ storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+ this.backend.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
+ }
+ storeEndToEndSessionProblem(deviceKey, type, fixed) {
+ return this.backend.storeEndToEndSessionProblem(deviceKey, type, fixed);
+ }
+ getEndToEndSessionProblem(deviceKey, timestamp) {
+ return this.backend.getEndToEndSessionProblem(deviceKey, timestamp);
+ }
+ filterOutNotifiedErrorDevices(devices) {
+ 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.
+ */
+ getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+ 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.
+ */
+ getAllEndToEndInboundGroupSessions(txn, func) {
+ 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().
+ */
+ addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ 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().
+ */
+ storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ this.backend.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+ storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) {
+ 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().
+ */
+ storeEndToEndDeviceData(deviceData, txn) {
+ 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
+ */
+ getEndToEndDeviceData(txn, func) {
+ 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().
+ */
+ storeEndToEndRoom(roomId, roomInfo, txn) {
+ 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
+ */
+ getEndToEndRooms(txn, func) {
+ 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
+ */
+ getSessionsNeedingBackup(limit) {
+ 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
+ */
+ countSessionsNeedingBackup(txn) {
+ 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
+ */
+ unmarkSessionsNeedingBackup(sessions, txn) {
+ 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
+ */
+ markSessionsNeedingBackup(sessions, txn) {
+ 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)
+ */
+ addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) {
+ 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]
+ */
+ getSharedHistoryInboundGroupSessions(roomId, txn) {
+ return this.backend.getSharedHistoryInboundGroupSessions(roomId, txn);
+ }
+
+ /**
+ * Park a shared-history group session for a room we may be invited to later.
+ */
+ addParkedSharedHistory(roomId, parkedData, txn) {
+ this.backend.addParkedSharedHistory(roomId, parkedData, txn);
+ }
+
+ /**
+ * Pop out all shared-history group sessions for a room.
+ */
+ takeParkedSharedHistory(roomId, txn) {
+ 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.
+ */
+ doTxn(mode, stores, func, log) {
+ return this.backend.doTxn(mode, stores, func, log);
+ }
+}
+exports.IndexedDBCryptoStore = IndexedDBCryptoStore;
+_defineProperty(IndexedDBCryptoStore, "STORE_ACCOUNT", "account");
+_defineProperty(IndexedDBCryptoStore, "STORE_SESSIONS", "sessions");
+_defineProperty(IndexedDBCryptoStore, "STORE_INBOUND_GROUP_SESSIONS", "inbound_group_sessions");
+_defineProperty(IndexedDBCryptoStore, "STORE_INBOUND_GROUP_SESSIONS_WITHHELD", "inbound_group_sessions_withheld");
+_defineProperty(IndexedDBCryptoStore, "STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS", "shared_history_inbound_group_sessions");
+_defineProperty(IndexedDBCryptoStore, "STORE_PARKED_SHARED_HISTORY", "parked_shared_history");
+_defineProperty(IndexedDBCryptoStore, "STORE_DEVICE_DATA", "device_data");
+_defineProperty(IndexedDBCryptoStore, "STORE_ROOMS", "rooms");
+_defineProperty(IndexedDBCryptoStore, "STORE_BACKUP", "sessions_needing_backup"); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js
new file mode 100644
index 0000000000..17348d1813
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js
@@ -0,0 +1,329 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.LocalStorageCryptoStore = void 0;
+var _logger = require("../../logger");
+var _memoryCryptoStore = require("./memory-crypto-store");
+var _utils = require("../../utils");
+/*
+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.
+*/
+
+/**
+ * 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) {
+ return E2E_PREFIX + "sessions/" + deviceKey;
+}
+function keyEndToEndSessionProblems(deviceKey) {
+ return E2E_PREFIX + "session.problems/" + deviceKey;
+}
+function keyEndToEndInboundGroupSession(senderKey, sessionId) {
+ return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
+}
+function keyEndToEndInboundGroupSessionWithheld(senderKey, sessionId) {
+ return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId;
+}
+function keyEndToEndRoomsPrefix(roomId) {
+ return KEY_ROOMS_PREFIX + roomId;
+}
+class LocalStorageCryptoStore extends _memoryCryptoStore.MemoryCryptoStore {
+ static exists(store) {
+ const length = store.length;
+ for (let i = 0; i < length; i++) {
+ if (store.key(i)?.startsWith(E2E_PREFIX)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ constructor(store) {
+ super();
+ this.store = store;
+ }
+
+ // Olm Sessions
+
+ countEndToEndSessions(txn, func) {
+ 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
+ _getEndToEndSessions(deviceKey) {
+ const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
+ const fixedSessions = {};
+
+ // 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;
+ }
+ getEndToEndSession(deviceKey, sessionId, txn, func) {
+ const sessions = this._getEndToEndSessions(deviceKey);
+ func(sessions[sessionId] || {});
+ }
+ getEndToEndSessions(deviceKey, txn, func) {
+ func(this._getEndToEndSessions(deviceKey) || {});
+ }
+ getAllEndToEndSessions(txn, func) {
+ 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);
+ }
+ }
+ }
+ }
+ storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+ const sessions = this._getEndToEndSessions(deviceKey) || {};
+ sessions[sessionId] = sessionInfo;
+ setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
+ }
+ async storeEndToEndSessionProblem(deviceKey, type, fixed) {
+ const key = keyEndToEndSessionProblems(deviceKey);
+ const problems = getJsonItem(this.store, key) || [];
+ problems.push({
+ type,
+ fixed,
+ time: Date.now()
+ });
+ problems.sort((a, b) => {
+ return a.time - b.time;
+ });
+ setJsonItem(this.store, key, problems);
+ }
+ async getEndToEndSessionProblem(deviceKey, timestamp) {
+ const key = keyEndToEndSessionProblems(deviceKey);
+ const problems = getJsonItem(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;
+ }
+ }
+ async filterOutNotifiedErrorDevices(devices) {
+ const notifiedErrorDevices = getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {};
+ const ret = [];
+ for (const device of devices) {
+ const {
+ userId,
+ deviceInfo
+ } = device;
+ if (userId in notifiedErrorDevices) {
+ if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
+ ret.push(device);
+ (0, _utils.safeSet)(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
+ }
+ } else {
+ ret.push(device);
+ (0, _utils.safeSet)(notifiedErrorDevices, userId, {
+ [deviceInfo.deviceId]: true
+ });
+ }
+ }
+ setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices);
+ return ret;
+ }
+
+ // Inbound Group Sessions
+
+ getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+ func(getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)), getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)));
+ }
+ getAllEndToEndInboundGroupSessions(txn, func) {
+ 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);
+ }
+ addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
+ if (!existing) {
+ this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+ }
+ storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
+ }
+ storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) {
+ setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
+ }
+ getEndToEndDeviceData(txn, func) {
+ func(getJsonItem(this.store, KEY_DEVICE_DATA));
+ }
+ storeEndToEndDeviceData(deviceData, txn) {
+ setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
+ }
+ storeEndToEndRoom(roomId, roomInfo, txn) {
+ setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
+ }
+ getEndToEndRooms(txn, func) {
+ const result = {};
+ 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);
+ }
+ getSessionsNeedingBackup(limit) {
+ const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ const sessions = [];
+ 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);
+ }
+ countSessionsNeedingBackup() {
+ const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ return Promise.resolve(Object.keys(sessionsNeedingBackup).length);
+ }
+ unmarkSessionsNeedingBackup(sessions) {
+ const sessionsNeedingBackup = getJsonItem(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();
+ }
+ markSessionsNeedingBackup(sessions) {
+ const sessionsNeedingBackup = getJsonItem(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.
+ */
+ deleteAllData() {
+ this.store.removeItem(KEY_END_TO_END_ACCOUNT);
+ return Promise.resolve();
+ }
+
+ // Olm account
+
+ getAccount(txn, func) {
+ const accountPickle = getJsonItem(this.store, KEY_END_TO_END_ACCOUNT);
+ func(accountPickle);
+ }
+ storeAccount(txn, accountPickle) {
+ setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
+ }
+ getCrossSigningKeys(txn, func) {
+ const keys = getJsonItem(this.store, KEY_CROSS_SIGNING_KEYS);
+ func(keys);
+ }
+ getSecretStorePrivateKey(txn, func, type) {
+ const key = getJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`);
+ func(key);
+ }
+ storeCrossSigningKeys(txn, keys) {
+ setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
+ }
+ storeSecretStorePrivateKey(txn, type, key) {
+ setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key);
+ }
+ doTxn(mode, stores, func) {
+ return Promise.resolve(func(null));
+ }
+}
+exports.LocalStorageCryptoStore = LocalStorageCryptoStore;
+function getJsonItem(store, key) {
+ 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.logger.log("Error: Failed to get key %s: %s", key, e.message);
+ _logger.logger.log(e.stack);
+ }
+ return null;
+}
+function setJsonItem(store, key, val) {
+ store.setItem(key, JSON.stringify(val));
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js
new file mode 100644
index 0000000000..5b9fba0289
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js
@@ -0,0 +1,439 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MemoryCryptoStore = void 0;
+var _logger = require("../../logger");
+var _utils = require("../../utils");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Internal module. in-memory storage for e2e.
+ */
+
+class MemoryCryptoStore {
+ constructor() {
+ _defineProperty(this, "outgoingRoomKeyRequests", []);
+ _defineProperty(this, "account", null);
+ _defineProperty(this, "crossSigningKeys", null);
+ _defineProperty(this, "privateKeys", {});
+ _defineProperty(this, "sessions", {});
+ _defineProperty(this, "sessionProblems", {});
+ _defineProperty(this, "notifiedErrorDevices", {});
+ _defineProperty(this, "inboundGroupSessions", {});
+ _defineProperty(this, "inboundGroupSessionsWithheld", {});
+ // Opaque device data object
+ _defineProperty(this, "deviceData", null);
+ _defineProperty(this, "rooms", {});
+ _defineProperty(this, "sessionsNeedingBackup", {});
+ _defineProperty(this, "sharedHistoryInboundGroupSessions", {});
+ _defineProperty(this, "parkedSharedHistory", new Map());
+ }
+ // 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.
+ */
+ async startup() {
+ // 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.
+ */
+ deleteAllData() {
+ 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.
+ */
+ getOrAddOutgoingRoomKeyRequest(request) {
+ const requestBody = request.requestBody;
+ return (0, _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.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.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
+ */
+ getOutgoingRoomKeyRequest(requestBody) {
+ 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
+ _getOutgoingRoomKeyRequest(requestBody) {
+ for (const existing of this.outgoingRoomKeyRequests) {
+ if ((0, _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
+ */
+ getOutgoingRoomKeyRequestByState(wantedStates) {
+ 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
+ */
+ getAllOutgoingRoomKeyRequestsByState(wantedState) {
+ return Promise.resolve(this.outgoingRoomKeyRequests.filter(r => r.state == wantedState));
+ }
+ getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+ const results = [];
+ 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
+ */
+ updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+ for (const req of this.outgoingRoomKeyRequests) {
+ if (req.requestId !== requestId) {
+ continue;
+ }
+ if (req.state !== expectedState) {
+ _logger.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
+ */
+ deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+ for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) {
+ const req = this.outgoingRoomKeyRequests[i];
+ if (req.requestId !== requestId) {
+ continue;
+ }
+ if (req.state != expectedState) {
+ _logger.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
+
+ getAccount(txn, func) {
+ func(this.account);
+ }
+ storeAccount(txn, accountPickle) {
+ this.account = accountPickle;
+ }
+ getCrossSigningKeys(txn, func) {
+ func(this.crossSigningKeys);
+ }
+ getSecretStorePrivateKey(txn, func, type) {
+ const result = this.privateKeys[type];
+ func(result || null);
+ }
+ storeCrossSigningKeys(txn, keys) {
+ this.crossSigningKeys = keys;
+ }
+ storeSecretStorePrivateKey(txn, type, key) {
+ this.privateKeys[type] = key;
+ }
+
+ // Olm Sessions
+
+ countEndToEndSessions(txn, func) {
+ func(Object.keys(this.sessions).length);
+ }
+ getEndToEndSession(deviceKey, sessionId, txn, func) {
+ const deviceSessions = this.sessions[deviceKey] || {};
+ func(deviceSessions[sessionId] || null);
+ }
+ getEndToEndSessions(deviceKey, txn, func) {
+ func(this.sessions[deviceKey] || {});
+ }
+ getAllEndToEndSessions(txn, func) {
+ Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => {
+ Object.entries(deviceSessions).forEach(([sessionId, session]) => {
+ func(_objectSpread(_objectSpread({}, session), {}, {
+ deviceKey,
+ sessionId
+ }));
+ });
+ });
+ }
+ storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+ let deviceSessions = this.sessions[deviceKey];
+ if (deviceSessions === undefined) {
+ deviceSessions = {};
+ this.sessions[deviceKey] = deviceSessions;
+ }
+ (0, _utils.safeSet)(deviceSessions, sessionId, sessionInfo);
+ }
+ async storeEndToEndSessionProblem(deviceKey, type, fixed) {
+ const problems = this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || [];
+ problems.push({
+ type,
+ fixed,
+ time: Date.now()
+ });
+ problems.sort((a, b) => {
+ return a.time - b.time;
+ });
+ }
+ async getEndToEndSessionProblem(deviceKey, timestamp) {
+ 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;
+ }
+ }
+ async filterOutNotifiedErrorDevices(devices) {
+ const notifiedErrorDevices = this.notifiedErrorDevices;
+ const ret = [];
+ for (const device of devices) {
+ const {
+ userId,
+ deviceInfo
+ } = device;
+ if (userId in notifiedErrorDevices) {
+ if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
+ ret.push(device);
+ (0, _utils.safeSet)(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
+ }
+ } else {
+ ret.push(device);
+ (0, _utils.safeSet)(notifiedErrorDevices, userId, {
+ [deviceInfo.deviceId]: true
+ });
+ }
+ }
+ return ret;
+ }
+
+ // Inbound Group Sessions
+
+ getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+ const k = senderCurve25519Key + "/" + sessionId;
+ func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
+ }
+ getAllEndToEndInboundGroupSessions(txn, func) {
+ 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);
+ }
+ addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ const k = senderCurve25519Key + "/" + sessionId;
+ if (this.inboundGroupSessions[k] === undefined) {
+ this.inboundGroupSessions[k] = sessionData;
+ }
+ }
+ storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+ this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
+ }
+ storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn) {
+ const k = senderCurve25519Key + "/" + sessionId;
+ this.inboundGroupSessionsWithheld[k] = sessionData;
+ }
+
+ // Device Data
+
+ getEndToEndDeviceData(txn, func) {
+ func(this.deviceData);
+ }
+ storeEndToEndDeviceData(deviceData, txn) {
+ this.deviceData = deviceData;
+ }
+
+ // E2E rooms
+
+ storeEndToEndRoom(roomId, roomInfo, txn) {
+ this.rooms[roomId] = roomInfo;
+ }
+ getEndToEndRooms(txn, func) {
+ func(this.rooms);
+ }
+ getSessionsNeedingBackup(limit) {
+ const sessions = [];
+ 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);
+ }
+ countSessionsNeedingBackup() {
+ return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length);
+ }
+ unmarkSessionsNeedingBackup(sessions) {
+ for (const session of sessions) {
+ const sessionKey = session.senderKey + "/" + session.sessionId;
+ delete this.sessionsNeedingBackup[sessionKey];
+ }
+ return Promise.resolve();
+ }
+ markSessionsNeedingBackup(sessions) {
+ for (const session of sessions) {
+ const sessionKey = session.senderKey + "/" + session.sessionId;
+ this.sessionsNeedingBackup[sessionKey] = true;
+ }
+ return Promise.resolve();
+ }
+ addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId) {
+ const sessions = this.sharedHistoryInboundGroupSessions[roomId] || [];
+ sessions.push([senderKey, sessionId]);
+ this.sharedHistoryInboundGroupSessions[roomId] = sessions;
+ }
+ getSharedHistoryInboundGroupSessions(roomId) {
+ return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
+ }
+ addParkedSharedHistory(roomId, parkedData) {
+ const parked = this.parkedSharedHistory.get(roomId) ?? [];
+ parked.push(parkedData);
+ this.parkedSharedHistory.set(roomId, parked);
+ }
+ takeParkedSharedHistory(roomId) {
+ const parked = this.parkedSharedHistory.get(roomId) ?? [];
+ this.parkedSharedHistory.delete(roomId);
+ return Promise.resolve(parked);
+ }
+
+ // Session key backups
+
+ doTxn(mode, stores, func) {
+ return Promise.resolve(func(null));
+ }
+}
+exports.MemoryCryptoStore = MemoryCryptoStore; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js
new file mode 100644
index 0000000000..4da45f880e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js
@@ -0,0 +1,345 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.VerificationEvent = exports.VerificationBase = exports.SwitchStartEventError = void 0;
+var _event = require("../../models/event");
+var _event2 = require("../../@types/event");
+var _logger = require("../../logger");
+var _deviceinfo = require("../deviceinfo");
+var _Error = require("./Error");
+var _CrossSigning = require("../CrossSigning");
+var _typedEventEmitter = require("../../models/typed-event-emitter");
+var _verification = require("../../crypto-api/verification");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const timeoutException = new Error("Verification timed out");
+class SwitchStartEventError extends Error {
+ constructor(startEvent) {
+ super();
+ this.startEvent = startEvent;
+ }
+}
+
+/** @deprecated use VerifierEvent */
+exports.SwitchStartEventError = SwitchStartEventError;
+/** @deprecated use VerifierEvent */
+const VerificationEvent = _verification.VerifierEvent;
+
+/** @deprecated use VerifierEventHandlerMap */
+exports.VerificationEvent = VerificationEvent;
+// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
+// backwards compatibility with applications that reference the class.
+class VerificationBase extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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
+ */
+ constructor(channel, baseApis, userId, deviceId, startEvent, request) {
+ super();
+ this.channel = channel;
+ this.baseApis = baseApis;
+ this.userId = userId;
+ this.deviceId = deviceId;
+ this.startEvent = startEvent;
+ this.request = request;
+ _defineProperty(this, "cancelled", false);
+ _defineProperty(this, "_done", false);
+ _defineProperty(this, "promise", null);
+ _defineProperty(this, "transactionTimeoutTimer", null);
+ _defineProperty(this, "expectedEvent", void 0);
+ _defineProperty(this, "resolve", void 0);
+ _defineProperty(this, "reject", void 0);
+ _defineProperty(this, "resolveEvent", void 0);
+ _defineProperty(this, "rejectEvent", void 0);
+ _defineProperty(this, "started", void 0);
+ _defineProperty(this, "doVerification", void 0);
+ }
+ get initiatedByMe() {
+ // 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();
+ }
+ get hasBeenCancelled() {
+ return this.cancelled;
+ }
+ resetTimer() {
+ _logger.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.logger.info("Triggering verification timeout");
+ this.cancel(timeoutException);
+ }
+ }, 10 * 60 * 1000); // 10 minutes
+ }
+
+ endTimer() {
+ if (this.transactionTimeoutTimer !== null) {
+ clearTimeout(this.transactionTimeoutTimer);
+ this.transactionTimeoutTimer = null;
+ }
+ }
+ send(type, uncompletedContent) {
+ return this.channel.send(type, uncompletedContent);
+ }
+ waitForEvent(type) {
+ 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;
+ });
+ }
+ canSwitchStartEvent(event) {
+ return false;
+ }
+ switchStartEvent(event) {
+ if (this.canSwitchStartEvent(event)) {
+ _logger.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;
+ }
+ }
+ }
+ handleEvent(e) {
+ 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 !== _event2.EventType.KeyVerificationDone) {
+ this.expectedEvent = undefined;
+ this.rejectEvent = undefined;
+ this.resetTimer();
+ this.resolveEvent?.(e);
+ }
+ } else if (e.getType() === _event2.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);
+ }
+ }
+ async done() {
+ this.endTimer(); // always kill the activity timer
+ if (!this._done) {
+ this.request.onVerifierFinished();
+ this.resolve?.();
+ return (0, _CrossSigning.requestKeysDuringVerification)(this.baseApis, this.userId, this.deviceId);
+ }
+ }
+ cancel(e) {
+ 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 = (0, _Error.newTimeoutError)();
+ this.send(timeoutEvent.getType(), timeoutEvent.getContent());
+ } else if (e instanceof _event.MatrixEvent) {
+ const sender = e.getSender();
+ if (sender !== this.userId) {
+ const content = e.getContent();
+ if (e.getType() === _event2.EventType.KeyVerificationCancel) {
+ content.code = content.code || "m.unknown";
+ content.reason = content.reason || content.body || "Unknown reason";
+ this.send(_event2.EventType.KeyVerificationCancel, content);
+ } else {
+ this.send(_event2.EventType.KeyVerificationCancel, {
+ code: "m.unknown",
+ reason: content.body || "Unknown reason"
+ });
+ }
+ }
+ } else {
+ this.send(_event2.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.
+ */
+ verify() {
+ if (this.promise) return this.promise;
+ this.promise = new Promise((resolve, reject) => {
+ this.resolve = (...args) => {
+ this._done = true;
+ this.endTimer();
+ resolve(...args);
+ };
+ this.reject = e => {
+ this._done = true;
+ this.endTimer();
+ reject(e);
+ };
+ });
+ if (this.doVerification && !this.started) {
+ this.started = true;
+ this.resetTimer(); // restart the timeout
+ new Promise((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;
+ }
+ async verifyKeys(userId, keys, verifier) {
+ // 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 = [];
+ 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.DeviceInfo.fromStorage({
+ keys: {
+ [keyId]: deviceId
+ }
+ }, deviceId), keyInfo);
+ verifiedDevices.push([deviceId, keyId, deviceId]);
+ } else {
+ _logger.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.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();
+ }
+ }
+ get events() {
+ return undefined;
+ }
+}
+exports.VerificationBase = VerificationBase; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js
new file mode 100644
index 0000000000..3d24c03955
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js
@@ -0,0 +1,100 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.errorFactory = errorFactory;
+exports.errorFromEvent = errorFromEvent;
+exports.newUserCancelledError = exports.newUnknownMethodError = exports.newUnexpectedMessageError = exports.newTimeoutError = exports.newKeyMismatchError = exports.newInvalidMessageError = void 0;
+exports.newVerificationError = newVerificationError;
+var _event = require("../../models/event");
+var _event2 = require("../../@types/event");
+/*
+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.
+ */
+
+function newVerificationError(code, reason, extraData) {
+ const content = Object.assign({}, {
+ code,
+ reason
+ }, extraData);
+ return new _event.MatrixEvent({
+ type: _event2.EventType.KeyVerificationCancel,
+ content
+ });
+}
+function errorFactory(code, reason) {
+ return function (extraData) {
+ return newVerificationError(code, reason, extraData);
+ };
+}
+
+/**
+ * The verification was cancelled by the user.
+ */
+const newUserCancelledError = errorFactory("m.user", "Cancelled by user");
+
+/**
+ * The verification timed out.
+ */
+exports.newUserCancelledError = newUserCancelledError;
+const newTimeoutError = errorFactory("m.timeout", "Timed out");
+
+/**
+ * An unknown method was selected.
+ */
+exports.newTimeoutError = newTimeoutError;
+const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method");
+
+/**
+ * An unexpected message was sent.
+ */
+exports.newUnknownMethodError = newUnknownMethodError;
+const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message");
+
+/**
+ * The key does not match.
+ */
+exports.newUnexpectedMessageError = newUnexpectedMessageError;
+const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch");
+
+/**
+ * An invalid message was sent.
+ */
+exports.newKeyMismatchError = newKeyMismatchError;
+const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message");
+exports.newInvalidMessageError = newInvalidMessageError;
+function errorFromEvent(event) {
+ const content = event.getContent();
+ if (content) {
+ const {
+ code,
+ reason
+ } = content;
+ return {
+ code,
+ reason
+ };
+ } else {
+ return {
+ code: "Unknown error",
+ reason: "m.unknown"
+ };
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js
new file mode 100644
index 0000000000..396d911eec
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js
@@ -0,0 +1,46 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.IllegalMethod = void 0;
+var _Base = require("./Base");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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).
+ */
+class IllegalMethod extends _Base.VerificationBase {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "doVerification", async () => {
+ throw new Error("Verification is not possible with this method");
+ });
+ }
+ static factory(channel, baseApis, userId, deviceId, startEvent, request) {
+ return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ static get NAME() {
+ // Typically the name will be something else, but to complete
+ // the contract we offer a default one here.
+ return "org.matrix.illegal_method";
+ }
+}
+exports.IllegalMethod = IllegalMethod; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js
new file mode 100644
index 0000000000..e2334c64e7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js
@@ -0,0 +1,269 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SHOW_QR_CODE_METHOD = exports.SCAN_QR_CODE_METHOD = exports.ReciprocateQRCode = exports.QrCodeEvent = exports.QRCodeData = void 0;
+var _Base = require("./Base");
+var _Error = require("./Error");
+var _olmlib = require("../olmlib");
+var _logger = require("../../logger");
+var _verification = require("../../crypto-api/verification");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
+exports.SHOW_QR_CODE_METHOD = SHOW_QR_CODE_METHOD;
+const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
+
+/** @deprecated use VerifierEvent */
+exports.SCAN_QR_CODE_METHOD = SCAN_QR_CODE_METHOD;
+/** @deprecated use VerifierEvent */
+const QrCodeEvent = _verification.VerifierEvent;
+exports.QrCodeEvent = QrCodeEvent;
+class ReciprocateQRCode extends _Base.VerificationBase {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "reciprocateQREvent", void 0);
+ _defineProperty(this, "doVerification", async () => {
+ 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 (0, _Error.newKeyMismatchError)();
+ }
+
+ // 2. ask if other user shows shield as well
+ await new Promise((resolve, reject) => {
+ this.reciprocateQREvent = {
+ confirm: resolve,
+ cancel: () => reject((0, _Error.newUserCancelledError)())
+ };
+ this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
+ });
+
+ // 3. determine key to sign / mark as trusted
+ const keys = {};
+ 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 (0, _Error.newKeyMismatchError)();
+ if (keyInfo !== targetKey) {
+ _logger.logger.error("key ID from key info does not match");
+ throw (0, _Error.newKeyMismatchError)();
+ }
+ for (const deviceKeyId in device.keys) {
+ if (!deviceKeyId.startsWith("ed25519")) continue;
+ const deviceTargetKey = keys[deviceKeyId];
+ if (!deviceTargetKey) throw (0, _Error.newKeyMismatchError)();
+ if (device.keys[deviceKeyId] !== deviceTargetKey) {
+ _logger.logger.error("master key does not match");
+ throw (0, _Error.newKeyMismatchError)();
+ }
+ }
+ });
+ });
+ }
+ static factory(channel, baseApis, userId, deviceId, startEvent, request) {
+ return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ static get NAME() {
+ return "m.reciprocate.v1";
+ }
+}
+exports.ReciprocateQRCode = ReciprocateQRCode;
+const CODE_VERSION = 0x02; // the version of binary QR codes we support
+const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format
+var Mode = /*#__PURE__*/function (Mode) {
+ Mode[Mode["VerifyOtherUser"] = 0] = "VerifyOtherUser";
+ Mode[Mode["VerifySelfTrusted"] = 1] = "VerifySelfTrusted";
+ Mode[Mode["VerifySelfUntrusted"] = 2] = "VerifySelfUntrusted";
+ return Mode;
+}(Mode || {}); // We do not trust the master key
+class QRCodeData {
+ constructor(mode, sharedSecret,
+ // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code
+ otherUserMasterKey,
+ // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code
+ otherDeviceKey,
+ // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code
+ myMasterKey, buffer) {
+ this.mode = mode;
+ this.sharedSecret = sharedSecret;
+ this.otherUserMasterKey = otherUserMasterKey;
+ this.otherDeviceKey = otherDeviceKey;
+ this.myMasterKey = myMasterKey;
+ this.buffer = buffer;
+ }
+ static async create(request, client) {
+ const sharedSecret = QRCodeData.generateSharedSecret();
+ const mode = QRCodeData.determineMode(request, client);
+ let otherUserMasterKey = null;
+ let otherDeviceKey = null;
+ let myMasterKey = 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.
+ */
+ get encodedSharedSecret() {
+ return this.sharedSecret;
+ }
+ getBuffer() {
+ return this.buffer;
+ }
+ static generateSharedSecret() {
+ const secretBytes = new Uint8Array(11);
+ global.crypto.getRandomValues(secretBytes);
+ return (0, _olmlib.encodeUnpaddedBase64)(secretBytes);
+ }
+ static async getOtherDeviceKey(request, client) {
+ 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();
+ }
+ static determineMode(request, client) {
+ 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;
+ }
+ static generateQrData(request, client, mode, encodedSharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey) {
+ const myUserId = client.getUserId();
+ const transactionId = request.channel.transactionId;
+ const qrData = {
+ 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;
+ }
+ static generateBuffer(qrData) {
+ let buf = Buffer.alloc(0); // we'll concat our way through life
+
+ const appendByte = b => {
+ const tmpBuf = Buffer.from([b]);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendInt = i => {
+ const tmpBuf = Buffer.alloc(2);
+ tmpBuf.writeInt16BE(i, 0);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendStr = (s, enc, withLengthPrefix = true) => {
+ const tmpBuf = Buffer.from(s, enc);
+ if (withLengthPrefix) appendInt(tmpBuf.byteLength);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendEncBase64 = b64 => {
+ const b = (0, _olmlib.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;
+ }
+}
+exports.QRCodeData = QRCodeData; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js
new file mode 100644
index 0000000000..fac79f7a00
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js
@@ -0,0 +1,454 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SasEvent = exports.SAS = void 0;
+var _anotherJson = _interopRequireDefault(require("another-json"));
+var _Base = require("./Base");
+var _Error = require("./Error");
+var _logger = require("../../logger");
+var _SASDecimal = require("./SASDecimal");
+var _event = require("../../@types/event");
+var _verification = require("../../crypto-api/verification");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// backwards-compatibility exports
+
+const START_TYPE = _event.EventType.KeyVerificationStart;
+const EVENTS = [_event.EventType.KeyVerificationAccept, _event.EventType.KeyVerificationKey, _event.EventType.KeyVerificationMac];
+let olmutil;
+const newMismatchedSASError = (0, _Error.errorFactory)("m.mismatched_sas", "Mismatched short authentication string");
+const newMismatchedCommitmentError = (0, _Error.errorFactory)("m.mismatched_commitment", "Mismatched commitment");
+const 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) {
+ 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: _SASDecimal.generateDecimalSas,
+ emoji: generateEmojiSas
+};
+function generateSas(sasBytes, methods) {
+ const sas = {};
+ 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"
+};
+function calculateMAC(olmSAS, method) {
+ return function (input, info) {
+ const mac = olmSAS[macMethods[method]](input, info);
+ _logger.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, olmSAS, bytes) {
+ 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, olmSAS, bytes) {
+ 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);
+ }
+};
+/* 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 = ["curve25519-hkdf-sha256", "curve25519"];
+const HASHES_LIST = ["sha256"];
+const MAC_LIST = ["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(anArray, aSet) {
+ return Array.isArray(anArray) ? anArray.filter(x => aSet.has(x)) : [];
+}
+
+/** @deprecated use VerifierEvent */
+
+/** @deprecated use VerifierEvent */
+const SasEvent = _verification.VerifierEvent;
+exports.SasEvent = SasEvent;
+class SAS extends _Base.VerificationBase {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "waitingForAccept", void 0);
+ _defineProperty(this, "ourSASPubKey", void 0);
+ _defineProperty(this, "theirSASPubKey", void 0);
+ _defineProperty(this, "sasEvent", void 0);
+ _defineProperty(this, "doVerification", async () => {
+ 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 _Base.SwitchStartEventError) {
+ // this changes what initiatedByMe returns
+ this.startEvent = err.startEvent;
+ retry = true;
+ } else {
+ throw err;
+ }
+ }
+ } while (retry);
+ });
+ }
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ static get NAME() {
+ return "m.sas.v1";
+ }
+ get events() {
+ return EVENTS;
+ }
+ canSwitchStartEvent(event) {
+ if (event.getType() !== START_TYPE) {
+ return false;
+ }
+ const content = event.getContent();
+ return content?.method === SAS.NAME && !!this.waitingForAccept;
+ }
+ async sendStart() {
+ 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;
+ }
+ async verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod) {
+ const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6);
+ const verifySAS = new Promise((resolve, reject) => {
+ this.sasEvent = {
+ sas: generateSas(sasBytes, sasMethods),
+ confirm: async () => {
+ try {
+ await this.sendMAC(olmSAS, macMethod);
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ },
+ cancel: () => reject((0, _Error.newUserCancelledError)()),
+ mismatch: () => reject(newMismatchedSASError())
+ };
+ this.emit(SasEvent.ShowSas, this.sasEvent);
+ });
+ const [e] = await Promise.all([this.waitForEvent(_event.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 = _event.EventType.KeyVerificationDone;
+ return e;
+ }), verifySAS]);
+ const content = e.getContent();
+ await this.checkMAC(olmSAS, content, macMethod);
+ }
+ async doSendVerification() {
+ 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 _Base.SwitchStartEventError(this.startEvent);
+ }
+ let e;
+ try {
+ e = await this.waitForEvent(_event.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 (0, _Error.newUnknownMethodError)();
+ }
+ if (typeof content.commitment !== "string") {
+ throw (0, _Error.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(_event.EventType.KeyVerificationKey, {
+ key: this.ourSASPubKey
+ });
+ e = await this.waitForEvent(_event.EventType.KeyVerificationKey);
+ // FIXME: make sure event is properly formed
+ content = e.getContent();
+ const commitmentStr = content.key + _anotherJson.default.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();
+ }
+ }
+ async doRespondVerification() {
+ // 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 (0, _Error.newUnknownMethodError)();
+ }
+ const olmSAS = new global.Olm.SAS();
+ try {
+ const commitmentStr = olmSAS.get_pubkey() + _anotherJson.default.stringify(content);
+ await this.send(_event.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(_event.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(_event.EventType.KeyVerificationKey, {
+ key: this.ourSASPubKey
+ });
+ await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod);
+ } finally {
+ olmSAS.free();
+ }
+ }
+ sendMAC(olmSAS, method) {
+ const mac = {};
+ const keyList = [];
+ 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(_event.EventType.KeyVerificationMac, {
+ mac,
+ keys
+ });
+ }
+ async checkMAC(olmSAS, content, method) {
+ 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 (0, _Error.newKeyMismatchError)();
+ }
+ await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => {
+ if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) {
+ throw (0, _Error.newKeyMismatchError)();
+ }
+ });
+ }
+}
+exports.SAS = SAS; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js
new file mode 100644
index 0000000000..7cd8fb2505
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js
@@ -0,0 +1,39 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.generateDecimalSas = generateDecimalSas;
+/*
+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
+ */
+function generateDecimalSas(sasBytes) {
+ /*
+ * +--------+--------+--------+--------+--------+
+ * | 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];
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js
new file mode 100644
index 0000000000..15c7fcae5a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js
@@ -0,0 +1,349 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.InRoomRequests = exports.InRoomChannel = void 0;
+var _VerificationRequest = require("./VerificationRequest");
+var _logger = require("../../../logger");
+var _event = require("../../../@types/event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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 MESSAGE_TYPE = _event.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.
+ */
+class InRoomChannel {
+ /**
+ * @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.
+ */
+ constructor(client, roomId, userId) {
+ this.client = client;
+ this.roomId = roomId;
+ this.userId = userId;
+ _defineProperty(this, "requestEventId", void 0);
+ }
+ get receiveStartFromOtherDevices() {
+ return true;
+ }
+
+ /** The transaction id generated/used by this verification channel */
+ get transactionId() {
+ return this.requestEventId;
+ }
+ static getOtherPartyUserId(event, client) {
+ const type = InRoomChannel.getEventType(event);
+ if (type !== _VerificationRequest.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
+ */
+ getTimestamp(event) {
+ 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
+ */
+ static canCreateRequest(type) {
+ return type === _VerificationRequest.REQUEST_TYPE;
+ }
+ canCreateRequest(type) {
+ 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
+ */
+ static getTransactionId(event) {
+ if (InRoomChannel.getEventType(event) === _VerificationRequest.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
+ */
+ static validateEvent(event, client) {
+ 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 === _VerificationRequest.REQUEST_TYPE) {
+ if (!content || typeof content.to !== "string" || !content.to.length) {
+ _logger.logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
+ return false;
+ }
+
+ // ignore requests that are not direct to or sent by the syncing user
+ if (!InRoomChannel.getOtherPartyUserId(event, client)) {
+ _logger.logger.log("InRoomChannel: validateEvent: " + `not directed to or sent by me: ${event.getSender()}` + `, ${content.to}`);
+ return false;
+ }
+ }
+ return _VerificationRequest.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
+ */
+ static getEventType(event) {
+ const type = event.getType();
+ if (type === MESSAGE_TYPE) {
+ const content = event.getContent();
+ if (content) {
+ const {
+ msgtype
+ } = content;
+ if (msgtype === _VerificationRequest.REQUEST_TYPE) {
+ return _VerificationRequest.REQUEST_TYPE;
+ }
+ }
+ }
+ if (type && type !== _VerificationRequest.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.
+ */
+ async handleEvent(event, request, isLiveEvent = false) {
+ // 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.logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`);
+ return;
+ }
+ }
+ if (!this.requestEventId) {
+ this.requestEventId = InRoomChannel.getTransactionId(event);
+ }
+
+ // With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
+ // back via /sync. These are "local echoes" and are identifiable by their txnId
+ const isLocalEcho = !!event.getTxnId();
+
+ // Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
+ // and have a transaction ID in the "unsigned" data
+ const isRemoteEcho = !!event.getUnsigned().transaction_id;
+ const isSentByUs = event.getSender() === this.client.getUserId();
+ return request.handleEvent(type, event, isLiveEvent, isLocalEcho || 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
+ */
+ completedContentFromEvent(event) {
+ // 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.
+ */
+ completeContent(type, content) {
+ content = Object.assign({}, content);
+ if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.START_TYPE) {
+ content.from_device = this.client.getDeviceId();
+ }
+ if (type === _VerificationRequest.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: _VerificationRequest.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
+ */
+ send(type, uncompletedContent) {
+ 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
+ */
+ async sendCompleted(type, content) {
+ let sendType = type;
+ if (type === _VerificationRequest.REQUEST_TYPE) {
+ sendType = MESSAGE_TYPE;
+ }
+ const response = await this.client.sendEvent(this.roomId, sendType, content);
+ if (type === _VerificationRequest.REQUEST_TYPE) {
+ this.requestEventId = response.event_id;
+ }
+ }
+}
+exports.InRoomChannel = InRoomChannel;
+class InRoomRequests {
+ constructor() {
+ _defineProperty(this, "requestsByRoomId", new Map());
+ }
+ getRequest(event) {
+ const roomId = event.getRoomId();
+ const txnId = InRoomChannel.getTransactionId(event);
+ return this.getRequestByTxnId(roomId, txnId);
+ }
+ getRequestByChannel(channel) {
+ return this.getRequestByTxnId(channel.roomId, channel.transactionId);
+ }
+ getRequestByTxnId(roomId, txnId) {
+ const requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (requestsByTxnId) {
+ return requestsByTxnId.get(txnId);
+ }
+ }
+ setRequest(event, request) {
+ this.doSetRequest(event.getRoomId(), InRoomChannel.getTransactionId(event), request);
+ }
+ setRequestByChannel(channel, request) {
+ this.doSetRequest(channel.roomId, channel.transactionId, request);
+ }
+ doSetRequest(roomId, txnId, request) {
+ let requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (!requestsByTxnId) {
+ requestsByTxnId = new Map();
+ this.requestsByRoomId.set(roomId, requestsByTxnId);
+ }
+ requestsByTxnId.set(txnId, request);
+ }
+ removeRequest(event) {
+ 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);
+ }
+ }
+ }
+ findRequestInProgress(roomId) {
+ const requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (requestsByTxnId) {
+ for (const request of requestsByTxnId.values()) {
+ if (request.pending) {
+ return request;
+ }
+ }
+ }
+ }
+}
+exports.InRoomRequests = InRoomRequests; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js
new file mode 100644
index 0000000000..781ec9358f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js
@@ -0,0 +1,322 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ToDeviceRequests = exports.ToDeviceChannel = void 0;
+var _randomstring = require("../../../randomstring");
+var _logger = require("../../../logger");
+var _VerificationRequest = require("./VerificationRequest");
+var _Error = require("../Error");
+var _event = require("../../../models/event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * A key verification channel that sends verification events over to_device messages.
+ * Generates its own transaction ids.
+ */
+class ToDeviceChannel {
+ // userId and devices of user we're about to verify
+ constructor(client, userId, devices, transactionId, deviceId) {
+ this.client = client;
+ this.userId = userId;
+ this.devices = devices;
+ this.transactionId = transactionId;
+ this.deviceId = deviceId;
+ _defineProperty(this, "request", void 0);
+ }
+ isToDevices(devices) {
+ if (devices.length === this.devices.length) {
+ for (const device of devices) {
+ if (!this.devices.includes(device)) {
+ return false;
+ }
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+ static getEventType(event) {
+ return event.getType();
+ }
+
+ /**
+ * Extract the transaction id used by a given key verification event, if any
+ * @param event - the event
+ * @returns the transaction id
+ */
+ static getTransactionId(event) {
+ 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
+ */
+ static canCreateRequest(type) {
+ return type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.START_TYPE;
+ }
+ canCreateRequest(type) {
+ 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
+ */
+ static validateEvent(event, client) {
+ if (event.isCancelled()) {
+ _logger.logger.warn("Ignoring flagged verification request from " + event.getSender());
+ return false;
+ }
+ const content = event.getContent();
+ if (!content) {
+ _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: no content");
+ return false;
+ }
+ if (!content.transaction_id) {
+ _logger.logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id");
+ return false;
+ }
+ const type = event.getType();
+ if (type === _VerificationRequest.REQUEST_TYPE) {
+ if (!Number.isFinite(content.timestamp)) {
+ _logger.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.logger.warn("ToDeviceChannel.validateEvent: invalid: from own device");
+ return false;
+ }
+ }
+ return _VerificationRequest.VerificationRequest.validateEvent(type, event, client);
+ }
+
+ /**
+ * @param event - the event to get the timestamp of
+ * @returns the timestamp when the event was sent
+ */
+ getTimestamp(event) {
+ 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.
+ */
+ async handleEvent(event, request, isLiveEvent = false) {
+ const type = event.getType();
+ const content = event.getContent();
+ if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.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(_VerificationRequest.CANCEL_TYPE, (0, _Error.errorFromEvent)((0, _Error.newUnexpectedMessageError)()));
+ return this.sendToDevices(_VerificationRequest.CANCEL_TYPE, cancelContent, [deviceId]);
+ }
+ }
+ const wasStarted = request.phase === _VerificationRequest.PHASE_STARTED || request.phase === _VerificationRequest.PHASE_READY;
+ await request.handleEvent(event.getType(), event, isLiveEvent, false, false);
+ const isStarted = request.phase === _VerificationRequest.PHASE_STARTED || request.phase === _VerificationRequest.PHASE_READY;
+ const isAcceptingEvent = type === _VerificationRequest.START_TYPE || type === _VerificationRequest.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(_VerificationRequest.CANCEL_TYPE, {
+ code: "m.accepted",
+ reason: "Verification request accepted by another device"
+ });
+ await this.sendToDevices(_VerificationRequest.CANCEL_TYPE, message, nonChosenDevices);
+ }
+ }
+ }
+
+ /**
+ * See {@link InRoomChannel#completedContentFromEvent} for why this is needed.
+ * @param event - the received event
+ * @returns the content object
+ */
+ completedContentFromEvent(event) {
+ 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.
+ */
+ completeContent(type, content) {
+ // make a copy
+ content = Object.assign({}, content);
+ if (this.transactionId) {
+ content.transaction_id = this.transactionId;
+ }
+ if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.READY_TYPE || type === _VerificationRequest.START_TYPE) {
+ content.from_device = this.client.getDeviceId();
+ }
+ if (type === _VerificationRequest.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
+ */
+ send(type, uncompletedContent = {}) {
+ // create transaction id when sending request
+ if ((type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.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
+ */
+ async sendCompleted(type, content) {
+ let result;
+ if (type === _VerificationRequest.REQUEST_TYPE || type === _VerificationRequest.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 _event.MatrixEvent({
+ sender: this.client.getUserId(),
+ content,
+ type
+ });
+ await this.request.handleEvent(type, remoteEchoEvent, /*isLiveEvent=*/true, /*isRemoteEcho=*/true, /*isSentByUs=*/true);
+ return result;
+ }
+ async sendToDevices(type, content, devices) {
+ if (devices.length) {
+ const deviceMessages = 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
+ */
+ static makeTransactionId() {
+ return (0, _randomstring.randomString)(32);
+ }
+}
+exports.ToDeviceChannel = ToDeviceChannel;
+class ToDeviceRequests {
+ constructor() {
+ _defineProperty(this, "requestsByUserId", new Map());
+ }
+ getRequest(event) {
+ return this.getRequestBySenderAndTxnId(event.getSender(), ToDeviceChannel.getTransactionId(event));
+ }
+ getRequestByChannel(channel) {
+ return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId);
+ }
+ getRequestBySenderAndTxnId(sender, txnId) {
+ const requestsByTxnId = this.requestsByUserId.get(sender);
+ if (requestsByTxnId) {
+ return requestsByTxnId.get(txnId);
+ }
+ }
+ setRequest(event, request) {
+ this.setRequestBySenderAndTxnId(event.getSender(), ToDeviceChannel.getTransactionId(event), request);
+ }
+ setRequestByChannel(channel, request) {
+ this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId, request);
+ }
+ setRequestBySenderAndTxnId(sender, txnId, request) {
+ let requestsByTxnId = this.requestsByUserId.get(sender);
+ if (!requestsByTxnId) {
+ requestsByTxnId = new Map();
+ this.requestsByUserId.set(sender, requestsByTxnId);
+ }
+ requestsByTxnId.set(txnId, request);
+ }
+ removeRequest(event) {
+ 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);
+ }
+ }
+ }
+ findRequestInProgress(userId, devices) {
+ const requestsByTxnId = this.requestsByUserId.get(userId);
+ if (requestsByTxnId) {
+ for (const request of requestsByTxnId.values()) {
+ if (request.pending && request.channel.isToDevices(devices)) {
+ return request;
+ }
+ }
+ }
+ }
+ getRequestsInProgress(userId) {
+ const requestsByTxnId = this.requestsByUserId.get(userId);
+ if (requestsByTxnId) {
+ return Array.from(requestsByTxnId.values()).filter(r => r.pending);
+ }
+ return [];
+ }
+}
+exports.ToDeviceRequests = ToDeviceRequests; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js
new file mode 100644
index 0000000000..d7987f367f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js
@@ -0,0 +1,870 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.VerificationRequestEvent = exports.VerificationRequest = exports.START_TYPE = exports.REQUEST_TYPE = exports.READY_TYPE = exports.Phase = exports.PHASE_UNSENT = exports.PHASE_STARTED = exports.PHASE_REQUESTED = exports.PHASE_READY = exports.PHASE_DONE = exports.PHASE_CANCELLED = exports.EVENT_PREFIX = exports.DONE_TYPE = exports.CANCEL_TYPE = void 0;
+var _logger = require("../../../logger");
+var _Error = require("../Error");
+var _QRCode = require("../QRCode");
+var _event = require("../../../@types/event");
+var _typedEventEmitter = require("../../../models/typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// 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
+
+const EVENT_PREFIX = "m.key.verification.";
+exports.EVENT_PREFIX = EVENT_PREFIX;
+const REQUEST_TYPE = EVENT_PREFIX + "request";
+exports.REQUEST_TYPE = REQUEST_TYPE;
+const START_TYPE = EVENT_PREFIX + "start";
+exports.START_TYPE = START_TYPE;
+const CANCEL_TYPE = EVENT_PREFIX + "cancel";
+exports.CANCEL_TYPE = CANCEL_TYPE;
+const DONE_TYPE = EVENT_PREFIX + "done";
+exports.DONE_TYPE = DONE_TYPE;
+const READY_TYPE = EVENT_PREFIX + "ready";
+exports.READY_TYPE = READY_TYPE;
+let Phase = /*#__PURE__*/function (Phase) {
+ Phase[Phase["Unsent"] = 1] = "Unsent";
+ Phase[Phase["Requested"] = 2] = "Requested";
+ Phase[Phase["Ready"] = 3] = "Ready";
+ Phase[Phase["Started"] = 4] = "Started";
+ Phase[Phase["Cancelled"] = 5] = "Cancelled";
+ Phase[Phase["Done"] = 6] = "Done";
+ return Phase;
+}({}); // Legacy export fields
+exports.Phase = Phase;
+const PHASE_UNSENT = Phase.Unsent;
+exports.PHASE_UNSENT = PHASE_UNSENT;
+const PHASE_REQUESTED = Phase.Requested;
+exports.PHASE_REQUESTED = PHASE_REQUESTED;
+const PHASE_READY = Phase.Ready;
+exports.PHASE_READY = PHASE_READY;
+const PHASE_STARTED = Phase.Started;
+exports.PHASE_STARTED = PHASE_STARTED;
+const PHASE_CANCELLED = Phase.Cancelled;
+exports.PHASE_CANCELLED = PHASE_CANCELLED;
+const PHASE_DONE = Phase.Done;
+exports.PHASE_DONE = PHASE_DONE;
+let VerificationRequestEvent = /*#__PURE__*/function (VerificationRequestEvent) {
+ VerificationRequestEvent["Change"] = "change";
+ return VerificationRequestEvent;
+}({});
+exports.VerificationRequestEvent = VerificationRequestEvent;
+/**
+ * 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`.
+ */
+class VerificationRequest extends _typedEventEmitter.TypedEventEmitter {
+ constructor(channel, verificationMethods, client) {
+ super();
+ this.channel = channel;
+ this.verificationMethods = verificationMethods;
+ this.client = client;
+ _defineProperty(this, "eventsByUs", new Map());
+ _defineProperty(this, "eventsByThem", new Map());
+ _defineProperty(this, "_observeOnly", false);
+ _defineProperty(this, "timeoutTimer", null);
+ _defineProperty(this, "_accepting", false);
+ _defineProperty(this, "_declining", false);
+ _defineProperty(this, "verifierHasFinished", false);
+ _defineProperty(this, "_cancelled", false);
+ _defineProperty(this, "_chosenMethod", 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
+ _defineProperty(this, "_qrCodeData", null);
+ // The timestamp when we received the request event from the other side
+ _defineProperty(this, "requestReceivedAt", null);
+ _defineProperty(this, "commonMethods", []);
+ _defineProperty(this, "_phase", void 0);
+ _defineProperty(this, "_cancellingUserId", void 0);
+ // Used in tests only
+ _defineProperty(this, "_verifier", void 0);
+ _defineProperty(this, "cancelOnTimeout", async () => {
+ 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.logger.error("Error while cancelling verification request", err);
+ }
+ });
+ 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
+ */
+ static validateEvent(type, event, client) {
+ 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.logger.log("VerificationRequest: validateEvent: no content");
+ return false;
+ }
+ if (type === REQUEST_TYPE || type === READY_TYPE) {
+ if (!Array.isArray(content.methods)) {
+ _logger.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.logger.log("VerificationRequest: validateEvent: " + "fail because from_device");
+ return false;
+ }
+ }
+ return true;
+ }
+ get invalid() {
+ return this.phase === PHASE_UNSENT;
+ }
+
+ /** returns whether the phase is PHASE_REQUESTED */
+ get requested() {
+ return this.phase === PHASE_REQUESTED;
+ }
+
+ /** returns whether the phase is PHASE_CANCELLED */
+ get cancelled() {
+ return this.phase === PHASE_CANCELLED;
+ }
+
+ /** returns whether the phase is PHASE_READY */
+ get ready() {
+ return this.phase === PHASE_READY;
+ }
+
+ /** returns whether the phase is PHASE_STARTED */
+ get started() {
+ return this.phase === PHASE_STARTED;
+ }
+
+ /** returns whether the phase is PHASE_DONE */
+ get done() {
+ return this.phase === PHASE_DONE;
+ }
+
+ /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */
+ get methods() {
+ return this.commonMethods;
+ }
+
+ /** the method picked in the .start event */
+ get chosenMethod() {
+ return this._chosenMethod;
+ }
+ calculateEventTimeout(event) {
+ 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 */
+ get timeout() {
+ 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.
+ */
+ get requestEvent() {
+ return this.getEventByEither(REQUEST_TYPE);
+ }
+
+ /** current phase of the request. Some properties might only be defined in a current phase. */
+ get 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. */
+ get verifier() {
+ return this._verifier;
+ }
+ get canAccept() {
+ return this.phase < PHASE_READY && !this._accepting && !this._declining;
+ }
+ get accepting() {
+ return this._accepting;
+ }
+ get declining() {
+ return this._declining;
+ }
+
+ /** whether this request has sent it's initial event and needs more events to complete */
+ get pending() {
+ 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 */
+ get qrCodeData() {
+ 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 */
+ otherPartySupportsMethod(method, force = false) {
+ 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
+ */
+ get initiatedByMe() {
+ // 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 */
+ get requestingUserId() {
+ if (this.initiatedByMe) {
+ return this.client.getUserId();
+ } else {
+ return this.otherUserId;
+ }
+ }
+
+ /** The id of the user that (will) receive(d) the request */
+ get receivingUserId() {
+ if (this.initiatedByMe) {
+ return this.otherUserId;
+ } else {
+ return this.client.getUserId();
+ }
+ }
+
+ /** The user id of the other party in this request */
+ get otherUserId() {
+ return this.channel.userId;
+ }
+ get isSelfVerification() {
+ return this.client.getUserId() === this.otherUserId;
+ }
+
+ /**
+ * The id of the user that cancelled the request,
+ * only defined when phase is PHASE_CANCELLED
+ */
+ get cancellingUserId() {
+ 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
+ */
+ get cancellationCode() {
+ const ev = this.getEventByEither(CANCEL_TYPE);
+ return ev ? ev.getContent().code : null;
+ }
+ get observeOnly() {
+ 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
+ */
+ get targetDevice() {
+ 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
+ */
+ beginKeyVerification(method, targetDevice = null) {
+ // 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 (0, _Error.newUnknownMethodError)();
+ }
+ this._verifier = this.createVerifier(method, null, targetDevice);
+ if (!this._verifier) {
+ throw (0, _Error.newUnknownMethodError)();
+ }
+ this._chosenMethod = method;
+ }
+ }
+ return this._verifier;
+ }
+
+ /**
+ * sends the initial .request event.
+ * @returns resolves when the event has been sent.
+ */
+ async sendRequest() {
+ 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.
+ */
+ async cancel({
+ reason = "User declined",
+ code = "m.user"
+ } = {}) {
+ if (!this.observeOnly && this._phase !== PHASE_CANCELLED) {
+ this._declining = true;
+ this.emit(VerificationRequestEvent.Change);
+ if (this._verifier) {
+ return this._verifier.cancel((0, _Error.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.
+ */
+ async accept() {
+ 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
+ */
+ waitFor(fn) {
+ return new Promise((resolve, reject) => {
+ const check = () => {
+ 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);
+ }
+ });
+ }
+ setPhase(phase, notify = true) {
+ this._phase = phase;
+ if (notify) {
+ this.emit(VerificationRequestEvent.Change);
+ }
+ }
+ getEventByEither(type) {
+ return this.eventsByThem.get(type) || this.eventsByUs.get(type);
+ }
+ getEventBy(type, byThem = false) {
+ if (byThem) {
+ return this.eventsByThem.get(type);
+ } else {
+ return this.eventsByUs.get(type);
+ }
+ }
+ calculatePhaseTransitions() {
+ const transitions = [{
+ phase: PHASE_UNSENT
+ }];
+ const 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;
+ 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;
+ }
+ transitionToPhase(transition) {
+ const {
+ phase,
+ event
+ } = transition;
+ // get common methods
+ if (phase === PHASE_REQUESTED || phase === PHASE_READY) {
+ if (!this.wasSentByOwnDevice(event)) {
+ const content = event.getContent();
+ 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;
+ }
+ }
+ }
+ }
+ applyPhaseTransitions() {
+ 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;
+ }
+ isWinningStartRace(newEvent) {
+ 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;
+ }
+ hasEventId(eventId) {
+ 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.
+ */
+ async handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs) {
+ // 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(_QRCode.SCAN_QR_CODE_METHOD, true);
+ if (shouldGenerateQrCode) {
+ this._qrCodeData = await _QRCode.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.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}`);
+ }
+ }
+ setupTimeout(phase) {
+ 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;
+ }
+ }
+ }
+ async cancelOnError(type, event) {
+ if (type === START_TYPE) {
+ const method = event.getContent().method;
+ if (!this.verificationMethods.has(method)) {
+ await this.cancel((0, _Error.errorFromEvent)((0, _Error.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.logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`);
+ const reason = `Unexpected ${type} event in phase ${this.phase}`;
+ await this.cancel((0, _Error.errorFromEvent)((0, _Error.newUnexpectedMessageError)({
+ reason
+ })));
+ return true;
+ }
+ return false;
+ }
+ adjustObserveOnly(event, isLiveEvent = false) {
+ // don't send out events for historical requests
+ if (!isLiveEvent) {
+ this._observeOnly = true;
+ }
+ if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) {
+ this._observeOnly = true;
+ }
+ }
+ addEvent(type, event, isSentByUs = false) {
+ 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();
+ }
+ }
+ createVerifier(method, startEvent = null, targetDevice = null) {
+ if (!targetDevice) {
+ targetDevice = this.targetDevice;
+ }
+ const {
+ userId,
+ deviceId
+ } = targetDevice;
+ const VerifierCtor = this.verificationMethods.get(method);
+ if (!VerifierCtor) {
+ _logger.logger.warn("could not find verifier constructor for method", method);
+ return;
+ }
+ return new VerifierCtor(this.channel, this.client, userId, deviceId, startEvent, this);
+ }
+ wasSentByOwnUser(event) {
+ return event?.getSender() === this.client.getUserId();
+ }
+
+ // only for .request, .ready or .start
+ wasSentByOwnDevice(event) {
+ if (!this.wasSentByOwnUser(event)) {
+ return false;
+ }
+ const content = event.getContent();
+ if (!content || content.from_device !== this.client.getDeviceId()) {
+ return false;
+ }
+ return true;
+ }
+ onVerifierCancelled() {
+ this._cancelled = true;
+ // move to cancelled phase
+ const newTransitions = this.applyPhaseTransitions();
+ if (newTransitions.length) {
+ this.setPhase(newTransitions[newTransitions.length - 1].phase);
+ }
+ }
+ onVerifierFinished() {
+ this.channel.send(_event.EventType.KeyVerificationDone, {});
+ this.verifierHasFinished = true;
+ // move to .done phase
+ const newTransitions = this.applyPhaseTransitions();
+ if (newTransitions.length) {
+ this.setPhase(newTransitions[newTransitions.length - 1].phase);
+ }
+ }
+ getEventFromOtherParty(type) {
+ return this.eventsByThem.get(type);
+ }
+}
+exports.VerificationRequest = VerificationRequest; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js b/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js
new file mode 100644
index 0000000000..3ec315820a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js
@@ -0,0 +1,261 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomWidgetClient = void 0;
+var _matrixWidgetApi = require("matrix-widget-api");
+var _event = require("./models/event");
+var _event2 = require("./@types/event");
+var _logger = require("./logger");
+var _client = require("./client");
+var _sync = require("./sync");
+var _slidingSyncSdk = require("./sliding-sync-sdk");
+var _user = require("./models/user");
+var _utils = require("./utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * A MatrixClient that routes its requests through the widget API instead of the
+ * real CS API.
+ * @experimental This class is considered unstable!
+ */
+class RoomWidgetClient extends _client.MatrixClient {
+ constructor(widgetApi, capabilities, roomId, opts) {
+ super(opts);
+
+ // Request capabilities for the functionality this client needs to support
+ this.widgetApi = widgetApi;
+ this.capabilities = capabilities;
+ this.roomId = roomId;
+ _defineProperty(this, "room", void 0);
+ _defineProperty(this, "widgetApiReady", new Promise(resolve => this.widgetApi.once("ready", resolve)));
+ _defineProperty(this, "lifecycle", void 0);
+ _defineProperty(this, "syncState", null);
+ _defineProperty(this, "onEvent", async ev => {
+ 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 _event.MatrixEvent(ev.detail.data);
+ await this.syncApi.injectRoomEvents(this.room, [], [event]);
+ this.emit(_client.ClientEvent.Event, event);
+ this.setSyncState(_sync.SyncState.Syncing);
+ _logger.logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
+ } else {
+ const {
+ event_id: eventId,
+ room_id: roomId
+ } = ev.detail.data;
+ _logger.logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
+ }
+ await this.ack(ev);
+ });
+ _defineProperty(this, "onToDevice", async ev => {
+ ev.preventDefault();
+ const event = new _event.MatrixEvent({
+ type: ev.detail.data.type,
+ sender: ev.detail.data.sender,
+ content: ev.detail.data.content
+ });
+ // 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(_event2.EventType.RoomMessageEncrypted, {}, "", "");
+ this.emit(_client.ClientEvent.ToDeviceEvent, event);
+ this.setSyncState(_sync.SyncState.Syncing);
+ await this.ack(ev);
+ });
+ 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(_matrixWidgetApi.MatrixCapabilities.MSC3846TurnServers);
+ }
+ widgetApi.on(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
+ widgetApi.on(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
+
+ // Open communication with the host
+ widgetApi.start();
+ }
+ async startClient(opts = {}) {
+ 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.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.SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions());
+ } else {
+ this.syncApi = new _sync.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 _event.MatrixEvent(rawEvent));
+ await this.syncApi.injectRoomEvents(this.room, [], events);
+ events.forEach(event => {
+ this.emit(_client.ClientEvent.Event, event);
+ _logger.logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
+ });
+ }) ?? []);
+ this.setSyncState(_sync.SyncState.Syncing);
+ _logger.logger.info("Finished backfilling events");
+
+ // Watch for TURN servers, if requested
+ if (this.capabilities.turnServers) this.watchTurnServers();
+ }
+ stopClient() {
+ this.widgetApi.off(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
+ this.widgetApi.off(`action:${_matrixWidgetApi.WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
+ super.stopClient();
+ this.lifecycle.abort(); // Signal to other async tasks that the client has stopped
+ }
+
+ async joinRoom(roomIdOrAlias) {
+ if (roomIdOrAlias === this.roomId) return this.room;
+ throw new Error(`Unknown room: ${roomIdOrAlias}`);
+ }
+ async encryptAndSendEvent(room, event) {
+ let response;
+ try {
+ response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId);
+ } catch (e) {
+ this.updatePendingEventStatus(room, event, _event.EventStatus.NOT_SENT);
+ throw e;
+ }
+ room.updatePendingEvent(event, _event.EventStatus.SENT, response.event_id);
+ return {
+ event_id: response.event_id
+ };
+ }
+ async sendStateEvent(roomId, eventType, content, stateKey = "") {
+ return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
+ }
+ async sendToDevice(eventType, contentMap) {
+ await this.widgetApi.sendToDevice(eventType, false, (0, _utils.recursiveMapToObject)(contentMap));
+ return {};
+ }
+ async queueToDevice({
+ eventType,
+ batch
+ }) {
+ // map: user Id → device Id → payload
+ const contentMap = new _utils.MapWithDefault(() => new Map());
+ for (const {
+ userId,
+ deviceId,
+ payload
+ } of batch) {
+ contentMap.getOrCreate(userId).set(deviceId, payload);
+ }
+ await this.widgetApi.sendToDevice(eventType, false, (0, _utils.recursiveMapToObject)(contentMap));
+ }
+ async encryptAndSendToDevices(userDeviceInfoArr, payload) {
+ // map: user Id → device Id → payload
+ const contentMap = new _utils.MapWithDefault(() => new Map());
+ for (const {
+ userId,
+ deviceInfo: {
+ deviceId
+ }
+ } of userDeviceInfoArr) {
+ contentMap.getOrCreate(userId).set(deviceId, payload);
+ }
+ await this.widgetApi.sendToDevice(payload.type, true, (0, _utils.recursiveMapToObject)(contentMap));
+ }
+
+ // Overridden since we get TURN servers automatically over the widget API,
+ // and this method would otherwise complain about missing an access token
+ async checkTurnServers() {
+ return this.turnServers.length > 0;
+ }
+
+ // Overridden since we 'sync' manually without the sync API
+ getSyncState() {
+ return this.syncState;
+ }
+ setSyncState(state) {
+ const oldState = this.syncState;
+ this.syncState = state;
+ this.emit(_client.ClientEvent.Sync, state, oldState);
+ }
+ async ack(ev) {
+ await this.widgetApi.transport.reply(ev.detail, {});
+ }
+ async watchTurnServers() {
+ const servers = this.widgetApi.getTurnServers();
+ const onClientStopped = () => {
+ 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(_client.ClientEvent.TurnServers, this.turnServers);
+ _logger.logger.log(`Received TURN server: ${server.uris}`);
+ }
+ } catch (e) {
+ _logger.logger.warn("Error watching TURN servers", e);
+ } finally {
+ this.lifecycle.signal.removeEventListener("abort", onClientStopped);
+ }
+ }
+}
+exports.RoomWidgetClient = RoomWidgetClient; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js b/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js
new file mode 100644
index 0000000000..5ddc30b49c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/errors.js
@@ -0,0 +1,62 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.KeySignatureUploadError = exports.InvalidStoreState = exports.InvalidStoreError = exports.InvalidCryptoStoreState = exports.InvalidCryptoStoreError = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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 InvalidStoreState = /*#__PURE__*/function (InvalidStoreState) {
+ InvalidStoreState[InvalidStoreState["ToggledLazyLoading"] = 0] = "ToggledLazyLoading";
+ return InvalidStoreState;
+}({});
+exports.InvalidStoreState = InvalidStoreState;
+class InvalidStoreError extends Error {
+ constructor(reason, value) {
+ const message = `Store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`;
+ super(message);
+ this.reason = reason;
+ this.value = value;
+ this.name = "InvalidStoreError";
+ }
+}
+exports.InvalidStoreError = InvalidStoreError;
+_defineProperty(InvalidStoreError, "TOGGLED_LAZY_LOADING", InvalidStoreState.ToggledLazyLoading);
+let InvalidCryptoStoreState = /*#__PURE__*/function (InvalidCryptoStoreState) {
+ InvalidCryptoStoreState["TooNew"] = "TOO_NEW";
+ return InvalidCryptoStoreState;
+}({});
+exports.InvalidCryptoStoreState = InvalidCryptoStoreState;
+class InvalidCryptoStoreError extends Error {
+ constructor(reason) {
+ const message = `Crypto store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`;
+ super(message);
+ this.reason = reason;
+ this.name = "InvalidCryptoStoreError";
+ }
+}
+exports.InvalidCryptoStoreError = InvalidCryptoStoreError;
+_defineProperty(InvalidCryptoStoreError, "TOO_NEW", InvalidCryptoStoreState.TooNew);
+class KeySignatureUploadError extends Error {
+ constructor(message, value) {
+ super(message);
+ this.value = value;
+ }
+}
+exports.KeySignatureUploadError = KeySignatureUploadError; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js b/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js
new file mode 100644
index 0000000000..a8b30881ab
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js
@@ -0,0 +1,86 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.eventMapperFor = eventMapperFor;
+var _event = require("./models/event");
+var _event2 = require("./@types/event");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+function eventMapperFor(client, options) {
+ let preventReEmit = Boolean(options.preventReEmit);
+ const decrypt = options.decrypt !== false;
+ function mapper(plainOldJsObject) {
+ if (options.toDevice) {
+ delete plainOldJsObject.room_id;
+ }
+ const room = client.getRoom(plainOldJsObject.room_id);
+ let event;
+ // 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 _event.MatrixEvent(plainOldJsObject);
+ } else {
+ // merge the latest unsigned data from the server
+ event.setUnsigned(_objectSpread(_objectSpread({}, 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(_event2.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, [_event.MatrixEventEvent.Decrypted]);
+ }
+ if (decrypt) {
+ client.decryptEventIfNeeded(event);
+ }
+ }
+ if (!preventReEmit) {
+ client.reEmitter.reEmit(event, [_event.MatrixEventEvent.Replaced, _event.MatrixEventEvent.VisibilityChange]);
+ room?.reEmitter.reEmit(event, [_event.MatrixEventEvent.BeforeRedaction]);
+ }
+ return event;
+ }
+ return mapper;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js
new file mode 100644
index 0000000000..c3578ffc35
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js
@@ -0,0 +1,63 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ExtensibleEvent = void 0;
+/*
+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.
+*/
+
+/**
+ * Represents an Extensible Event in Matrix.
+ */
+class ExtensibleEvent {
+ constructor(wireFormat) {
+ this.wireFormat = wireFormat;
+ }
+
+ /**
+ * Shortcut to wireFormat.content
+ */
+ get wireContent() {
+ 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.
+ */
+
+ /**
+ * 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.
+ */
+}
+exports.ExtensibleEvent = ExtensibleEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js
new file mode 100644
index 0000000000..2136849a23
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.InvalidEventError = void 0;
+/*
+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.
+ */
+class InvalidEventError extends Error {
+ constructor(message) {
+ super(message);
+ }
+}
+exports.InvalidEventError = InvalidEventError; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js
new file mode 100644
index 0000000000..513266a460
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js
@@ -0,0 +1,138 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MessageEvent = void 0;
+var _ExtensibleEvent = require("./ExtensibleEvent");
+var _extensible_events = require("../@types/extensible_events");
+var _utilities = require("./utilities");
+var _InvalidEventError = require("./InvalidEventError");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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.
+ */
+class MessageEvent extends _ExtensibleEvent.ExtensibleEvent {
+ /**
+ * 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.
+ */
+ constructor(wireFormat) {
+ super(wireFormat);
+ /**
+ * The default text for the event.
+ */
+ _defineProperty(this, "text", void 0);
+ /**
+ * The default HTML for the event, if provided.
+ */
+ _defineProperty(this, "html", void 0);
+ /**
+ * 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.
+ */
+ _defineProperty(this, "renderings", void 0);
+ const mmessage = _extensible_events.M_MESSAGE.findIn(this.wireContent);
+ const mtext = _extensible_events.M_TEXT.findIn(this.wireContent);
+ const mhtml = _extensible_events.M_HTML.findIn(this.wireContent);
+ if ((0, _utilities.isProvided)(mmessage)) {
+ if (!Array.isArray(mmessage)) {
+ throw new _InvalidEventError.InvalidEventError("m.message contents must be an array");
+ }
+ const text = mmessage.find(r => !(0, _utilities.isProvided)(r.mimetype) || r.mimetype === "text/plain");
+ const html = mmessage.find(r => r.mimetype === "text/html");
+ if (!text) throw new _InvalidEventError.InvalidEventError("m.message is missing a plain text representation");
+ this.text = text.body;
+ this.html = html?.body;
+ this.renderings = mmessage;
+ } else if ((0, _utilities.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.InvalidEventError("Missing textual representation for event");
+ }
+ }
+ isEquivalentTo(primaryEventType) {
+ return (0, _extensible_events.isEventTypeSame)(primaryEventType, _extensible_events.M_MESSAGE);
+ }
+ serializeMMessageOnly() {
+ let messageRendering = {
+ [_extensible_events.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 = {
+ [_extensible_events.M_TEXT.name]: this.renderings[0].body
+ };
+ }
+ }
+ return messageRendering;
+ }
+ serialize() {
+ return {
+ type: "m.room.message",
+ content: _objectSpread(_objectSpread({}, 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.
+ */
+ static from(text, html) {
+ return new MessageEvent({
+ type: _extensible_events.M_MESSAGE.name,
+ content: {
+ [_extensible_events.M_TEXT.name]: text,
+ [_extensible_events.M_HTML.name]: html
+ }
+ });
+ }
+}
+exports.MessageEvent = MessageEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js
new file mode 100644
index 0000000000..0594146dfe
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js
@@ -0,0 +1,93 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollEndEvent = void 0;
+var _extensible_events = require("../@types/extensible_events");
+var _polls = require("../@types/polls");
+var _ExtensibleEvent = require("./ExtensibleEvent");
+var _InvalidEventError = require("./InvalidEventError");
+var _MessageEvent = require("./MessageEvent");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Represents a poll end/closure event.
+ */
+class PollEndEvent extends _ExtensibleEvent.ExtensibleEvent {
+ /**
+ * 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.
+ */
+ constructor(wireFormat) {
+ super(wireFormat);
+ /**
+ * The poll start event ID referenced by the response.
+ */
+ _defineProperty(this, "pollEventId", void 0);
+ /**
+ * The closing message for the event.
+ */
+ _defineProperty(this, "closingMessage", void 0);
+ const rel = this.wireContent["m.relates_to"];
+ if (!_extensible_events.REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
+ throw new _InvalidEventError.InvalidEventError("Relationship must be a reference to an event");
+ }
+ this.pollEventId = rel.event_id;
+ this.closingMessage = new _MessageEvent.MessageEvent(this.wireFormat);
+ }
+ isEquivalentTo(primaryEventType) {
+ return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_END);
+ }
+ serialize() {
+ return {
+ type: _polls.M_POLL_END.name,
+ content: _objectSpread({
+ "m.relates_to": {
+ rel_type: _extensible_events.REFERENCE_RELATION.name,
+ event_id: this.pollEventId
+ },
+ [_polls.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.
+ */
+ static from(pollEventId, message) {
+ return new PollEndEvent({
+ type: _polls.M_POLL_END.name,
+ content: {
+ "m.relates_to": {
+ rel_type: _extensible_events.REFERENCE_RELATION.name,
+ event_id: pollEventId
+ },
+ [_polls.M_POLL_END.name]: {},
+ [_extensible_events.M_TEXT.name]: message
+ }
+ });
+ }
+}
+exports.PollEndEvent = PollEndEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js
new file mode 100644
index 0000000000..1c6e2bf23f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js
@@ -0,0 +1,140 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollResponseEvent = void 0;
+var _ExtensibleEvent = require("./ExtensibleEvent");
+var _polls = require("../@types/polls");
+var _extensible_events = require("../@types/extensible_events");
+var _InvalidEventError = require("./InvalidEventError");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Represents a poll response event.
+ */
+class PollResponseEvent extends _ExtensibleEvent.ExtensibleEvent {
+ /**
+ * The provided answers for the poll. Note that this may be falsy/unpredictable if
+ * the `spoiled` property is true.
+ */
+ get answerIds() {
+ return this.internalAnswerIds;
+ }
+
+ /**
+ * The poll start event ID referenced by the response.
+ */
+
+ /**
+ * Whether the vote is spoiled.
+ */
+ get spoiled() {
+ 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.
+ */
+ constructor(wireFormat) {
+ super(wireFormat);
+ _defineProperty(this, "internalAnswerIds", []);
+ _defineProperty(this, "internalSpoiled", false);
+ _defineProperty(this, "pollEventId", void 0);
+ const rel = this.wireContent["m.relates_to"];
+ if (!_extensible_events.REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
+ throw new _InvalidEventError.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.
+ */
+ validateAgainst(poll) {
+ const response = _polls.M_POLL_RESPONSE.findIn(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;
+ }
+ isEquivalentTo(primaryEventType) {
+ return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_RESPONSE);
+ }
+ serialize() {
+ return {
+ type: _polls.M_POLL_RESPONSE.name,
+ content: {
+ "m.relates_to": {
+ rel_type: _extensible_events.REFERENCE_RELATION.name,
+ event_id: this.pollEventId
+ },
+ [_polls.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.
+ */
+ static from(answers, pollEventId) {
+ return new PollResponseEvent({
+ type: _polls.M_POLL_RESPONSE.name,
+ content: {
+ "m.relates_to": {
+ rel_type: _extensible_events.REFERENCE_RELATION.name,
+ event_id: pollEventId
+ },
+ [_polls.M_POLL_RESPONSE.name]: {
+ answers: answers
+ }
+ }
+ });
+ }
+}
+exports.PollResponseEvent = PollResponseEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js
new file mode 100644
index 0000000000..5a1088566b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js
@@ -0,0 +1,191 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PollStartEvent = exports.PollAnswerSubevent = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _MessageEvent = require("./MessageEvent");
+var _extensible_events = require("../@types/extensible_events");
+var _polls = require("../@types/polls");
+var _InvalidEventError = require("./InvalidEventError");
+var _ExtensibleEvent = require("./ExtensibleEvent");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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.
+ */
+class PollAnswerSubevent extends _MessageEvent.MessageEvent {
+ constructor(wireFormat) {
+ super(wireFormat);
+ /**
+ * The answer ID.
+ */
+ _defineProperty(this, "id", void 0);
+ const id = wireFormat.content.id;
+ if (!id || typeof id !== "string") {
+ throw new _InvalidEventError.InvalidEventError("Answer ID must be a non-empty string");
+ }
+ this.id = id;
+ }
+ serialize() {
+ return {
+ type: "org.matrix.sdk.poll.answer",
+ content: _objectSpread({
+ 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.
+ */
+ static from(id, text) {
+ return new PollAnswerSubevent({
+ type: "org.matrix.sdk.poll.answer",
+ content: {
+ id: id,
+ [_extensible_events.M_TEXT.name]: text
+ }
+ });
+ }
+}
+
+/**
+ * Represents a poll start event.
+ */
+exports.PollAnswerSubevent = PollAnswerSubevent;
+class PollStartEvent extends _ExtensibleEvent.ExtensibleEvent {
+ /**
+ * 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.
+ */
+ constructor(wireFormat) {
+ super(wireFormat);
+ /**
+ * The question being asked, as a MessageEvent node.
+ */
+ _defineProperty(this, "question", void 0);
+ /**
+ * 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.
+ */
+ _defineProperty(this, "kind", void 0);
+ /**
+ * The true kind as provided by the event sender. Might not be valid.
+ */
+ _defineProperty(this, "rawKind", void 0);
+ /**
+ * The maximum number of selections a user is allowed to make.
+ */
+ _defineProperty(this, "maxSelections", void 0);
+ /**
+ * The possible answers for the poll.
+ */
+ _defineProperty(this, "answers", void 0);
+ const poll = _polls.M_POLL_START.findIn(this.wireContent);
+ if (!poll?.question) {
+ throw new _InvalidEventError.InvalidEventError("A question is required");
+ }
+ this.question = new _MessageEvent.MessageEvent({
+ type: "org.matrix.sdk.poll.question",
+ content: poll.question
+ });
+ this.rawKind = poll.kind;
+ if (_polls.M_POLL_KIND_DISCLOSED.matches(this.rawKind)) {
+ this.kind = _polls.M_POLL_KIND_DISCLOSED;
+ } else {
+ this.kind = _polls.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.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.InvalidEventError("No answers available");
+ }
+ this.answers = answers;
+ }
+ isEquivalentTo(primaryEventType) {
+ return (0, _extensible_events.isEventTypeSame)(primaryEventType, _polls.M_POLL_START);
+ }
+ serialize() {
+ return {
+ type: _polls.M_POLL_START.name,
+ content: {
+ [_polls.M_POLL_START.name]: {
+ question: this.question.serialize().content,
+ kind: this.rawKind,
+ max_selections: this.maxSelections,
+ answers: this.answers.map(a => a.serialize().content)
+ },
+ [_extensible_events.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.
+ */
+ static from(question, answers, kind, maxSelections = 1) {
+ return new PollStartEvent({
+ type: _polls.M_POLL_START.name,
+ content: {
+ [_extensible_events.M_TEXT.name]: question,
+ // unused by parsing
+ [_polls.M_POLL_START.name]: {
+ question: {
+ [_extensible_events.M_TEXT.name]: question
+ },
+ kind: kind instanceof _matrixEventsSdk.NamespacedValue ? kind.name : kind,
+ max_selections: maxSelections,
+ answers: answers.map(a => ({
+ id: makeId(),
+ [_extensible_events.M_TEXT.name]: a
+ }))
+ }
+ }
+ });
+ }
+}
+exports.PollStartEvent = PollStartEvent;
+const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+function makeId() {
+ return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join("");
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js
new file mode 100644
index 0000000000..62e02febd7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js
@@ -0,0 +1,40 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isOptionalAString = isOptionalAString;
+exports.isProvided = isProvided;
+/*
+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.
+*/
+
+/**
+ * Determines if the given optional was provided a value.
+ * @param s - The optional to test.
+ * @returns True if the value is defined.
+ */
+function isProvided(s) {
+ 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.
+ */
+function isOptionalAString(s) {
+ return isProvided(s) && typeof s === "string";
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js b/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js
new file mode 100644
index 0000000000..e56be89ecb
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/feature.js
@@ -0,0 +1,78 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ServerSupport = exports.Feature = void 0;
+exports.buildFeatureSupportMap = buildFeatureSupportMap;
+/*
+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 ServerSupport = /*#__PURE__*/function (ServerSupport) {
+ ServerSupport[ServerSupport["Stable"] = 0] = "Stable";
+ ServerSupport[ServerSupport["Unstable"] = 1] = "Unstable";
+ ServerSupport[ServerSupport["Unsupported"] = 2] = "Unsupported";
+ return ServerSupport;
+}({});
+exports.ServerSupport = ServerSupport;
+let Feature = /*#__PURE__*/function (Feature) {
+ Feature["Thread"] = "Thread";
+ Feature["ThreadUnreadNotifications"] = "ThreadUnreadNotifications";
+ Feature["LoginTokenRequest"] = "LoginTokenRequest";
+ Feature["RelationBasedRedactions"] = "RelationBasedRedactions";
+ Feature["AccountDataDeletion"] = "AccountDataDeletion";
+ Feature["RelationsRecursion"] = "RelationsRecursion";
+ return Feature;
+}({});
+exports.Feature = Feature;
+const featureSupportResolver = {
+ [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"]
+ },
+ [Feature.RelationsRecursion]: {
+ unstablePrefixes: ["org.matrix.msc3981"]
+ }
+};
+async function buildFeatureSupportMap(versions) {
+ const supportMap = new Map();
+ 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, ServerSupport.Stable);
+ } else if (supportUnstablePrefixes) {
+ supportMap.set(feature, ServerSupport.Unstable);
+ } else {
+ supportMap.set(feature, ServerSupport.Unsupported);
+ }
+ }
+ return supportMap;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js b/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js
new file mode 100644
index 0000000000..7baaf0c55a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js
@@ -0,0 +1,171 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.FilterComponent = void 0;
+var _thread = require("./models/thread");
+/*
+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.
+*/
+
+/**
+ * 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, filterValue) {
+ if (filterValue.endsWith("*")) {
+ const typePrefix = filterValue.slice(0, -1);
+ return actualValue.slice(0, typePrefix.length) === typePrefix;
+ } else {
+ return actualValue === filterValue;
+ }
+}
+
+/* eslint-disable camelcase */
+
+/* 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'.
+ */
+class FilterComponent {
+ constructor(filterJson, userId) {
+ this.filterJson = filterJson;
+ this.userId = userId;
+ }
+
+ /**
+ * 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
+ */
+ check(event) {
+ const bundledRelationships = event.getUnsigned()?.["m.relations"] || {};
+ const relations = 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 = [];
+ if (this.userId && bundledRelationships?.[_thread.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
+ */
+ toJSON() {
+ 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,
+ [_thread.FILTER_RELATED_BY_SENDERS.name]: this.filterJson[_thread.FILTER_RELATED_BY_SENDERS.name] || [],
+ [_thread.FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[_thread.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
+ */
+ checkFields(roomId, sender, eventType, containsUrl, relationTypes, relationSenders) {
+ const literalKeys = {
+ rooms: function (v) {
+ return roomId === v;
+ },
+ senders: function (v) {
+ return sender === v;
+ },
+ types: function (v) {
+ return matchesWildcard(eventType, v);
+ }
+ };
+ for (const name in literalKeys) {
+ const matchFunc = literalKeys[name];
+ const notName = "not_" + name;
+ const disallowedValues = this.filterJson[notName];
+ if (disallowedValues?.some(matchFunc)) {
+ return false;
+ }
+ const allowedValues = this.filterJson[name];
+ if (allowedValues && !allowedValues.some(matchFunc)) {
+ return false;
+ }
+ }
+ const containsUrlFilter = this.filterJson.contains_url;
+ if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) {
+ return false;
+ }
+ const relationTypesFilter = this.filterJson[_thread.FILTER_RELATED_BY_REL_TYPES.name];
+ if (relationTypesFilter !== undefined) {
+ if (!this.arrayMatchesFilter(relationTypesFilter, relationTypes)) {
+ return false;
+ }
+ }
+ const relationSendersFilter = this.filterJson[_thread.FILTER_RELATED_BY_SENDERS.name];
+ if (relationSendersFilter !== undefined) {
+ if (!this.arrayMatchesFilter(relationSendersFilter, relationSenders)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ arrayMatchesFilter(filter, values) {
+ 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
+ */
+ filter(events) {
+ 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.
+ */
+ limit() {
+ return this.filterJson.limit !== undefined ? this.filterJson.limit : 10;
+ }
+}
+exports.FilterComponent = FilterComponent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js
new file mode 100644
index 0000000000..2fda475ec3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/filter.js
@@ -0,0 +1,212 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.Filter = void 0;
+var _sync = require("./@types/sync");
+var _filterComponent = require("./filter-component");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ */
+function setProp(obj, keyNesting, val) {
+ const nestedKeys = keyNesting.split(".");
+ 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 */
+
+/* eslint-enable camelcase */
+
+class Filter {
+ /**
+ * Create a filter from existing data.
+ */
+ static fromJson(userId, filterId, jsonObj) {
+ const filter = new Filter(userId, filterId);
+ filter.setDefinition(jsonObj);
+ return filter;
+ }
+ /**
+ * Construct a new Filter.
+ * @param userId - The user ID for this filter.
+ * @param filterId - The filter ID if known.
+ */
+ constructor(userId, filterId) {
+ this.userId = userId;
+ this.filterId = filterId;
+ _defineProperty(this, "definition", {});
+ _defineProperty(this, "roomFilter", void 0);
+ _defineProperty(this, "roomTimelineFilter", void 0);
+ }
+
+ /**
+ * Get the ID of this filter on your homeserver (if known)
+ * @returns The filter ID
+ */
+ getFilterId() {
+ return this.filterId;
+ }
+
+ /**
+ * Get the JSON body of the filter.
+ * @returns The filter definition
+ */
+ getDefinition() {
+ return this.definition;
+ }
+
+ /**
+ * Set the JSON body of the filter
+ * @param definition - The filter definition
+ */
+ setDefinition(definition) {
+ 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 = {};
+ if (roomFilterJson) {
+ if (roomFilterJson.rooms) {
+ roomFilterFields.rooms = roomFilterJson.rooms;
+ }
+ if (roomFilterJson.rooms) {
+ roomFilterFields.not_rooms = roomFilterJson.not_rooms;
+ }
+ }
+ this.roomFilter = new _filterComponent.FilterComponent(roomFilterFields, this.userId);
+ this.roomTimelineFilter = new _filterComponent.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
+ */
+ getRoomTimelineFilterComponent() {
+ 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
+ */
+ filterRoomTimeline(events) {
+ 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.
+ */
+ setTimelineLimit(limit) {
+ setProp(this.definition, "room.timeline.limit", limit);
+ }
+
+ /**
+ * Enable threads unread notification
+ */
+ setUnreadThreadNotifications(enabled) {
+ this.definition = _objectSpread(_objectSpread({}, this.definition), {}, {
+ room: _objectSpread(_objectSpread({}, this.definition?.room), {}, {
+ timeline: _objectSpread(_objectSpread({}, this.definition?.room?.timeline), {}, {
+ [_sync.UNREAD_THREAD_NOTIFICATIONS.name]: enabled
+ })
+ })
+ });
+ }
+ setLazyLoadMembers(enabled) {
+ 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.
+ */
+ setIncludeLeaveRooms(includeLeave) {
+ setProp(this.definition, "room.include_leave", includeLeave);
+ }
+}
+exports.Filter = Filter;
+_defineProperty(Filter, "LAZY_LOADING_MESSAGES_FILTER", {
+ lazy_load_members: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js
new file mode 100644
index 0000000000..067053b548
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js
@@ -0,0 +1,83 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MatrixError = exports.HTTPError = exports.ConnectionError = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+/**
+ * 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.
+ */
+class HTTPError extends Error {
+ constructor(msg, httpStatus) {
+ super(msg);
+ this.httpStatus = httpStatus;
+ }
+}
+exports.HTTPError = HTTPError;
+class MatrixError extends HTTPError {
+ /**
+ * 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
+ */
+ constructor(errorJson = {}, httpStatus, url, event) {
+ let message = errorJson.error || "Unknown message";
+ if (httpStatus) {
+ message = `[${httpStatus}] ${message}`;
+ }
+ if (url) {
+ message = `${message} (${url})`;
+ }
+ super(`MatrixError: ${message}`, httpStatus);
+ this.httpStatus = httpStatus;
+ this.url = url;
+ this.event = event;
+ // The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
+ _defineProperty(this, "errcode", void 0);
+ // The raw Matrix error JSON used to construct this object.
+ _defineProperty(this, "data", void 0);
+ 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.
+ */
+exports.MatrixError = MatrixError;
+class ConnectionError extends Error {
+ constructor(message, cause) {
+ super(message + (cause ? `: ${cause.message}` : ""));
+ }
+ get name() {
+ return "ConnectionError";
+ }
+}
+exports.ConnectionError = ConnectionError; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js
new file mode 100644
index 0000000000..5450392423
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js
@@ -0,0 +1,265 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.FetchHttpApi = void 0;
+var _utils = require("../utils");
+var _method = require("./method");
+var _errors = require("./errors");
+var _interface = require("./interface");
+var _utils2 = require("./utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class FetchHttpApi {
+ constructor(eventEmitter, opts) {
+ this.eventEmitter = eventEmitter;
+ this.opts = opts;
+ _defineProperty(this, "abortController", new AbortController());
+ (0, _utils.checkObjectHasKeys)(opts, ["baseUrl", "prefix"]);
+ opts.onlyData = !!opts.onlyData;
+ opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
+ }
+ abort() {
+ this.abortController.abort();
+ this.abortController = new AbortController();
+ }
+ fetch(resource, options) {
+ 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
+ */
+ setIdBaseUrl(url) {
+ this.opts.idBaseUrl = url;
+ }
+ idServerRequest(method, path, params, prefix, accessToken) {
+ if (!this.opts.idBaseUrl) {
+ throw new Error("No identity server base URL set");
+ }
+ let queryParams = undefined;
+ let body = undefined;
+ if (method === _method.Method.Get) {
+ queryParams = params;
+ } else {
+ body = params;
+ }
+ const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl);
+ const opts = {
+ 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.
+ */
+ authedRequest(method, path, queryParams, body, opts = {}) {
+ 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(method, path, queryParams, body, opts);
+ requestPromise.catch(err => {
+ if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
+ this.eventEmitter.emit(_interface.HttpApiEvent.SessionLoggedOut, err);
+ } else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
+ this.eventEmitter.emit(_interface.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.
+ */
+ request(method, path, queryParams, body, opts) {
+ const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
+ return this.requestOtherUrl(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.
+ */
+ async requestOtherUrl(method, url, body, opts = {}) {
+ 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((0, _utils2.timeoutSignal)(timeout));
+ }
+ if (opts.abortSignal) {
+ signals.push(opts.abortSignal);
+ }
+ let data;
+ if (jsonBody) {
+ data = JSON.stringify(body);
+ } else {
+ data = body;
+ }
+ const {
+ signal,
+ cleanup
+ } = (0, _utils2.anySignal)(signals);
+ let res;
+ 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 (e.name === "AbortError") {
+ throw e;
+ }
+ throw new _errors.ConnectionError("fetch failed", e);
+ } finally {
+ cleanup();
+ }
+ if (!res.ok) {
+ throw (0, _utils2.parseErrorResponse)(res, await res.text());
+ }
+ if (this.opts.onlyData) {
+ return json ? res.json() : res.text();
+ }
+ return res;
+ }
+
+ /**
+ * 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
+ */
+ getUrl(path, queryParams, prefix, baseUrl) {
+ const baseUrlWithFallback = baseUrl ?? this.opts.baseUrl;
+ const baseUrlWithoutTrailingSlash = baseUrlWithFallback.endsWith("/") ? baseUrlWithFallback.slice(0, -1) : baseUrlWithFallback;
+ const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
+ if (queryParams) {
+ (0, _utils.encodeParams)(queryParams, url.searchParams);
+ }
+ return url;
+ }
+}
+exports.FetchHttpApi = FetchHttpApi; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js
new file mode 100644
index 0000000000..c9425eec60
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js
@@ -0,0 +1,240 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _exportNames = {
+ MatrixHttpApi: true
+};
+exports.MatrixHttpApi = void 0;
+var _fetch = require("./fetch");
+var _prefix = require("./prefix");
+Object.keys(_prefix).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _prefix[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _prefix[key];
+ }
+ });
+});
+var _utils = require("../utils");
+var callbacks = _interopRequireWildcard(require("../realtime-callbacks"));
+var _method = require("./method");
+Object.keys(_method).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _method[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _method[key];
+ }
+ });
+});
+var _errors = require("./errors");
+Object.keys(_errors).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _errors[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _errors[key];
+ }
+ });
+});
+var _utils2 = require("./utils");
+Object.keys(_utils2).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _utils2[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _utils2[key];
+ }
+ });
+});
+var _interface = require("./interface");
+Object.keys(_interface).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _interface[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _interface[key];
+ }
+ });
+});
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class MatrixHttpApi extends _fetch.FetchHttpApi {
+ constructor(...args) {
+ super(...args);
+ _defineProperty(this, "uploads", []);
+ }
+ /**
+ * 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).
+ */
+ uploadContent(file, opts = {}) {
+ 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.type ?? "application/octet-stream";
+ const fileName = opts.name ?? file.name;
+ const upload = {
+ loaded: 0,
+ total: 0,
+ abortController
+ };
+ const deferred = (0, _utils.defer)();
+ if (global.XMLHttpRequest) {
+ const xhr = new global.XMLHttpRequest();
+ const timeoutFn = function () {
+ xhr.abort();
+ deferred.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 () {
+ 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) {
+ deferred.reject((0, _utils2.parseErrorResponse)(xhr, xhr.responseText));
+ } else {
+ deferred.resolve(JSON.parse(xhr.responseText));
+ }
+ } catch (err) {
+ if (err.name === "AbortError") {
+ deferred.reject(err);
+ return;
+ }
+ deferred.reject(new _errors.ConnectionError("request failed", err));
+ }
+ break;
+ }
+ };
+ xhr.upload.onprogress = ev => {
+ 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, _prefix.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.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 = {};
+ if (includeFilename && fileName) {
+ queryParams.filename = fileName;
+ }
+ const headers = {
+ "Content-Type": contentType
+ };
+ this.authedRequest(_method.Method.Post, "/upload", queryParams, file, {
+ prefix: _prefix.MediaPrefix.R0,
+ headers,
+ abortSignal: abortController.signal
+ }).then(response => {
+ return this.opts.onlyData ? response : response.json();
+ }).then(deferred.resolve, deferred.reject);
+ }
+
+ // remove the upload from the list on completion
+ upload.promise = deferred.promise.finally(() => {
+ (0, _utils.removeElement)(this.uploads, elem => elem === upload);
+ });
+ abortController.signal.addEventListener("abort", () => {
+ (0, _utils.removeElement)(this.uploads, elem => elem === upload);
+ deferred.reject(new DOMException("Aborted", "AbortError"));
+ });
+ this.uploads.push(upload);
+ return upload.promise;
+ }
+ cancelUpload(promise) {
+ const upload = this.uploads.find(u => u.promise === promise);
+ if (upload) {
+ upload.abortController.abort();
+ return true;
+ }
+ return false;
+ }
+ getCurrentUploads() {
+ 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.
+ */
+ getContentUri() {
+ return {
+ base: this.opts.baseUrl,
+ path: _prefix.MediaPrefix.R0 + "/upload",
+ params: {
+ access_token: this.opts.accessToken
+ }
+ };
+ }
+}
+exports.MatrixHttpApi = MatrixHttpApi; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js
new file mode 100644
index 0000000000..4ee57a29b0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js
@@ -0,0 +1,27 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.HttpApiEvent = void 0;
+/*
+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 HttpApiEvent = /*#__PURE__*/function (HttpApiEvent) {
+ HttpApiEvent["SessionLoggedOut"] = "Session.logged_out";
+ HttpApiEvent["NoConsent"] = "no_consent";
+ return HttpApiEvent;
+}({});
+exports.HttpApiEvent = HttpApiEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js
new file mode 100644
index 0000000000..cab6c3e720
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js
@@ -0,0 +1,29 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.Method = void 0;
+/*
+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 Method = /*#__PURE__*/function (Method) {
+ Method["Get"] = "GET";
+ Method["Put"] = "PUT";
+ Method["Post"] = "POST";
+ Method["Delete"] = "DELETE";
+ return Method;
+}({});
+exports.Method = Method; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js
new file mode 100644
index 0000000000..3bc37083b3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js
@@ -0,0 +1,39 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaPrefix = exports.IdentityPrefix = exports.ClientPrefix = void 0;
+/*
+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 ClientPrefix = /*#__PURE__*/function (ClientPrefix) {
+ ClientPrefix["R0"] = "/_matrix/client/r0";
+ ClientPrefix["V1"] = "/_matrix/client/v1";
+ ClientPrefix["V3"] = "/_matrix/client/v3";
+ ClientPrefix["Unstable"] = "/_matrix/client/unstable";
+ return ClientPrefix;
+}({});
+exports.ClientPrefix = ClientPrefix;
+let IdentityPrefix = /*#__PURE__*/function (IdentityPrefix) {
+ IdentityPrefix["V2"] = "/_matrix/identity/v2";
+ return IdentityPrefix;
+}({});
+exports.IdentityPrefix = IdentityPrefix;
+let MediaPrefix = /*#__PURE__*/function (MediaPrefix) {
+ MediaPrefix["R0"] = "/_matrix/media/r0";
+ return MediaPrefix;
+}({});
+exports.MediaPrefix = MediaPrefix; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js
new file mode 100644
index 0000000000..a39b6dc2bd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js
@@ -0,0 +1,143 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.anySignal = anySignal;
+exports.parseErrorResponse = parseErrorResponse;
+exports.retryNetworkOperation = retryNetworkOperation;
+exports.timeoutSignal = timeoutSignal;
+var _contentType = require("content-type");
+var _logger = require("../logger");
+var _utils = require("../utils");
+var _errors = require("./errors");
+/*
+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.
+*/
+
+// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
+function timeoutSignal(ms) {
+ const controller = new AbortController();
+ setTimeout(() => {
+ controller.abort();
+ }, ms);
+ return controller.signal;
+}
+function anySignal(signals) {
+ const controller = new AbortController();
+ function cleanup() {
+ for (const signal of signals) {
+ signal.removeEventListener("abort", onAbort);
+ }
+ }
+ function onAbort() {
+ 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
+ */
+function parseErrorResponse(response, body) {
+ let contentType;
+ try {
+ contentType = getResponseContentType(response);
+ } catch (e) {
+ return e;
+ }
+ if (contentType?.type === "application/json" && body) {
+ return new _errors.MatrixError(JSON.parse(body), response.status, isXhr(response) ? response.responseURL : response.url);
+ }
+ if (contentType?.type === "text/plain") {
+ return new _errors.HTTPError(`Server returned ${response.status} error: ${body}`, response.status);
+ }
+ return new _errors.HTTPError(`Server returned ${response.status} error`, response.status);
+}
+function isXhr(response) {
+ 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) {
+ let contentType;
+ if (isXhr(response)) {
+ contentType = response.getResponseHeader("Content-Type");
+ } else {
+ contentType = response.headers.get("Content-Type");
+ }
+ if (!contentType) return null;
+ try {
+ return (0, _contentType.parse)(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
+ */
+async function retryNetworkOperation(maxAttempts, callback) {
+ let attempts = 0;
+ let lastConnectionError = null;
+ while (attempts < maxAttempts) {
+ try {
+ if (attempts > 0) {
+ const timeout = 1000 * Math.pow(2, attempts);
+ _logger.logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`);
+ await (0, _utils.sleep)(timeout);
+ }
+ return await callback();
+ } catch (err) {
+ if (err instanceof _errors.ConnectionError) {
+ attempts += 1;
+ lastConnectionError = err;
+ } else {
+ throw err;
+ }
+ }
+ }
+ throw lastConnectionError;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/index.js
new file mode 100644
index 0000000000..77e952b9c0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/index.js
@@ -0,0 +1,43 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _exportNames = {};
+exports.default = void 0;
+var matrixcs = _interopRequireWildcard(require("./matrix"));
+Object.keys(matrixcs).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === matrixcs[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return matrixcs[key];
+ }
+ });
+});
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+if (global.__js_sdk_entrypoint) {
+ throw new Error("Multiple matrix-js-sdk entrypoints detected!");
+}
+global.__js_sdk_entrypoint = true;
+var _default = matrixcs;
+exports.default = _default; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js
new file mode 100644
index 0000000000..1fa33bee4e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js
@@ -0,0 +1,56 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.exists = exists;
+/*
+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
+ */
+function exists(indexedDB, dbName) {
+ return new Promise((resolve, reject) => {
+ let exists = true;
+ const req = indexedDB.open(dbName);
+ req.onupgradeneeded = () => {
+ // 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 = () => reject(req.error);
+ req.onsuccess = () => {
+ 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 = () => reject(req.error);
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js
new file mode 100644
index 0000000000..e1eb076b70
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js
@@ -0,0 +1,12 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+Object.defineProperty(exports, "IndexedDBStoreWorker", {
+ enumerable: true,
+ get: function () {
+ return _indexeddbStoreWorker.IndexedDBStoreWorker;
+ }
+});
+var _indexeddbStoreWorker = require("./store/indexeddb-store-worker"); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js b/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js
new file mode 100644
index 0000000000..d810c674dd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js
@@ -0,0 +1,510 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.NoAuthFlowFoundError = exports.InteractiveAuth = exports.AuthType = void 0;
+var _logger = require("./logger");
+var _utils = require("./utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const EMAIL_STAGE_TYPE = "m.login.email.identity";
+const MSISDN_STAGE_TYPE = "m.login.msisdn";
+
+/**
+ * Data returned in the body of a 401 response from a UIA endpoint.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
+ */
+let AuthType = /*#__PURE__*/function (AuthType) {
+ AuthType["Password"] = "m.login.password";
+ AuthType["Recaptcha"] = "m.login.recaptcha";
+ AuthType["Terms"] = "m.login.terms";
+ AuthType["Email"] = "m.login.email.identity";
+ AuthType["Msisdn"] = "m.login.msisdn";
+ AuthType["Sso"] = "m.login.sso";
+ AuthType["SsoUnstable"] = "org.matrix.login.sso";
+ AuthType["Dummy"] = "m.login.dummy";
+ AuthType["RegistrationToken"] = "m.login.registration_token";
+ AuthType["UnstableRegistrationToken"] = "org.matrix.msc3231.login.registration_token";
+ return AuthType;
+}({});
+/**
+ * The parameters which are submitted as the `auth` dict in a UIA request
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
+ */
+exports.AuthType = AuthType;
+class NoAuthFlowFoundError extends Error {
+ // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
+ constructor(m, required_stages, flows) {
+ super(m);
+ this.required_stages = required_stages;
+ this.flows = flows;
+ _defineProperty(this, "name", "NoAuthFlowFoundError");
+ }
+}
+
+/**
+ * The type of an application callback to perform the user-interactive bit of UIA.
+ *
+ * It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
+ * makes the HTTP request.
+ *
+ * The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
+ */
+exports.NoAuthFlowFoundError = NoAuthFlowFoundError;
+/**
+ * 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
+ */
+class InteractiveAuth {
+ constructor(opts) {
+ _defineProperty(this, "matrixClient", void 0);
+ _defineProperty(this, "inputs", void 0);
+ _defineProperty(this, "clientSecret", void 0);
+ _defineProperty(this, "requestCallback", void 0);
+ _defineProperty(this, "busyChangedCallback", void 0);
+ _defineProperty(this, "stateUpdatedCallback", void 0);
+ _defineProperty(this, "requestEmailTokenCallback", void 0);
+ _defineProperty(this, "supportedStages", void 0);
+ _defineProperty(this, "data", void 0);
+ _defineProperty(this, "emailSid", void 0);
+ _defineProperty(this, "requestingEmailToken", false);
+ _defineProperty(this, "attemptAuthDeferred", null);
+ _defineProperty(this, "chosenFlow", null);
+ _defineProperty(this, "currentStage", null);
+ _defineProperty(this, "emailAttempt", 1);
+ // if we are currently trying to submit an auth dict (which includes polling)
+ // the promise the will resolve/reject when it completes
+ _defineProperty(this, "submitPromise", null);
+ /**
+ * Requests a new email token and sets the email sid for the validation session
+ */
+ _defineProperty(this, "requestEmailToken", async () => {
+ if (!this.requestingEmailToken) {
+ _logger.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.logger.trace("Email token request succeeded");
+ } finally {
+ this.requestingEmailToken = false;
+ }
+ } else {
+ _logger.logger.warn("Could not request email token: Already requesting");
+ }
+ });
+ 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;
+ if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages);
+ }
+
+ /**
+ * 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
+ */
+ attemptAuth() {
+ // This promise will be quite long-lived and will resolve when the
+ // request is authenticated and completes successfully.
+ this.attemptAuthDeferred = (0, _utils.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.
+ */
+ async poll() {
+ 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 = {};
+ 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 = {
+ 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
+ */
+ getSessionId() {
+ return this.data?.session;
+ }
+
+ /**
+ * get the client secret used for validation sessions
+ * with the identity server.
+ *
+ * @returns client secret
+ */
+ getClientSecret() {
+ 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
+ */
+ getStageParams(loginType) {
+ return this.data.params?.[loginType];
+ }
+ getChosenFlow() {
+ 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.
+ */
+ async submitAuthDict(authData, background = false) {
+ 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;
+ 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
+ */
+ getEmailSid() {
+ 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
+ */
+ setEmailSid(sid) {
+ this.emailSid = sid;
+ }
+ /**
+ * 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.
+ */
+ async doRequest(auth, background = false) {
+ 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 = error.data?.flows ?? null;
+ const haveFlows = this.data.flows || Boolean(errorFlows);
+ if (error.httpStatus !== 401 || !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.logger.log("Background poll request failed doing UI auth: ignoring", error);
+ }
+ }
+ if (!error.data) {
+ 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 (!error.data.flows && !error.data.completed && !error.data.session) {
+ error.data.flows = this.data.flows;
+ error.data.completed = this.data.completed;
+ error.data.session = this.data.session;
+ }
+ this.data = 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
+ */
+ startNextAuthStage() {
+ 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
+ */
+ chooseStage() {
+ if (this.chosenFlow === null) {
+ this.chosenFlow = this.chooseFlow();
+ }
+ _logger.logger.log("Active flow => %s", JSON.stringify(this.chosenFlow));
+ const nextStage = this.firstUncompletedStage(this.chosenFlow);
+ _logger.logger.log("Next stage: %s", nextStage);
+ return nextStage;
+ }
+
+ // Returns a low number for flows we consider best. Counts increase for longer flows and even more so
+ // for flows which contain stages not listed in `supportedStages`.
+ scoreFlow(flow) {
+ let score = flow.stages.length;
+ if (this.supportedStages !== undefined) {
+ // Add 10 points to the score for each unsupported stage in the flow.
+ score += flow.stages.filter(stage => !this.supportedStages.has(stage)).length * 10;
+ }
+ return score;
+ }
+
+ /**
+ * 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
+ */
+ chooseFlow() {
+ 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);
+
+ // Flows are not represented in a significant order, so we can choose any we support best
+ // Sort flows based on how many unsupported stages they contain ascending
+ flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b));
+ 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 = [];
+ 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
+ */
+ firstUncompletedStage(flow) {
+ const completed = this.data.completed || [];
+ return flow.stages.find(stageType => !completed.includes(stageType));
+ }
+}
+exports.InteractiveAuth = InteractiveAuth; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js b/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js
new file mode 100644
index 0000000000..5946410754
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/logger.js
@@ -0,0 +1,80 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.logger = void 0;
+var _loglevel = _interopRequireDefault(require("loglevel"));
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+/*
+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.
+*/
+
+// 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.
+_loglevel.default.methodFactory = function (methodName, logLevel, loggerName) {
+ return function (...args) {
+ /* 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.
+ */
+const logger = _loglevel.default.getLogger(DEFAULT_NAMESPACE);
+exports.logger = logger;
+logger.setLevel(_loglevel.default.levels.DEBUG, false);
+function extendLogger(logger) {
+ logger.withPrefix = function (prefix) {
+ const existingPrefix = this.prefix || "";
+ return getPrefixedLogger(existingPrefix + prefix);
+ };
+}
+extendLogger(logger);
+function getPrefixedLogger(prefix) {
+ const prefixLogger = _loglevel.default.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`);
+ 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(_loglevel.default.levels.DEBUG, false);
+ }
+ return prefixLogger;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js b/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js
new file mode 100644
index 0000000000..0e01b8eaeb
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js
@@ -0,0 +1,546 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _exportNames = {
+ setCryptoStoreFactory: true,
+ createClient: true,
+ createRoomWidgetClient: true,
+ ContentHelpers: true,
+ SecretStorage: true,
+ createNewMatrixCall: true,
+ GroupCallEvent: true,
+ GroupCallIntent: true,
+ GroupCallState: true,
+ GroupCallType: true,
+ CryptoEvent: true,
+ DeviceVerificationStatus: true,
+ Crypto: true
+};
+exports.Crypto = exports.ContentHelpers = void 0;
+Object.defineProperty(exports, "CryptoEvent", {
+ enumerable: true,
+ get: function () {
+ return _crypto.CryptoEvent;
+ }
+});
+Object.defineProperty(exports, "DeviceVerificationStatus", {
+ enumerable: true,
+ get: function () {
+ return _Crypto.DeviceVerificationStatus;
+ }
+});
+Object.defineProperty(exports, "GroupCallEvent", {
+ enumerable: true,
+ get: function () {
+ return _groupCall.GroupCallEvent;
+ }
+});
+Object.defineProperty(exports, "GroupCallIntent", {
+ enumerable: true,
+ get: function () {
+ return _groupCall.GroupCallIntent;
+ }
+});
+Object.defineProperty(exports, "GroupCallState", {
+ enumerable: true,
+ get: function () {
+ return _groupCall.GroupCallState;
+ }
+});
+Object.defineProperty(exports, "GroupCallType", {
+ enumerable: true,
+ get: function () {
+ return _groupCall.GroupCallType;
+ }
+});
+exports.SecretStorage = void 0;
+exports.createClient = createClient;
+Object.defineProperty(exports, "createNewMatrixCall", {
+ enumerable: true,
+ get: function () {
+ return _call.createNewMatrixCall;
+ }
+});
+exports.createRoomWidgetClient = createRoomWidgetClient;
+exports.setCryptoStoreFactory = setCryptoStoreFactory;
+var _memoryCryptoStore = require("./crypto/store/memory-crypto-store");
+Object.keys(_memoryCryptoStore).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _memoryCryptoStore[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _memoryCryptoStore[key];
+ }
+ });
+});
+var _memory = require("./store/memory");
+Object.keys(_memory).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _memory[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _memory[key];
+ }
+ });
+});
+var _scheduler = require("./scheduler");
+Object.keys(_scheduler).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _scheduler[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _scheduler[key];
+ }
+ });
+});
+var _client = require("./client");
+Object.keys(_client).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _client[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _client[key];
+ }
+ });
+});
+var _embedded = require("./embedded");
+Object.keys(_embedded).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _embedded[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _embedded[key];
+ }
+ });
+});
+var _httpApi = require("./http-api");
+Object.keys(_httpApi).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _httpApi[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _httpApi[key];
+ }
+ });
+});
+var _autodiscovery = require("./autodiscovery");
+Object.keys(_autodiscovery).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _autodiscovery[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _autodiscovery[key];
+ }
+ });
+});
+var _syncAccumulator = require("./sync-accumulator");
+Object.keys(_syncAccumulator).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _syncAccumulator[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _syncAccumulator[key];
+ }
+ });
+});
+var _errors = require("./errors");
+Object.keys(_errors).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _errors[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _errors[key];
+ }
+ });
+});
+var _beacon = require("./models/beacon");
+Object.keys(_beacon).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _beacon[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _beacon[key];
+ }
+ });
+});
+var _event = require("./models/event");
+Object.keys(_event).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _event[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _event[key];
+ }
+ });
+});
+var _room = require("./models/room");
+Object.keys(_room).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _room[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _room[key];
+ }
+ });
+});
+var _eventTimeline = require("./models/event-timeline");
+Object.keys(_eventTimeline).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _eventTimeline[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _eventTimeline[key];
+ }
+ });
+});
+var _eventTimelineSet = require("./models/event-timeline-set");
+Object.keys(_eventTimelineSet).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _eventTimelineSet[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _eventTimelineSet[key];
+ }
+ });
+});
+var _poll = require("./models/poll");
+Object.keys(_poll).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _poll[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _poll[key];
+ }
+ });
+});
+var _roomMember = require("./models/room-member");
+Object.keys(_roomMember).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _roomMember[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _roomMember[key];
+ }
+ });
+});
+var _roomState = require("./models/room-state");
+Object.keys(_roomState).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _roomState[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _roomState[key];
+ }
+ });
+});
+var _typedEventEmitter = require("./models/typed-event-emitter");
+Object.keys(_typedEventEmitter).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _typedEventEmitter[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _typedEventEmitter[key];
+ }
+ });
+});
+var _user = require("./models/user");
+Object.keys(_user).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _user[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _user[key];
+ }
+ });
+});
+var _device = require("./models/device");
+Object.keys(_device).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _device[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _device[key];
+ }
+ });
+});
+var _filter = require("./filter");
+Object.keys(_filter).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _filter[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _filter[key];
+ }
+ });
+});
+var _timelineWindow = require("./timeline-window");
+Object.keys(_timelineWindow).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _timelineWindow[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _timelineWindow[key];
+ }
+ });
+});
+var _interactiveAuth = require("./interactive-auth");
+Object.keys(_interactiveAuth).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _interactiveAuth[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _interactiveAuth[key];
+ }
+ });
+});
+var _serviceTypes = require("./service-types");
+Object.keys(_serviceTypes).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _serviceTypes[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _serviceTypes[key];
+ }
+ });
+});
+var _indexeddb = require("./store/indexeddb");
+Object.keys(_indexeddb).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _indexeddb[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _indexeddb[key];
+ }
+ });
+});
+var _indexeddbCryptoStore = require("./crypto/store/indexeddb-crypto-store");
+Object.keys(_indexeddbCryptoStore).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _indexeddbCryptoStore[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _indexeddbCryptoStore[key];
+ }
+ });
+});
+var _contentRepo = require("./content-repo");
+Object.keys(_contentRepo).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _contentRepo[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _contentRepo[key];
+ }
+ });
+});
+var _event2 = require("./@types/event");
+Object.keys(_event2).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _event2[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _event2[key];
+ }
+ });
+});
+var _PushRules = require("./@types/PushRules");
+Object.keys(_PushRules).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _PushRules[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _PushRules[key];
+ }
+ });
+});
+var _partials = require("./@types/partials");
+Object.keys(_partials).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _partials[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _partials[key];
+ }
+ });
+});
+var _requests = require("./@types/requests");
+Object.keys(_requests).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _requests[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _requests[key];
+ }
+ });
+});
+var _search = require("./@types/search");
+Object.keys(_search).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _search[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _search[key];
+ }
+ });
+});
+var _roomSummary = require("./models/room-summary");
+Object.keys(_roomSummary).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
+ if (key in exports && exports[key] === _roomSummary[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _roomSummary[key];
+ }
+ });
+});
+var _ContentHelpers = _interopRequireWildcard(require("./content-helpers"));
+exports.ContentHelpers = _ContentHelpers;
+var _SecretStorage = _interopRequireWildcard(require("./secret-storage"));
+exports.SecretStorage = _SecretStorage;
+var _call = require("./webrtc/call");
+var _groupCall = require("./webrtc/groupCall");
+var _crypto = require("./crypto");
+var _Crypto = _interopRequireWildcard(require("./crypto-api"));
+exports.Crypto = _Crypto;
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+// used to be located here
+
+/**
+ * Types supporting cryptography.
+ *
+ * The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
+ * {@link MatrixClient.getCrypto}.
+ */
+
+/**
+ * Backwards compatibility re-export
+ * @internal
+ * @deprecated use {@link Crypto.CryptoApi}
+ */
+
+/**
+ * Backwards compatibility re-export
+ * @internal
+ * @deprecated use {@link Crypto.DeviceVerificationStatus}
+ */
+
+let cryptoStoreFactory = () => new _memoryCryptoStore.MemoryCryptoStore();
+
+/**
+ * Configure a different factory to be used for creating crypto stores
+ *
+ * @param fac - a function which will return a new `CryptoStore`
+ */
+function setCryptoStoreFactory(fac) {
+ cryptoStoreFactory = fac;
+}
+function amendClientOpts(opts) {
+ opts.store = opts.store ?? new _memory.MemoryStore({
+ localStorage: global.localStorage
+ });
+ opts.scheduler = opts.scheduler ?? new _scheduler.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`.
+ */
+function createClient(opts) {
+ return new _client.MatrixClient(amendClientOpts(opts));
+}
+function createRoomWidgetClient(widgetApi, capabilities, roomId, opts) {
+ return new _embedded.RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts));
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js
new file mode 100644
index 0000000000..61560cde27
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js
@@ -0,0 +1,227 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MSC3089Branch = void 0;
+var _event = require("../@types/event");
+var _eventTimeline = require("./event-timeline");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * 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.
+ */
+class MSC3089Branch {
+ constructor(client, indexEvent, directory) {
+ this.client = client;
+ this.indexEvent = indexEvent;
+ this.directory = directory;
+ } // Nothing to do
+
+ /**
+ * The file ID.
+ */
+ get id() {
+ const stateKey = this.indexEvent.getStateKey();
+ if (!stateKey) {
+ throw new Error("State key not found for branch");
+ }
+ return stateKey;
+ }
+
+ /**
+ * Whether this branch is active/valid.
+ */
+ get isActive() {
+ return this.indexEvent.getContent()["active"] === true;
+ }
+
+ /**
+ * Version for the file, one-indexed.
+ */
+ get version() {
+ return this.indexEvent.getContent()["version"] ?? 1;
+ }
+ get roomId() {
+ return this.indexEvent.getRoomId();
+ }
+
+ /**
+ * Deletes the file from the tree, including all prior edits/versions.
+ * @returns Promise which resolves when complete.
+ */
+ async delete() {
+ await this.client.sendStateEvent(this.roomId, _event.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.
+ */
+ getName() {
+ 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.
+ */
+ async setName(name) {
+ await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, this.indexEvent.getContent()), {}, {
+ name: name
+ }), this.id);
+ }
+
+ /**
+ * Gets whether or not a file is locked.
+ * @returns True if locked, false otherwise.
+ */
+ isLocked() {
+ 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.
+ */
+ async setLocked(locked) {
+ await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, this.indexEvent.getContent()), {}, {
+ locked: locked
+ }), this.id);
+ }
+
+ /**
+ * Gets information about the file needed to download it.
+ * @returns Information about the file.
+ */
+ async getFileInfo() {
+ 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.
+ */
+ async getFileEvent() {
+ const room = this.client.getRoom(this.roomId);
+ if (!room) throw new Error("Unknown room");
+ let event = 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.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.
+ */
+ async createNewVersion(name, encryptedContents, info, additionalContent) {
+ const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, _objectSpread(_objectSpread({}, additionalContent ?? {}), {}, {
+ "m.new_content": true,
+ "m.relates_to": {
+ rel_type: _event.RelationType.Replace,
+ event_id: this.id
+ }
+ }));
+
+ // Update the version of the new event
+ await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, {
+ active: true,
+ name: name,
+ version: this.version + 1
+ }, fileEventResponse["event_id"]);
+
+ // Deprecate ourselves
+ await this.client.sendStateEvent(this.roomId, _event.UNSTABLE_MSC3089_BRANCH.name, _objectSpread(_objectSpread({}, 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.
+ */
+ async getVersionHistory() {
+ const fileHistory = [];
+ 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;
+ 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;
+ }
+}
+exports.MSC3089Branch = MSC3089Branch; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js
new file mode 100644
index 0000000000..e73ef3f12a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js
@@ -0,0 +1,508 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TreePermissions = exports.MSC3089TreeSpace = exports.DEFAULT_TREE_POWER_LEVELS_TEMPLATE = void 0;
+var _pRetry = _interopRequireDefault(require("p-retry"));
+var _event = require("../@types/event");
+var _logger = require("../logger");
+var _utils = require("../utils");
+var _MSC3089Branch = require("./MSC3089Branch");
+var _megolm = require("../crypto/algorithms/megolm");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * The recommended defaults for a tree space's power levels. Note that this
+ * is UNSTABLE and subject to breaking changes without notice.
+ */
+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: {
+ [_event.EventType.RoomPowerLevels]: 100,
+ [_event.EventType.RoomHistoryVisibility]: 100,
+ [_event.EventType.RoomTombstone]: 100,
+ [_event.EventType.RoomEncryption]: 100,
+ [_event.EventType.RoomName]: 50,
+ [_event.EventType.RoomMessage]: 50,
+ [_event.EventType.RoomMessageEncrypted]: 50,
+ [_event.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.
+ */
+exports.DEFAULT_TREE_POWER_LEVELS_TEMPLATE = DEFAULT_TREE_POWER_LEVELS_TEMPLATE;
+let TreePermissions = /*#__PURE__*/function (TreePermissions) {
+ TreePermissions["Viewer"] = "viewer";
+ TreePermissions["Editor"] = "editor";
+ TreePermissions["Owner"] = "owner";
+ return TreePermissions;
+}({}); // "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.
+ */
+exports.TreePermissions = TreePermissions;
+class MSC3089TreeSpace {
+ constructor(client, roomId) {
+ this.client = client;
+ this.roomId = roomId;
+ _defineProperty(this, "room", void 0);
+ this.room = this.client.getRoom(this.roomId);
+ if (!this.room) throw new Error("Unknown room");
+ }
+
+ /**
+ * Syntactic sugar for room ID of the Space.
+ */
+ get id() {
+ return this.roomId;
+ }
+
+ /**
+ * Whether or not this is a top level space.
+ */
+ get isTopLevel() {
+ // 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(_event.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.
+ */
+ async setName(name) {
+ await this.client.sendStateEvent(this.roomId, _event.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.
+ */
+ async invite(userId, andSubspaces = true, shareHistoryKeys = true) {
+ const promises = [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 && (0, _megolm.isRoomSharedHistory)(this.room)) {
+ // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails.
+ this.client.sendSharedHistoryKeys(this.roomId, [userId]);
+ }
+ });
+ }
+ retryInvite(userId) {
+ return (0, _utils.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 _pRetry.default.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.
+ */
+ async setPermissions(userId, role) {
+ const currentPls = this.room.currentState.getStateEvents(_event.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"]?.[_event.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, _event.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.
+ */
+ getPermissions(userId) {
+ const currentPls = this.room.currentState.getStateEvents(_event.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"]?.[_event.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.
+ */
+ async createDirectory(name) {
+ const directory = await this.client.unstableCreateFileTree(name);
+ await this.client.sendStateEvent(this.roomId, _event.EventType.SpaceChild, {
+ via: [this.client.getDomain()]
+ }, directory.roomId);
+ await this.client.sendStateEvent(directory.roomId, _event.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.
+ */
+ getDirectories() {
+ const trees = [];
+ const children = this.room.currentState.getStateEvents(_event.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.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.
+ */
+ getDirectory(roomId) {
+ return this.getDirectories().find(r => r.roomId === roomId);
+ }
+
+ /**
+ * Deletes the tree, kicking all members and deleting **all subdirectories**.
+ * @returns Promise which resolves when complete.
+ */
+ async delete() {
+ const subdirectories = this.getDirectories();
+ for (const dir of subdirectories) {
+ await dir.delete();
+ }
+ const kickMemberships = ["invite", "knock", "join"];
+ const members = this.room.currentState.getStateEvents(_event.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);
+ }
+ getOrderedChildren(children) {
+ const ordered = children.map(c => ({
+ roomId: c.getStateKey(),
+ order: c.getContent()["order"]
+ })).filter(c => c.roomId);
+ 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 (0, _utils.lexicographicCompare)(a.roomId, b.roomId);
+ }
+ const createTsA = roomA.currentState.getStateEvents(_event.EventType.RoomCreate, "")?.getTs() ?? 0;
+ const createTsB = roomB.currentState.getStateEvents(_event.EventType.RoomCreate, "")?.getTs() ?? 0;
+ if (createTsA === createTsB) {
+ return (0, _utils.lexicographicCompare)(a.roomId, b.roomId);
+ }
+ return createTsA - createTsB;
+ } else {
+ // both not-null orders
+ return (0, _utils.lexicographicCompare)(a.order, b.order);
+ }
+ });
+ return ordered;
+ }
+ getParentRoom() {
+ const parents = this.room.currentState.getStateEvents(_event.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.
+ */
+ getOrder() {
+ if (this.isTopLevel) return -1;
+ const parentRoom = this.getParentRoom();
+ const children = parentRoom.currentState.getStateEvents(_event.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.
+ */
+ async setOrder(index) {
+ if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
+ const parentRoom = this.getParentRoom();
+ const children = parentRoom.currentState.getStateEvents(_event.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 = _utils.DEFAULT_ALPHABET[0];
+ let ensureBeforeIsSane = false;
+ if (!prev) {
+ // Move to front
+ if (next?.order) {
+ newOrder = (0, _utils.prevString)(next.order);
+ }
+ } else if (index === ordered.length - 1) {
+ // Move to back
+ if (next?.order) {
+ newOrder = (0, _utils.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 = (0, _utils.nextString)(startOrder);
+ } else {
+ newOrder = (0, _utils.averageBetweenStrings)(startOrder, endOrder);
+ }
+ } else {
+ if (startOrder) {
+ // We're at the end (endOrder is null, so no explicit order)
+ newOrder = (0, _utils.nextString)(startOrder);
+ } else if (endOrder) {
+ // We're at the start (startOrder is null, so nothing before us)
+ newOrder = (0, _utils.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;
+ 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 ? (0, _utils.nextString)(lastOrder) : _utils.DEFAULT_ALPHABET[0];
+ const currentChild = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild, target.roomId);
+ const content = currentChild?.getContent() ?? {
+ via: [this.client.getDomain()]
+ };
+ await this.client.sendStateEvent(parentRoom.roomId, _event.EventType.SpaceChild, _objectSpread(_objectSpread({}, content), {}, {
+ order: lastOrder
+ }), target.roomId);
+ } else {
+ lastOrder = target.order;
+ }
+ }
+ if (lastOrder) {
+ newOrder = (0, _utils.nextString)(lastOrder);
+ }
+ }
+
+ // TODO: Deal with order conflicts by reordering
+
+ // Now we can finally update our own order state
+ const currentChild = parentRoom.currentState.getStateEvents(_event.EventType.SpaceChild, this.roomId);
+ const content = currentChild?.getContent() ?? {
+ via: [this.client.getDomain()]
+ };
+ await this.client.sendStateEvent(parentRoom.roomId, _event.EventType.SpaceChild, _objectSpread(_objectSpread({}, 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.
+ */
+ async createFile(name, encryptedContents, info, additionalContent) {
+ const {
+ content_uri: mxc
+ } = await this.client.uploadContent(encryptedContents, {
+ includeFilename: false
+ });
+ info.url = mxc;
+ const fileContent = {
+ msgtype: _event.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, _objectSpread(_objectSpread(_objectSpread({}, additionalContent), fileContent), {}, {
+ [_event.UNSTABLE_MSC3089_LEAF.name]: {}
+ }));
+ await this.client.sendStateEvent(this.roomId, _event.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.
+ */
+ getFile(fileEventId) {
+ const branch = this.room.currentState.getStateEvents(_event.UNSTABLE_MSC3089_BRANCH.name, fileEventId);
+ return branch ? new _MSC3089Branch.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.
+ */
+ listFiles() {
+ 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.
+ */
+ listAllFiles() {
+ const branches = this.room.currentState.getStateEvents(_event.UNSTABLE_MSC3089_BRANCH.name) ?? [];
+ return branches.map(e => new _MSC3089Branch.MSC3089Branch(this.client, e, this));
+ }
+}
+exports.MSC3089TreeSpace = MSC3089TreeSpace; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js
new file mode 100644
index 0000000000..6b3cf3c509
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js
@@ -0,0 +1,181 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isTimestampInDuration = exports.getBeaconInfoIdentifier = exports.BeaconEvent = exports.Beacon = void 0;
+var _contentHelpers = require("../content-helpers");
+var _utils = require("../utils");
+var _typedEventEmitter = require("./typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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 BeaconEvent = /*#__PURE__*/function (BeaconEvent) {
+ BeaconEvent["New"] = "Beacon.new";
+ BeaconEvent["Update"] = "Beacon.update";
+ BeaconEvent["LivenessChange"] = "Beacon.LivenessChange";
+ BeaconEvent["Destroy"] = "Beacon.Destroy";
+ BeaconEvent["LocationUpdate"] = "Beacon.LocationUpdate";
+ return BeaconEvent;
+}({});
+exports.BeaconEvent = BeaconEvent;
+const isTimestampInDuration = (startTimestamp, durationMs, timestamp) => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp;
+
+// beacon info events are uniquely identified by
+// `<roomId>_<state_key>`
+exports.isTimestampInDuration = isTimestampInDuration;
+const getBeaconInfoIdentifier = event => `${event.getRoomId()}_${event.getStateKey()}`;
+
+// https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+exports.getBeaconInfoIdentifier = getBeaconInfoIdentifier;
+class Beacon extends _typedEventEmitter.TypedEventEmitter {
+ constructor(rootEvent) {
+ super();
+ this.rootEvent = rootEvent;
+ _defineProperty(this, "roomId", void 0);
+ // beaconInfo is assigned by setBeaconInfo in the constructor
+ // ! to make tsc believe it is definitely assigned
+ _defineProperty(this, "_beaconInfo", void 0);
+ _defineProperty(this, "_isLive", void 0);
+ _defineProperty(this, "livenessWatchTimeout", void 0);
+ _defineProperty(this, "_latestLocationEvent", void 0);
+ _defineProperty(this, "clearLatestLocation", () => {
+ this._latestLocationEvent = undefined;
+ this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
+ });
+ this.roomId = this.rootEvent.getRoomId();
+ this.setBeaconInfo(this.rootEvent);
+ }
+ get isLive() {
+ return !!this._isLive;
+ }
+ get identifier() {
+ return getBeaconInfoIdentifier(this.rootEvent);
+ }
+ get beaconInfoId() {
+ return this.rootEvent.getId();
+ }
+ get beaconInfoOwner() {
+ return this.rootEvent.getStateKey();
+ }
+ get beaconInfoEventType() {
+ return this.rootEvent.getType();
+ }
+ get beaconInfo() {
+ return this._beaconInfo;
+ }
+ get latestLocationState() {
+ return this._latestLocationEvent && (0, _contentHelpers.parseBeaconContent)(this._latestLocationEvent.getContent());
+ }
+ get latestLocationEvent() {
+ return this._latestLocationEvent;
+ }
+ update(beaconInfoEvent) {
+ 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();
+ }
+ destroy() {
+ 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
+ */
+ monitorLiveness() {
+ 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
+ */
+ addLocations(beaconLocationEvents) {
+ // discard locations for beacons that are not live
+ if (!this.isLive) {
+ return;
+ }
+ const validLocationEvents = beaconLocationEvents.filter(event => {
+ const content = event.getContent();
+ const parsed = (0, _contentHelpers.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(_utils.sortEventsByLatestContentTimestamp)?.[0];
+ if (latestLocationEvent) {
+ this._latestLocationEvent = latestLocationEvent;
+ this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
+ }
+ }
+ setBeaconInfo(event) {
+ this._beaconInfo = (0, _contentHelpers.parseBeaconInfoContent)(event.getContent());
+ this.checkLiveness();
+ }
+ checkLiveness() {
+ 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);
+ }
+ }
+}
+exports.Beacon = Beacon; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js
new file mode 100644
index 0000000000..fb91d0865a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js
@@ -0,0 +1,80 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.DeviceVerification = exports.Device = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+/** State of the verification of the device. */
+let DeviceVerification = /*#__PURE__*/function (DeviceVerification) {
+ DeviceVerification[DeviceVerification["Blocked"] = -1] = "Blocked";
+ DeviceVerification[DeviceVerification["Unverified"] = 0] = "Unverified";
+ DeviceVerification[DeviceVerification["Verified"] = 1] = "Verified";
+ return DeviceVerification;
+}({});
+/** A map from user ID to device ID to Device */
+exports.DeviceVerification = DeviceVerification;
+/**
+ * Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
+ */
+class Device {
+ constructor(opts) {
+ /** id of the device */
+ _defineProperty(this, "deviceId", void 0);
+ /** id of the user that owns the device */
+ _defineProperty(this, "userId", void 0);
+ /** list of algorithms supported by this device */
+ _defineProperty(this, "algorithms", void 0);
+ /** a map from `<key type>:<id> -> <base64-encoded key>` */
+ _defineProperty(this, "keys", void 0);
+ /** whether the device has been verified/blocked by the user */
+ _defineProperty(this, "verified", void 0);
+ /** a map `<userId, map<algorithm:device_id, signature>>` */
+ _defineProperty(this, "signatures", void 0);
+ /** display name of the device */
+ _defineProperty(this, "displayName", void 0);
+ this.deviceId = opts.deviceId;
+ this.userId = opts.userId;
+ this.algorithms = opts.algorithms;
+ this.keys = opts.keys;
+ this.verified = opts.verified || DeviceVerification.Unverified;
+ this.signatures = opts.signatures || new Map();
+ this.displayName = opts.displayName;
+ }
+
+ /**
+ * Get the fingerprint for this device (ie, the Ed25519 key)
+ *
+ * @returns base64-encoded fingerprint of this device
+ */
+ getFingerprint() {
+ return this.keys.get(`ed25519:${this.deviceId}`);
+ }
+
+ /**
+ * Get the identity key for this device (ie, the Curve25519 key)
+ *
+ * @returns base64-encoded identity key of this device
+ */
+ getIdentityKey() {
+ return this.keys.get(`curve25519:${this.deviceId}`);
+ }
+}
+exports.Device = Device; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js
new file mode 100644
index 0000000000..29f9223674
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js
@@ -0,0 +1,116 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EventContext = void 0;
+var _eventTimeline = require("./event-timeline");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class EventContext {
+ /**
+ * 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
+ */
+ constructor(ourEvent) {
+ this.ourEvent = ourEvent;
+ _defineProperty(this, "timeline", void 0);
+ _defineProperty(this, "ourEventIndex", 0);
+ _defineProperty(this, "paginateTokens", {
+ [_eventTimeline.Direction.Backward]: null,
+ [_eventTimeline.Direction.Forward]: null
+ });
+ 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.
+ */
+ getEvent() {
+ return this.timeline[this.ourEventIndex];
+ }
+
+ /**
+ * Get the list of events in this context
+ *
+ * @returns An array of MatrixEvents
+ */
+ getTimeline() {
+ return this.timeline;
+ }
+
+ /**
+ * Get the index in the timeline of our event
+ */
+ getOurEventIndex() {
+ return this.ourEventIndex;
+ }
+
+ /**
+ * Get a pagination token.
+ *
+ * @param backwards - true to get the pagination token for going
+ */
+ getPaginateToken(backwards = false) {
+ return this.paginateTokens[backwards ? _eventTimeline.Direction.Backward : _eventTimeline.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
+ */
+ setPaginateToken(token, backwards = false) {
+ this.paginateTokens[backwards ? _eventTimeline.Direction.Backward : _eventTimeline.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
+ */
+ addEvents(events, atStart = false) {
+ // 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);
+ }
+ }
+}
+exports.EventContext = EventContext; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js
new file mode 100644
index 0000000000..5618c09aca
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js
@@ -0,0 +1,35 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EventStatus = void 0;
+/*
+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
+ */
+let EventStatus = /*#__PURE__*/function (EventStatus) {
+ EventStatus["NOT_SENT"] = "not_sent";
+ EventStatus["ENCRYPTING"] = "encrypting";
+ EventStatus["SENDING"] = "sending";
+ EventStatus["QUEUED"] = "queued";
+ EventStatus["SENT"] = "sent";
+ EventStatus["CANCELLED"] = "cancelled";
+ return EventStatus;
+}({});
+exports.EventStatus = EventStatus; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js
new file mode 100644
index 0000000000..3452bbfaee
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js
@@ -0,0 +1,809 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EventTimelineSet = exports.DuplicateStrategy = void 0;
+var _eventTimeline = require("./event-timeline");
+var _logger = require("../logger");
+var _room = require("./room");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _relationsContainer = require("./relations-container");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const DEBUG = true;
+
+/* istanbul ignore next */
+let debuglog;
+if (DEBUG) {
+ // using bind means that we get to keep useful line numbers in the console
+ debuglog = _logger.logger.log.bind(_logger.logger);
+} else {
+ /* istanbul ignore next */
+ debuglog = function () {};
+}
+let DuplicateStrategy = /*#__PURE__*/function (DuplicateStrategy) {
+ DuplicateStrategy["Ignore"] = "ignore";
+ DuplicateStrategy["Replace"] = "replace";
+ return DuplicateStrategy;
+}({});
+exports.DuplicateStrategy = DuplicateStrategy;
+class EventTimelineSet extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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)
+ */
+ constructor(room, opts = {}, client, thread, threadListType = null) {
+ super();
+ this.room = room;
+ this.thread = thread;
+ this.threadListType = threadListType;
+ _defineProperty(this, "relations", void 0);
+ _defineProperty(this, "timelineSupport", void 0);
+ _defineProperty(this, "displayPendingEvents", void 0);
+ _defineProperty(this, "liveTimeline", void 0);
+ _defineProperty(this, "timelines", void 0);
+ _defineProperty(this, "_eventIdToTimeline", new Map());
+ _defineProperty(this, "filter", void 0);
+ this.timelineSupport = Boolean(opts.timelineSupport);
+ this.liveTimeline = new _eventTimeline.EventTimeline(this);
+ this.displayPendingEvents = opts.pendingEvents !== false;
+
+ // just a list - *not* ordered.
+ this.timelines = [this.liveTimeline];
+ this._eventIdToTimeline = new Map();
+ this.filter = opts.filter;
+ this.relations = this.room?.relations ?? new _relationsContainer.RelationsContainer(room?.client ?? client);
+ }
+
+ /**
+ * Get all the timelines in this set
+ * @returns the timelines in this set
+ */
+ getTimelines() {
+ return this.timelines;
+ }
+
+ /**
+ * Get the filter object this timeline set is filtered on, if any
+ * @returns the optional filter for this timelineSet
+ */
+ getFilter() {
+ 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
+ */
+ setFilter(filter) {
+ 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'
+ */
+ getPendingEvents() {
+ if (!this.room || !this.displayPendingEvents) {
+ return [];
+ }
+ return this.room.getPendingEvents();
+ }
+ /**
+ * Get the live timeline for this room.
+ *
+ * @returns live timeline
+ */
+ getLiveTimeline() {
+ return this.liveTimeline;
+ }
+
+ /**
+ * Set the live timeline for this room.
+ *
+ * @returns live timeline
+ */
+ setLiveTimeline(timeline) {
+ this.liveTimeline = timeline;
+ }
+
+ /**
+ * Return the timeline (if any) this event is in.
+ * @param eventId - the eventId being sought
+ * @returns timeline
+ */
+ eventIdToTimeline(eventId) {
+ 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
+ */
+ replaceEventId(oldEventId, newEventId) {
+ 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}
+ */
+ resetLiveTimeline(backPaginationToken, forwardPaginationToken) {
+ // 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.EventTimeline.FORWARDS) : oldTimeline.fork(_eventTimeline.EventTimeline.FORWARDS);
+ if (resetAllTimelines) {
+ this.timelines = [newTimeline];
+ this._eventIdToTimeline = new Map();
+ } 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.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.EventTimeline.BACKWARDS);
+
+ // Now we can swap the live timeline to the new one.
+ this.liveTimeline = newTimeline;
+ this.emit(_room.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
+ */
+ getTimelineForEvent(eventId) {
+ 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
+ */
+ findEventById(eventId) {
+ 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
+ */
+ addTimeline() {
+ 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.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}
+ *
+ */
+ addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken) {
+ 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.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS;
+ const inverseDirection = toStartOfTimeline ? _eventTimeline.EventTimeline.FORWARDS : _eventTimeline.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.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.EventTimeline.BACKWARDS && existingIsLive;
+ const forwardsIsLive = direction === _eventTimeline.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.logger.warn("Refusing to set a preceding existingTimeLine on our " + "timeline as the existingTimeLine is live (" + existingTimeline + ")");
+ }
+ if (forwardsIsLive) {
+ _logger.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.EventTimeline.FORWARDS && timeline === this.liveTimeline) {
+ _logger.logger.warn({
+ lastEventWasNew,
+ didUpdate
+ }); // for debugging
+ _logger.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
+ */
+
+ /**
+ * @deprecated In favor of the overload with `IAddLiveEventOptions`
+ */
+
+ addLiveEvent(event, duplicateStrategyOrOpts, fromCache = false, roomState) {
+ let duplicateStrategy = duplicateStrategyOrOpts || DuplicateStrategy.Ignore;
+ let timelineWasEmpty;
+ 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.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.EventTimeline.FORWARDS);
+ }
+ _eventTimeline.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}
+ */
+
+ /**
+ * @deprecated In favor of the overload with `IAddEventToTimelineOptions`
+ */
+
+ addEventToTimeline(event, timeline, toStartOfTimelineOrOpts, fromCache = false, roomState) {
+ let toStartOfTimeline = !!toStartOfTimelineOrOpts;
+ let timelineWasEmpty;
+ 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.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.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 = {
+ timeline: timeline,
+ liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache
+ };
+ this.emit(_room.RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
+ }
+
+ /**
+ * Insert event to the given timeline, and emit Room.timeline. Assumes
+ * we have already checked we don't know about this event.
+ *
+ * TEMPORARY: until we have recursive relations, we need this function
+ * to exist to allow us to insert events in timeline order, which is our
+ * best guess for Sync Order.
+ * This is a copy of addEventToTimeline above, modified to insert the event
+ * after the event it relates to, and before any event with a later
+ * timestamp. This is our best guess at Sync Order.
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @internal
+ *
+ * @param options - addEventToTimeline options
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ */
+ insertEventIntoTimeline(event, timeline, roomState) {
+ 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.logger.warn(`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` + `in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`);
+ return;
+ }
+
+ // Find the event that this event is related to - the "parent"
+ const parentEventId = event.relationEventId;
+ if (!parentEventId) {
+ // Not related to anything - we just append
+ this.addEventToTimeline(event, timeline, {
+ toStartOfTimeline: false,
+ fromCache: false,
+ timelineWasEmpty: false,
+ roomState
+ });
+ return;
+ }
+ const parentEvent = this.findEventById(parentEventId);
+ const timelineEvents = timeline.getEvents();
+
+ // Start searching from the parent event, or if it's not loaded, start
+ // at the beginning and insert purely using timestamp order.
+ const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
+ let insertIndex = parentIndex;
+ for (; insertIndex < timelineEvents.length; insertIndex++) {
+ const nextEvent = timelineEvents[insertIndex];
+ if (nextEvent.getTs() > event.getTs()) {
+ // We found an event later than ours, so insert before that.
+ break;
+ }
+ }
+ // If we got to the end of the loop, insertIndex points at the end of
+ // the list.
+
+ const eventId = event.getId();
+ timeline.insertEvent(event, insertIndex, roomState);
+ this._eventIdToTimeline.set(eventId, timeline);
+ this.relations.aggregateParentEvent(event);
+ this.relations.aggregateChildEvent(event, this);
+ const data = {
+ timeline: timeline,
+ liveEvent: timeline == this.liveTimeline
+ };
+ this.emit(_room.RoomEvent.Timeline, event, this.room, false, 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}
+ */
+ handleRemoteEcho(localEvent, oldEventId, newEventId) {
+ // 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.
+ */
+ removeEvent(eventId) {
+ 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(_room.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).
+ */
+ compareEventOrdering(eventId1, eventId2) {
+ 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 = undefined;
+ let idx2 = 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 = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline1 is before timeline2
+ return -1;
+ }
+ tl = tl.getNeighbouringTimeline(_eventTimeline.EventTimeline.FORWARDS);
+ }
+
+ // now try backwards from timeline1
+ tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline2 is before timeline1
+ return 1;
+ }
+ tl = tl.getNeighbouringTimeline(_eventTimeline.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.
+ */
+ canContain(event) {
+ 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;
+ }
+}
+exports.EventTimelineSet = EventTimelineSet; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js
new file mode 100644
index 0000000000..d5e9b4ac40
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js
@@ -0,0 +1,469 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.EventTimeline = exports.Direction = void 0;
+var _logger = require("../logger");
+var _roomState = require("./room-state");
+var _event = require("../@types/event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let Direction = /*#__PURE__*/function (Direction) {
+ Direction["Backward"] = "b";
+ Direction["Forward"] = "f";
+ return Direction;
+}({});
+exports.Direction = Direction;
+class EventTimeline {
+ /**
+ * 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
+ */
+ static setEventMetadata(event, stateContext, toStartOfTimeline) {
+ // 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() === _event.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;
+ }
+ }
+ }
+ /**
+ * 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
+ */
+ constructor(eventTimelineSet) {
+ this.eventTimelineSet = eventTimelineSet;
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "name", void 0);
+ _defineProperty(this, "events", []);
+ _defineProperty(this, "baseIndex", 0);
+ _defineProperty(this, "startState", void 0);
+ _defineProperty(this, "endState", void 0);
+ // 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.
+ _defineProperty(this, "startToken", null);
+ _defineProperty(this, "endToken", null);
+ _defineProperty(this, "prevTimeline", null);
+ _defineProperty(this, "nextTimeline", null);
+ _defineProperty(this, "paginationRequests", {
+ [Direction.Backward]: null,
+ [Direction.Forward]: null
+ });
+ this.roomId = eventTimelineSet.room?.roomId ?? null;
+ if (this.roomId) {
+ this.startState = new _roomState.RoomState(this.roomId);
+ this.endState = new _roomState.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.
+ */
+ initialiseState(stateEvents, {
+ timelineWasEmpty
+ } = {}) {
+ 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
+ */
+ forkLive(direction) {
+ 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
+ */
+ fork(direction) {
+ 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
+ */
+ getRoomId() {
+ return this.roomId;
+ }
+
+ /**
+ * Get the filter for this timeline's timelineSet (if any)
+ * @returns filter
+ */
+ getFilter() {
+ return this.eventTimelineSet.getFilter();
+ }
+
+ /**
+ * Get the timelineSet for this timeline
+ * @returns timelineSet
+ */
+ getTimelineSet() {
+ 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).
+ */
+ getBaseIndex() {
+ return this.baseIndex;
+ }
+
+ /**
+ * Get the list of events in this context
+ *
+ * @returns An array of MatrixEvents
+ */
+ getEvents() {
+ 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
+ */
+ getState(direction) {
+ 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
+ */
+ getPaginationToken(direction) {
+ 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.
+ */
+ setPaginationToken(token, direction) {
+ 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.
+ */
+ getNeighbouringTimeline(direction) {
+ 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.
+ */
+ setNeighbouringTimeline(neighbour, direction) {
+ 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
+ */
+
+ /**
+ * @deprecated In favor of the overload with `IAddEventOptions`
+ */
+
+ addEvent(event, toStartOfTimelineOrOpts, roomState) {
+ let toStartOfTimeline = !!toStartOfTimelineOrOpts;
+ let timelineWasEmpty;
+ if (typeof toStartOfTimelineOrOpts === "object") {
+ ({
+ toStartOfTimeline,
+ roomState,
+ timelineWasEmpty
+ } = toStartOfTimelineOrOpts);
+ } else if (toStartOfTimelineOrOpts !== undefined) {
+ // Deprecation warning
+ // FIXME: Remove after 2023-06-01 (technical debt)
+ _logger.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() === _event.EventType.RoomMember && !toStartOfTimeline) {
+ EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
+ }
+ }
+ }
+ let insertIndex;
+ if (toStartOfTimeline) {
+ insertIndex = 0;
+ } else {
+ insertIndex = this.events.length;
+ }
+ this.events.splice(insertIndex, 0, event); // insert element
+ if (toStartOfTimeline) {
+ this.baseIndex++;
+ }
+ }
+
+ /**
+ * Insert a new event into the timeline, and update the state.
+ *
+ * TEMPORARY: until we have recursive relations, we need this function
+ * to exist to allow us to insert events in timeline order, which is our
+ * best guess for Sync Order.
+ * This is a copy of addEvent above, modified to allow inserting an event at
+ * a specific index.
+ *
+ * @internal
+ */
+ insertEvent(event, insertIndex, roomState) {
+ const timelineSet = this.getTimelineSet();
+ if (timelineSet.room) {
+ EventTimeline.setEventMetadata(event, roomState, false);
+
+ // modify state but only on unfiltered timelineSets
+ if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
+ roomState.setStateEvents([event], {});
+ // 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() === _event.EventType.RoomMember) {
+ EventTimeline.setEventMetadata(event, roomState, false);
+ }
+ }
+ }
+ this.events.splice(insertIndex, 0, event); // insert element
+ }
+
+ /**
+ * Remove an event from the timeline
+ *
+ * @param eventId - ID of event to be removed
+ * @returns removed event, or null if not found
+ */
+ removeEvent(eventId) {
+ 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
+ */
+ toString() {
+ return this.name;
+ }
+}
+exports.EventTimeline = EventTimeline;
+/**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the start of the timeline, or backwards in time.
+ */
+_defineProperty(EventTimeline, "BACKWARDS", Direction.Backward);
+/**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the end of the timeline, or forwards in time.
+ */
+_defineProperty(EventTimeline, "FORWARDS", Direction.Forward); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js
new file mode 100644
index 0000000000..eb89ee148d
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js
@@ -0,0 +1,1442 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+Object.defineProperty(exports, "EventStatus", {
+ enumerable: true,
+ get: function () {
+ return _eventStatus.EventStatus;
+ }
+});
+exports.MatrixEventEvent = exports.MatrixEvent = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _logger = require("../logger");
+var _event = require("../@types/event");
+var _utils = require("../utils");
+var _thread = require("./thread");
+var _ReEmitter = require("../ReEmitter");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _algorithms = require("../crypto/algorithms");
+var _OlmDevice = require("../crypto/OlmDevice");
+var _eventStatus = require("./event-status");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/* eslint-disable camelcase */
+
+/**
+ * When an event is a visibility change event, as per MSC3531,
+ * the visibility change implied by the event.
+ */
+
+/* eslint-enable camelcase */
+
+/**
+ * Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531.
+ */
+
+/**
+ * Variant of `MessageVisibility` for the case in which the message should be displayed.
+ */
+
+/**
+ * Variant of `MessageVisibility` for the case in which the message should be hidden.
+ */
+
+// A singleton implementing `IMessageVisibilityVisible`.
+const MESSAGE_VISIBLE = Object.freeze({
+ visible: true
+});
+let MatrixEventEvent = /*#__PURE__*/function (MatrixEventEvent) {
+ MatrixEventEvent["Decrypted"] = "Event.decrypted";
+ MatrixEventEvent["BeforeRedaction"] = "Event.beforeRedaction";
+ MatrixEventEvent["VisibilityChange"] = "Event.visibilityChange";
+ MatrixEventEvent["LocalEventIdReplaced"] = "Event.localEventIdReplaced";
+ MatrixEventEvent["Status"] = "Event.status";
+ MatrixEventEvent["Replaced"] = "Event.replaced";
+ MatrixEventEvent["RelationsCreated"] = "Event.relationsCreated";
+ return MatrixEventEvent;
+}({});
+exports.MatrixEventEvent = MatrixEventEvent;
+class MatrixEvent extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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.
+ */
+ constructor(event = {}) {
+ 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.
+ this.event = event;
+ // applied push rule and action for this event
+ _defineProperty(this, "pushDetails", {});
+ _defineProperty(this, "_replacingEvent", null);
+ _defineProperty(this, "_localRedactionEvent", null);
+ _defineProperty(this, "_isCancelled", false);
+ _defineProperty(this, "clearEvent", void 0);
+ /* 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.
+ */
+ _defineProperty(this, "visibility", 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.
+ _defineProperty(this, "_hasCachedExtEv", false);
+ _defineProperty(this, "_cachedExtEv", undefined);
+ /* curve25519 key which we believe belongs to the sender of the event. See
+ * getSenderKey()
+ */
+ _defineProperty(this, "senderCurve25519Key", 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()
+ */
+ _defineProperty(this, "claimedEd25519Key", null);
+ /* curve25519 keys of devices involved in telling us about the
+ * senderCurve25519Key and claimedEd25519Key.
+ * See getForwardingCurve25519KeyChain().
+ */
+ _defineProperty(this, "forwardingCurve25519KeyChain", []);
+ /* where the decryption key is untrusted
+ */
+ _defineProperty(this, "untrusted", null);
+ /* if we have a process decrypting this event, a Promise which resolves
+ * when it is finished. Normally null.
+ */
+ _defineProperty(this, "decryptionPromise", 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)
+ */
+ _defineProperty(this, "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.
+ */
+ _defineProperty(this, "txnId", void 0);
+ /**
+ * A reference to the thread this event belongs to
+ */
+ _defineProperty(this, "thread", void 0);
+ _defineProperty(this, "threadId", void 0);
+ /*
+ * 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.
+ */
+ _defineProperty(this, "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.
+ */
+ _defineProperty(this, "localTimestamp", void 0);
+ /**
+ * 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
+ */
+ _defineProperty(this, "sender", 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
+ */
+ _defineProperty(this, "target", null);
+ /**
+ * The sending status of the event.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "status", null);
+ /**
+ * most recent error associated with sending the event, if any
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "error", 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
+ */
+ _defineProperty(this, "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.
+ */
+ _defineProperty(this, "verificationRequest", void 0);
+ _defineProperty(this, "reEmitter", void 0);
+ ["state_key", "type", "sender", "room_id", "membership"].forEach(prop => {
+ if (typeof event[prop] !== "string") return;
+ event[prop] = (0, _utils.internaliseString)(event[prop]);
+ });
+ ["membership", "avatar_url", "displayname"].forEach(prop => {
+ if (typeof event.content?.[prop] !== "string") return;
+ event.content[prop] = (0, _utils.internaliseString)(event.content[prop]);
+ });
+ ["rel_type"].forEach(prop => {
+ if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return;
+ event.content["m.relates_to"][prop] = (0, _utils.internaliseString)(event.content["m.relates_to"][prop]);
+ });
+ this.txnId = event.txn_id;
+ this.localTimestamp = Date.now() - (this.getAge() ?? 0);
+ this.reEmitter = new _ReEmitter.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.
+ */
+ get unstableExtensibleEvent() {
+ if (!this._hasCachedExtEv) {
+ this._cachedExtEv = _matrixEventsSdk.ExtensibleEvents.parse(this.getEffectiveEvent());
+ }
+ return this._cachedExtEv;
+ }
+ invalidateExtensibleEvent() {
+ // 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.
+ */
+ getEffectiveEvent() {
+ const content = Object.assign({}, this.getContent()); // clone for mutation
+
+ if (this.getWireType() === _event.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
+ });
+ }
+
+ /**
+ * Get the event_id for this event.
+ * @returns The event ID, e.g. <code>$143350589368169JsLZx:localhost
+ * </code>
+ */
+ getId() {
+ return this.event.event_id;
+ }
+
+ /**
+ * Get the user_id for this event.
+ * @returns The user ID, e.g. `@alice:matrix.org`
+ */
+ getSender() {
+ 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`
+ */
+ getType() {
+ 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.
+ */
+ getWireType() {
+ 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>
+ */
+ getRoomId() {
+ return this.event.room_id;
+ }
+
+ /**
+ * Get the timestamp of this event.
+ * @returns The event timestamp, e.g. `1433502692297`
+ */
+ getTs() {
+ 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)`
+ */
+ getDate() {
+ 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
+ * ```
+ */
+ getDetails() {
+ 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.
+ */
+ getOriginalContent() {
+ if (this._localRedactionEvent) {
+ return {};
+ }
+ if (this.clearEvent) {
+ return this.clearEvent.content || {};
+ }
+ return this.event.content || {};
+ }
+
+ /**
+ * 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.
+ */
+ getContent() {
+ if (this._localRedactionEvent) {
+ return {};
+ } 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.
+ */
+ getWireContent() {
+ return this.event.content || {};
+ }
+
+ /**
+ * Get the event ID of the thread head
+ */
+ get threadRootId() {
+ const relatesTo = this.getWireContent()?.["m.relates_to"];
+ if (relatesTo?.rel_type === _thread.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
+ */
+ get isThreadRoot() {
+ const threadDetails = this.getServerAggregatedRelation(_thread.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();
+ }
+ get replyEventId() {
+ return this.getWireContent()["m.relates_to"]?.["m.in_reply_to"]?.event_id;
+ }
+ get relationEventId() {
+ 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.
+ */
+ getPrevContent() {
+ // 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.
+ */
+ getDirectionalContent() {
+ 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.
+ */
+ getAge() {
+ 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.
+ */
+ getLocalAge() {
+ 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`.
+ */
+ getStateKey() {
+ return this.event.state_key;
+ }
+
+ /**
+ * Check if this event is a state event.
+ * @returns True if this is a state event.
+ */
+ isState() {
+ 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}
+ */
+ makeEncrypted(cryptoType, cryptoContent, senderCurve25519Key, claimedEd25519Key) {
+ // 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.
+ */
+ isBeingDecrypted() {
+ return this.decryptionPromise != null;
+ }
+ getDecryptionPromise() {
+ 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.
+ */
+ isDecryptionFailure() {
+ 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.
+ */
+ get isEncryptedDisabledForUnverifiedDevices() {
+ return this.isDecryptionFailure() && this.encryptedDisabledForUnverifiedDevices;
+ }
+ shouldAttemptDecryption() {
+ 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.
+ */
+ async attemptDecryption(crypto, options = {}) {
+ // 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.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
+ */
+ cancelAndResendKeyRequest(crypto, userId) {
+ 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
+ */
+ getKeyRequestRecipients(userId) {
+ // send the request to all of our own devices
+ const recipients = [{
+ userId,
+ deviceId: "*"
+ }];
+ return recipients;
+ }
+ async decryptionLoop(crypto, options = {}) {
+ // 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;
+ let err = undefined;
+ try {
+ if (!crypto) {
+ res = this.badEncryptedMessage("Encryption not enabled");
+ } else {
+ res = await crypto.decryptEvent(this);
+ if (options.isRetry === true) {
+ _logger.logger.info(`Decrypted event on retry (${this.getDetails()})`);
+ }
+ }
+ } catch (e) {
+ const detailedError = e instanceof _algorithms.DecryptionError ? e.detailedString : String(e);
+ err = e;
+
+ // 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.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.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.setPushDetails();
+ if (options.emit !== false) {
+ this.emit(MatrixEventEvent.Decrypted, this, err);
+ }
+ return;
+ }
+ }
+ badEncryptedMessage(reason) {
+ return {
+ clearEvent: {
+ type: _event.EventType.RoomMessage,
+ content: {
+ msgtype: "m.bad.encrypted",
+ body: "** Unable to decrypt: " + reason + " **"
+ }
+ },
+ encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${_OlmDevice.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}
+ */
+ setClearData(decryptionResult) {
+ 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
+ */
+ getClearContent() {
+ return this.clearEvent ? this.clearEvent.content : null;
+ }
+
+ /**
+ * Check if the event is encrypted.
+ * @returns True if this event is encrypted.
+ */
+ isEncrypted() {
+ return !this.isState() && this.event.type === _event.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
+ */
+ getSenderKey() {
+ return this.senderCurve25519Key;
+ }
+
+ /**
+ * The additional keys the sender of this encrypted event claims to possess.
+ *
+ * Just a wrapper for #getClaimedEd25519Key (q.v.)
+ */
+ getKeysClaimed() {
+ 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.
+ */
+ getClaimedEd25519Key() {
+ 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.
+ */
+ getForwardingCurve25519KeyChain() {
+ return this.forwardingCurve25519KeyChain;
+ }
+
+ /**
+ * Whether the decryption key was obtained from an untrusted source. If so,
+ * we cannot verify the authenticity of the message.
+ */
+ isKeySourceUntrusted() {
+ return !!this.untrusted;
+ }
+ getUnsigned() {
+ return this.event.unsigned || {};
+ }
+ setUnsigned(unsigned) {
+ this.event.unsigned = unsigned;
+ }
+ unmarkLocallyRedacted() {
+ const value = this._localRedactionEvent;
+ this._localRedactionEvent = null;
+ if (this.event.unsigned) {
+ this.event.unsigned.redacted_because = undefined;
+ }
+ return !!value;
+ }
+ markLocallyRedacted(redactionEvent) {
+ 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;
+ }
+
+ /**
+ * 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).
+ */
+ applyVisibilityEvent(visibilityChange) {
+ 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.
+ */
+ 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
+ */
+ makeRedacted(redactionEvent) {
+ // 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;
+ for (const key in this.event) {
+ if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) {
+ delete this.event[key];
+ }
+ }
+
+ // 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()] : {};
+ 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
+ */
+ isRedacted() {
+ return Boolean(this.getUnsigned().redacted_because);
+ }
+
+ /**
+ * Check if this event is a redaction of another event
+ *
+ * @returns True if this event is a redaction
+ */
+ isRedaction() {
+ return this.getType() === _event.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`.
+ */
+ asVisibilityChange() {
+ if (!_event.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.
+ */
+ isVisibilityEvent() {
+ return _event.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
+ */
+ getRedactionEvent() {
+ 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
+ */
+ getPushActions() {
+ return this.pushDetails.actions || null;
+ }
+
+ /**
+ * Get the push details, if known, for this event
+ *
+ * @returns push actions
+ */
+ getPushDetails() {
+ return this.pushDetails;
+ }
+
+ /**
+ * Set the push actions for this event.
+ * Clears rule from push details if present
+ * @deprecated use `setPushDetails`
+ *
+ * @param pushActions - push actions
+ */
+ setPushActions(pushActions) {
+ this.pushDetails = {
+ actions: pushActions || undefined
+ };
+ }
+
+ /**
+ * Set the push details for this event.
+ *
+ * @param pushActions - push actions
+ * @param rule - the executed push rule
+ */
+ setPushDetails(pushActions, rule) {
+ this.pushDetails = {
+ actions: pushActions,
+ rule
+ };
+ }
+
+ /**
+ * Replace the `event` property and recalculate any properties based on it.
+ * @param event - the object to assign to the `event` property
+ */
+ handleRemoteEcho(event) {
+ 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.
+ */
+ isSending() {
+ return !!this.status;
+ }
+
+ /**
+ * Update the event's sending status and emit an event as well.
+ *
+ * @param status - The new status
+ */
+ setStatus(status) {
+ this.status = status;
+ this.emit(MatrixEventEvent.Status, this, status);
+ }
+ replaceLocalEventId(eventId) {
+ 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
+ */
+ isRelation(relType) {
+ // 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 === _event.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.
+ */
+ getRelation() {
+ 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}
+ */
+ makeReplaced(newEvent) {
+ // 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.
+ */
+ getAssociatedStatus() {
+ if (this._replacingEvent) {
+ return this._replacingEvent.status;
+ } else if (this._localRedactionEvent) {
+ return this._localRedactionEvent.status;
+ }
+ return this.status;
+ }
+ getServerAggregatedRelation(relType) {
+ return this.getUnsigned()["m.relations"]?.[relType];
+ }
+
+ /**
+ * Returns the event ID of the event replacing the content of this event, if any.
+ */
+ replacingEventId() {
+ const replaceRelation = this.getServerAggregatedRelation(_event.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.
+ */
+ replacingEvent() {
+ return this._replacingEvent;
+ }
+
+ /**
+ * Returns the origin_server_ts of the event replacing the content of this event, if any.
+ */
+ replacingEventDate() {
+ const replaceRelation = this.getServerAggregatedRelation(_event.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
+ */
+ localRedactionEvent() {
+ return this._localRedactionEvent;
+ }
+
+ /**
+ * For relations and redactions, returns the event_id this event is referring to.
+ */
+ getAssociatedId() {
+ 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.
+ */
+ hasAssocation() {
+ return !!this.getAssociatedId();
+ }
+
+ /**
+ * Checks if this event is associated with another event. See `getAssociatedId`.
+ */
+ hasAssociation() {
+ 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
+ */
+ updateAssociatedId(eventId) {
+ 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.
+ */
+ flagCancelled(cancelled = true) {
+ 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.
+ */
+ isCancelled() {
+ 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.
+ */
+ toSnapshot() {
+ 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] = 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.
+ */
+ isEquivalentTo(otherEvent) {
+ if (!otherEvent) return false;
+ if (otherEvent === this) return true;
+ const myProps = (0, _utils.deepSortedObjectEntries)(this.event);
+ const theirProps = (0, _utils.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.
+ */
+ toJSON() {
+ const event = this.getEffectiveEvent();
+ if (!this.isEncrypted()) {
+ return event;
+ }
+ return {
+ decrypted: event,
+ encrypted: this.event
+ };
+ }
+ setVerificationRequest(request) {
+ this.verificationRequest = request;
+ }
+ setTxnId(txnId) {
+ this.txnId = txnId;
+ }
+ getTxnId() {
+ return this.txnId;
+ }
+
+ /**
+ * Set the instance of a thread associated with the current event
+ * @param thread - the thread
+ */
+ setThread(thread) {
+ if (this.thread) {
+ this.reEmitter.stopReEmitting(this.thread, [_thread.ThreadEvent.Update]);
+ }
+ this.thread = thread;
+ this.setThreadId(thread?.id);
+ if (thread) {
+ this.reEmitter.reEmit(thread, [_thread.ThreadEvent.Update]);
+ }
+ }
+
+ /**
+ * Get the instance of the thread associated with the current event
+ */
+ getThread() {
+ return this.thread;
+ }
+ setThreadId(threadId) {
+ 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
+ */
+exports.MatrixEvent = MatrixEvent;
+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 = {
+ [_event.EventType.RoomMember]: {
+ membership: 1
+ },
+ [_event.EventType.RoomCreate]: {
+ creator: 1
+ },
+ [_event.EventType.RoomJoinRules]: {
+ join_rule: 1
+ },
+ [_event.EventType.RoomPowerLevels]: {
+ ban: 1,
+ events: 1,
+ events_default: 1,
+ kick: 1,
+ redact: 1,
+ state_default: 1,
+ users: 1,
+ users_default: 1
+ }
+}; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js
new file mode 100644
index 0000000000..b49d37c372
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js
@@ -0,0 +1,358 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PolicyScope = exports.POLICIES_ACCOUNT_EVENT_TYPE = exports.IgnoredInvites = exports.IGNORE_INVITES_ACCOUNT_EVENT_KEY = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _eventTimeline = require("./event-timeline");
+var _partials = require("../@types/partials");
+var _utils = require("../utils");
+/*
+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 event type storing the user's individual policies.
+///
+/// Exported for testing purposes.
+const POLICIES_ACCOUNT_EVENT_TYPE = new _matrixEventsSdk.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.
+exports.POLICIES_ACCOUNT_EVENT_TYPE = POLICIES_ACCOUNT_EVENT_TYPE;
+const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new _matrixEventsSdk.UnstableValue("m.ignore.invites", "org.matrix.msc3847.ignore.invites");
+
+/// The types of recommendations understood.
+exports.IGNORE_INVITES_ACCOUNT_EVENT_KEY = IGNORE_INVITES_ACCOUNT_EVENT_KEY;
+var PolicyRecommendation = /*#__PURE__*/function (PolicyRecommendation) {
+ PolicyRecommendation["Ban"] = "m.ban";
+ return PolicyRecommendation;
+}(PolicyRecommendation || {});
+/**
+ * The various scopes for policies.
+ */
+let PolicyScope = /*#__PURE__*/function (PolicyScope) {
+ PolicyScope["User"] = "m.policy.user";
+ PolicyScope["Room"] = "m.policy.room";
+ PolicyScope["Server"] = "m.policy.server";
+ return PolicyScope;
+}({});
+/**
+ * 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.
+ */
+exports.PolicyScope = PolicyScope;
+class IgnoredInvites {
+ constructor(client) {
+ this.client = client;
+ }
+
+ /**
+ * 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.
+ */
+ async addRule(scope, entity, reason) {
+ 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.
+ */
+ async removeRule(event) {
+ 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.
+ */
+ async addSource(roomId) {
+ // 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.
+ */
+ async getRuleForInvite({
+ sender,
+ roomId
+ }) {
+ // 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.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;
+ try {
+ regexp = new RegExp((0, _utils.globToRegexp)(glob));
+ } 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.
+ */
+ async getOrCreateTargetRoom() {
+ 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: _partials.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.
+ */
+ async getOrCreateSourceRooms() {
+ const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
+ let sources = 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);
+ 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.
+ */
+ getIgnoreInvitesPolicies() {
+ return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
+ }
+
+ /**
+ * Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
+ */
+ async withIgnoreInvitesPolicies(cb) {
+ 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.
+ */
+ getPoliciesAndIgnoreInvitesPolicies() {
+ let policies = {};
+ 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
+ };
+ }
+}
+exports.IgnoredInvites = IgnoredInvites; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js
new file mode 100644
index 0000000000..25818398f5
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js
@@ -0,0 +1,237 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isPollEvent = exports.PollEvent = exports.Poll = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _polls = require("../@types/polls");
+var _relations = require("./relations");
+var _typedEventEmitter = require("./typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let PollEvent = /*#__PURE__*/function (PollEvent) {
+ PollEvent["New"] = "Poll.new";
+ PollEvent["End"] = "Poll.end";
+ PollEvent["Update"] = "Poll.update";
+ PollEvent["Responses"] = "Poll.Responses";
+ PollEvent["Destroy"] = "Poll.Destroy";
+ PollEvent["UndecryptableRelations"] = "Poll.UndecryptableRelations";
+ return PollEvent;
+}({});
+exports.PollEvent = PollEvent;
+const filterResponseRelations = (relationEvents, pollEndTimestamp) => {
+ const responseEvents = relationEvents.filter(event => {
+ if (event.isDecryptionFailure()) {
+ return;
+ }
+ return _polls.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
+ };
+};
+class Poll extends _typedEventEmitter.TypedEventEmitter {
+ constructor(rootEvent, matrixClient, room) {
+ super();
+ this.rootEvent = rootEvent;
+ this.matrixClient = matrixClient;
+ this.room = room;
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "pollEvent", void 0);
+ _defineProperty(this, "_isFetchingResponses", false);
+ _defineProperty(this, "relationsNextBatch", void 0);
+ _defineProperty(this, "responses", null);
+ _defineProperty(this, "endEvent", void 0);
+ /**
+ * Keep track of undecryptable relations
+ * As incomplete result sets affect poll results
+ */
+ _defineProperty(this, "undecryptableRelationEventIds", new Set());
+ _defineProperty(this, "countUndecryptableEvents", events => {
+ 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);
+ }
+ });
+ if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
+ throw new Error("Invalid poll start event.");
+ }
+ this.roomId = this.rootEvent.getRoomId();
+ this.pollEvent = this.rootEvent.unstableExtensibleEvent;
+ }
+ get pollId() {
+ return this.rootEvent.getId();
+ }
+ get endEventId() {
+ return this.endEvent?.getId();
+ }
+ get isEnded() {
+ return !!this.endEvent;
+ }
+ get isFetchingResponses() {
+ return this._isFetchingResponses;
+ }
+ get undecryptableRelationsCount() {
+ return this.undecryptableRelationEventIds.size;
+ }
+ async getResponses() {
+ // 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
+ */
+ onNewRelation(event) {
+ if (_polls.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);
+ }
+ }
+ async fetchResponses() {
+ 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.Relations("m.reference", _polls.M_POLL_RESPONSE.name, this.matrixClient, [_polls.M_POLL_RESPONSE.altName]);
+ const pollEndEvent = allRelations.events.find(event => _polls.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
+ */
+ refilterResponsesOnEnd() {
+ 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);
+ }
+ validateEndEvent(endEvent) {
+ 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));
+ }
+}
+
+/**
+ * Tests whether the event is a start, response or end poll event.
+ *
+ * @param event - Event to test
+ * @returns true if the event is a poll event, else false
+ */
+exports.Poll = Poll;
+const isPollEvent = event => {
+ const eventType = event.getType();
+ return _matrixEventsSdk.M_POLL_START.matches(eventType) || _polls.M_POLL_RESPONSE.matches(eventType) || _polls.M_POLL_END.matches(eventType);
+};
+exports.isPollEvent = isPollEvent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js
new file mode 100644
index 0000000000..8bef646ab4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js
@@ -0,0 +1,260 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ReadReceipt = void 0;
+exports.synthesizeReceipt = synthesizeReceipt;
+var _read_receipts = require("../@types/read_receipts");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _utils = require("../utils");
+var _event = require("./event");
+var _event2 = require("../@types/event");
+var _room = require("./room");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+function synthesizeReceipt(userId, event, receiptType) {
+ return new _event.MatrixEvent({
+ content: {
+ [event.getId()]: {
+ [receiptType]: {
+ [userId]: {
+ ts: event.getTs(),
+ thread_id: event.threadRootId ?? _read_receipts.MAIN_ROOM_TIMELINE
+ }
+ }
+ }
+ },
+ type: _event2.EventType.Receipt,
+ room_id: event.getRoomId()
+ });
+}
+const ReceiptPairRealIndex = 0;
+const ReceiptPairSyntheticIndex = 1;
+class ReadReceipt extends _typedEventEmitter.TypedEventEmitter {
+ constructor(...args) {
+ super(...args);
+ // 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
+ _defineProperty(this, "receipts", new _utils.MapWithDefault(() => new Map()));
+ _defineProperty(this, "receiptCacheByEventId", new Map());
+ _defineProperty(this, "timeline", void 0);
+ }
+ /**
+ * 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
+ */
+ getReadReceiptForUserId(userId, ignoreSynthesized = false, receiptType = _read_receipts.ReceiptType.Read) {
+ 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.
+ */
+ getEventReadUpTo(userId, ignoreSynthesized = false) {
+ // 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, _read_receipts.ReceiptType.Read);
+ const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, _read_receipts.ReceiptType.ReadPrivate);
+
+ // If we have both, compare them
+ let comparison;
+ 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;
+ }
+ addReceiptToStructure(eventId, receiptType, userId, receipt, synthetic) {
+ 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 = {
+ eventId,
+ data: receipt
+ };
+ const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
+ const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
+ let ordering = 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,
+ 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.
+ */
+ getReceiptsForEvent(event) {
+ return this.receiptCacheByEventId.get(event.getId()) || [];
+ }
+ /**
+ * 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
+ */
+ fixupNotifications(userId) {
+ 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(_room.NotificationCountType.Total, 0);
+ this.setUnread(_room.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
+ */
+ addLocalEchoReceipt(userId, e, receiptType) {
+ 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.
+ */
+ getUsersReadUpTo(event) {
+ return this.getReceiptsForEvent(event).filter(function (receipt) {
+ return (0, _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.
+ */
+ hasUserReadEvent(userId, eventId) {
+ 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;
+ }
+}
+exports.ReadReceipt = ReadReceipt; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js
new file mode 100644
index 0000000000..9db67bf544
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js
@@ -0,0 +1,41 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RelatedRelations = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+class RelatedRelations {
+ constructor(relations) {
+ _defineProperty(this, "relations", void 0);
+ this.relations = relations.filter(r => !!r);
+ }
+ getRelations() {
+ return this.relations.reduce((c, p) => [...c, ...p.getRelations()], []);
+ }
+ on(ev, fn) {
+ this.relations.forEach(r => r.on(ev, fn));
+ }
+ off(ev, fn) {
+ this.relations.forEach(r => r.off(ev, fn));
+ }
+}
+exports.RelatedRelations = RelatedRelations; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js
new file mode 100644
index 0000000000..489ab267eb
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js
@@ -0,0 +1,135 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RelationsContainer = void 0;
+var _relations = require("./relations");
+var _event = require("./event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class RelationsContainer {
+ constructor(client, room) {
+ this.client = client;
+ this.room = room;
+ // A tree of objects to access a set of related children for an event, as in:
+ // this.relations.get(parentEventId).get(relationType).get(relationEventType)
+ _defineProperty(this, "relations", new Map());
+ }
+
+ /**
+ * 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.
+ */
+ getChildEventsForEvent(eventId, relationType, eventType) {
+ return this.relations.get(eventId)?.get(relationType)?.get(eventType);
+ }
+ getAllChildEventsForEvent(parentEventId) {
+ const relationsForEvent = this.relations.get(parentEventId) ?? new Map();
+ const events = [];
+ 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.
+ */
+ aggregateParentEvent(event) {
+ 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.
+ */
+ aggregateChildEvent(event, timelineSet) {
+ if (event.isRedacted() || event.status === _event.EventStatus.CANCELLED) {
+ return;
+ }
+ const relation = event.getRelation();
+ if (!relation) return;
+ const onEventDecrypted = () => {
+ 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(_event.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(_event.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();
+ this.relations.set(relatesToEventId, relationsForEvent);
+ }
+ let relationsWithRelType = relationsForEvent.get(relationType);
+ if (!relationsWithRelType) {
+ relationsWithRelType = new Map();
+ relationsForEvent.set(relationType, relationsWithRelType);
+ }
+ let relationsWithEventType = relationsWithRelType.get(eventType);
+ if (!relationsWithEventType) {
+ relationsWithEventType = new _relations.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);
+ }
+}
+exports.RelationsContainer = RelationsContainer; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js
new file mode 100644
index 0000000000..1b83615e8b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js
@@ -0,0 +1,336 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RelationsEvent = exports.Relations = void 0;
+var _event = require("./event");
+var _logger = require("../logger");
+var _event2 = require("../@types/event");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _room = require("./room");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let RelationsEvent = /*#__PURE__*/function (RelationsEvent) {
+ RelationsEvent["Add"] = "Relations.add";
+ RelationsEvent["Remove"] = "Relations.remove";
+ RelationsEvent["Redaction"] = "Relations.redaction";
+ return RelationsEvent;
+}({});
+exports.RelationsEvent = RelationsEvent;
+const matchesEventType = (eventType, targetEventType, altTargetEventTypes = []) => [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.
+ */
+class Relations extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * @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
+ */
+ constructor(relationType, eventType, client, altEventTypes) {
+ super();
+ this.relationType = relationType;
+ this.eventType = eventType;
+ this.altEventTypes = altEventTypes;
+ _defineProperty(this, "relationEventIds", new Set());
+ _defineProperty(this, "relations", new Set());
+ _defineProperty(this, "annotationsByKey", {});
+ _defineProperty(this, "annotationsBySender", {});
+ _defineProperty(this, "sortedAnnotationsByKey", []);
+ _defineProperty(this, "targetEvent", null);
+ _defineProperty(this, "creationEmitted", false);
+ _defineProperty(this, "client", void 0);
+ /**
+ * Listens for event status changes to remove cancelled events.
+ *
+ * @param event - The event whose status has changed
+ * @param status - The new status
+ */
+ _defineProperty(this, "onEventStatus", (event, status) => {
+ if (!event.isSending()) {
+ // Sending is done, so we don't need to listen anymore
+ event.removeListener(_event.MatrixEventEvent.Status, this.onEventStatus);
+ return;
+ }
+ if (status !== _event.EventStatus.CANCELLED) {
+ return;
+ }
+ // Event was cancelled, remove from the collection
+ event.removeListener(_event.MatrixEventEvent.Status, this.onEventStatus);
+ this.removeEvent(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.
+ */
+ _defineProperty(this, "onBeforeRedaction", async redactedEvent => {
+ if (!this.relations.has(redactedEvent)) {
+ return;
+ }
+ this.relations.delete(redactedEvent);
+ if (this.relationType === _event2.RelationType.Annotation) {
+ // Remove the redacted annotation from aggregation by key
+ this.removeAnnotationFromAggregation(redactedEvent);
+ } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement);
+ }
+ redactedEvent.removeListener(_event.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+ this.emit(RelationsEvent.Redaction, redactedEvent);
+ });
+ this.client = client instanceof _room.Room ? client.client : client;
+ }
+
+ /**
+ * Add relation events to this collection.
+ *
+ * @param event - The new relation event to be added.
+ */
+ async addEvent(event) {
+ if (this.relationEventIds.has(event.getId())) {
+ return;
+ }
+ const relation = event.getRelation();
+ if (!relation) {
+ _logger.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.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(_event.MatrixEventEvent.Status, this.onEventStatus);
+ }
+ this.relations.add(event);
+ this.relationEventIds.add(event.getId());
+ if (this.relationType === _event2.RelationType.Annotation) {
+ this.addAnnotationToAggregation(event);
+ } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement);
+ }
+ event.on(_event.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+ this.emit(RelationsEvent.Add, event);
+ this.maybeEmitCreated();
+ }
+
+ /**
+ * Remove relation event from this collection.
+ *
+ * @param event - The relation event to remove.
+ */
+ async removeEvent(event) {
+ if (!this.relations.has(event)) {
+ return;
+ }
+ this.relations.delete(event);
+ if (this.relationType === _event2.RelationType.Annotation) {
+ this.removeAnnotationFromAggregation(event);
+ } else if (this.relationType === _event2.RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement);
+ }
+ this.emit(RelationsEvent.Remove, 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.
+ */
+ getRelations() {
+ return [...this.relations];
+ }
+ addAnnotationToAggregation(event) {
+ 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);
+ }
+ removeAnnotationFromAggregation(event) {
+ 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);
+ }
+ }
+ /**
+ * 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).
+ */
+ getSortedAnnotationsByKey() {
+ if (this.relationType !== _event2.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.
+ */
+ getAnnotationsBySender() {
+ if (this.relationType !== _event2.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`.
+ */
+ async getLastReplacement() {
+ if (this.relationType !== _event2.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(_event2.RelationType.Replace);
+ const minTs = replaceRelation?.origin_server_ts;
+ const lastReplacement = this.getRelations().reduce((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.
+ */
+ async setTargetEvent(event) {
+ if (this.targetEvent) {
+ return;
+ }
+ this.targetEvent = event;
+ if (this.relationType === _event2.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();
+ }
+ maybeEmitCreated() {
+ 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(_event.MatrixEventEvent.RelationsCreated, this.relationType, this.eventType);
+ }
+}
+exports.Relations = Relations; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js
new file mode 100644
index 0000000000..53c01065de
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js
@@ -0,0 +1,363 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomMemberEvent = exports.RoomMember = void 0;
+var _contentRepo = require("../content-repo");
+var _utils = require("../utils");
+var _logger = require("../logger");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _event = require("../@types/event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let RoomMemberEvent = /*#__PURE__*/function (RoomMemberEvent) {
+ RoomMemberEvent["Membership"] = "RoomMember.membership";
+ RoomMemberEvent["Name"] = "RoomMember.name";
+ RoomMemberEvent["PowerLevel"] = "RoomMember.powerLevel";
+ RoomMemberEvent["Typing"] = "RoomMember.typing";
+ return RoomMemberEvent;
+}({});
+exports.RoomMemberEvent = RoomMemberEvent;
+class RoomMember extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * Construct a new room member.
+ *
+ * @param roomId - The room ID of the member.
+ * @param userId - The user ID of the member.
+ */
+ constructor(roomId, userId) {
+ super();
+ this.roomId = roomId;
+ this.userId = userId;
+ _defineProperty(this, "_isOutOfBand", false);
+ _defineProperty(this, "modified", -1);
+ _defineProperty(this, "requestedProfileInfo", false);
+ // used by sync.ts
+ // XXX these should be read-only
+ /**
+ * True if the room member is currently typing.
+ */
+ _defineProperty(this, "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.
+ */
+ _defineProperty(this, "name", void 0);
+ /**
+ * The ambiguous displayname of this room member.
+ */
+ _defineProperty(this, "rawDisplayName", void 0);
+ /**
+ * The power level for this room member.
+ */
+ _defineProperty(this, "powerLevel", 0);
+ /**
+ * The normalised power level (0-100) for this room member.
+ */
+ _defineProperty(this, "powerLevelNorm", 0);
+ /**
+ * The User object for this room member, if one exists.
+ */
+ _defineProperty(this, "user", void 0);
+ /**
+ * The membership state for this room member e.g. 'join'.
+ */
+ _defineProperty(this, "membership", void 0);
+ /**
+ * True if the member's name is disambiguated.
+ */
+ _defineProperty(this, "disambiguate", false);
+ /**
+ * The events describing this RoomMember.
+ */
+ _defineProperty(this, "events", {});
+ this.name = userId;
+ this.rawDisplayName = userId;
+ this.updateModifiedTime();
+ }
+
+ /**
+ * Mark the member as coming from a channel that is not sync
+ */
+ markOutOfBand() {
+ 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.
+ */
+ isOutOfBand() {
+ 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}
+ */
+ setMembershipEvent(event, roomState) {
+ const displayName = event.getDirectionalContent().displayname ?? "";
+ if (event.getType() !== _event.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.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 = (0, _utils.removeDirectionOverrideChars)(event.getDirectionalContent().displayname ?? "");
+ if (!this.rawDisplayName || !(0, _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}
+ */
+ setPowerLevelEvent(powerLevelEvent) {
+ if (powerLevelEvent.getType() !== _event.EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") {
+ return;
+ }
+ const evContent = powerLevelEvent.getDirectionalContent();
+ let maxLevel = evContent.users_default || 0;
+ const users = evContent.users || {};
+ Object.values(users).forEach(lvl => {
+ 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}
+ */
+ setTypingEvent(event) {
+ 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.
+ */
+ updateModifiedTime() {
+ 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
+ */
+ getLastModifiedTime() {
+ return this.modified;
+ }
+ isKicked() {
+ 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
+ */
+ getDMInviter() {
+ // 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 = 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.
+ */
+ getAvatarUrl(baseUrl, width, height, resizeMethod, allowDefault = true, allowDirectLinks) {
+ const rawUrl = this.getMxcAvatarUrl();
+ if (!rawUrl && !allowDefault) {
+ return null;
+ }
+ const httpUrl = (0, _contentRepo.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
+ */
+ getMxcAvatarUrl() {
+ if (this.events.member) {
+ return this.events.member.getDirectionalContent().avatar_url;
+ } else if (this.user) {
+ return this.user.avatarUrl;
+ }
+ }
+}
+exports.RoomMember = RoomMember;
+const MXID_PATTERN = /@.+:.+/;
+const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
+function shouldDisambiguate(selfUserId, displayName, roomState) {
+ 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 (!(0, _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, displayName, disambiguate) {
+ if (!displayName || displayName === selfUserId) return selfUserId;
+ if (disambiguate) return (0, _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 (!(0, _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 (0, _utils.removeDirectionOverrideChars)(displayName);
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js
new file mode 100644
index 0000000000..69d37911cf
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js
@@ -0,0 +1,931 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomStateEvent = exports.RoomState = void 0;
+var _roomMember = require("./room-member");
+var _logger = require("../logger");
+var _utils = require("../utils");
+var _event = require("../@types/event");
+var _event2 = require("./event");
+var _partials = require("../@types/partials");
+var _typedEventEmitter = require("./typed-event-emitter");
+var _beacon = require("./beacon");
+var _ReEmitter = require("../ReEmitter");
+var _beacon2 = require("../@types/beacon");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// possible statuses for out-of-band member loading
+var OobStatus = /*#__PURE__*/function (OobStatus) {
+ OobStatus[OobStatus["NotStarted"] = 0] = "NotStarted";
+ OobStatus[OobStatus["InProgress"] = 1] = "InProgress";
+ OobStatus[OobStatus["Finished"] = 2] = "Finished";
+ return OobStatus;
+}(OobStatus || {});
+let RoomStateEvent = /*#__PURE__*/function (RoomStateEvent) {
+ RoomStateEvent["Events"] = "RoomState.events";
+ RoomStateEvent["Members"] = "RoomState.members";
+ RoomStateEvent["NewMember"] = "RoomState.newMember";
+ RoomStateEvent["Update"] = "RoomState.update";
+ RoomStateEvent["BeaconLiveness"] = "RoomState.BeaconLiveness";
+ RoomStateEvent["Marker"] = "RoomState.Marker";
+ return RoomStateEvent;
+}({});
+exports.RoomStateEvent = RoomStateEvent;
+class RoomState extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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.
+ */
+ constructor(roomId, oobMemberFlags = {
+ status: OobStatus.NotStarted
+ }) {
+ super();
+ this.roomId = roomId;
+ this.oobMemberFlags = oobMemberFlags;
+ _defineProperty(this, "reEmitter", new _ReEmitter.TypedReEmitter(this));
+ _defineProperty(this, "sentinels", {});
+ // userId: RoomMember
+ // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
+ _defineProperty(this, "displayNameToUserIds", new Map());
+ _defineProperty(this, "userIdsToDisplayNames", {});
+ _defineProperty(this, "tokenToInvite", {});
+ // 3pid invite state_key to m.room.member invite
+ _defineProperty(this, "joinedMemberCount", 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
+ _defineProperty(this, "summaryJoinedMemberCount", null);
+ // same for invited member count
+ _defineProperty(this, "invitedMemberCount", null);
+ _defineProperty(this, "summaryInvitedMemberCount", null);
+ _defineProperty(this, "modified", -1);
+ // XXX: Should be read-only
+ // The room member dictionary, keyed on the user's ID.
+ _defineProperty(this, "members", {});
+ // userId: RoomMember
+ // The state events dictionary, keyed on the event type and then the state_key value.
+ _defineProperty(this, "events", new Map());
+ // Map<eventType, Map<stateKey, MatrixEvent>>
+ // The pagination token for this state.
+ _defineProperty(this, "paginationToken", null);
+ _defineProperty(this, "beacons", new Map());
+ _defineProperty(this, "_liveBeaconIds", []);
+ 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'
+ */
+ getJoinedMemberCount() {
+ 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
+ */
+ setJoinedMemberCount(count) {
+ this.summaryJoinedMemberCount = count;
+ }
+
+ /**
+ * Returns the number of invited members in this room
+ * @returns The number of members in this room whose membership is 'invite'
+ */
+ getInvitedMemberCount() {
+ 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
+ */
+ setInvitedMemberCount(count) {
+ this.summaryInvitedMemberCount = count;
+ }
+
+ /**
+ * Get all RoomMembers in this room.
+ * @returns A list of RoomMembers.
+ */
+ getMembers() {
+ 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.
+ */
+ getMembersExcept(excludedIds) {
+ 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.
+ */
+ getMember(userId) {
+ 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.
+ */
+ getSentinelMember(userId) {
+ if (!userId) return null;
+ let sentinel = this.sentinels[userId];
+ if (sentinel === undefined) {
+ sentinel = new _roomMember.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).
+ */
+
+ getStateEvents(eventType, stateKey) {
+ 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;
+ }
+ get hasLiveBeacons() {
+ return !!this.liveBeaconIds?.length;
+ }
+ get liveBeaconIds() {
+ 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
+ */
+ clone() {
+ 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
+ */
+ setUnknownStateEvents(events) {
+ 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}
+ */
+ setStateEvents(stateEvents, markerFoundOptions) {
+ this.updateModifiedTime();
+
+ // update the core event dict
+ stateEvents.forEach(event => {
+ if (event.getRoomId() !== this.roomId || !event.isState()) return;
+ if (_beacon2.M_BEACON_INFO.matches(event.getType())) {
+ this.setBeacon(event);
+ }
+ const lastStateEvent = this.getStateEventMatching(event);
+ this.setStateEvent(event);
+ if (event.getType() === _event.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() === _event.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() === _event.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 (_event.UNSTABLE_MSC2716_MARKER.matches(event.getType())) {
+ this.emit(RoomStateEvent.Marker, event, markerFoundOptions);
+ }
+ });
+ this.emit(RoomStateEvent.Update, this);
+ }
+ async processBeaconEvents(events, matrixClient) {
+ if (!events.length ||
+ // discard locations if we have no beacons
+ !this.beacons.size) {
+ return;
+ }
+ const beaconByEventIdDict = [...this.beacons.values()].reduce((dict, beacon) => {
+ dict[beacon.beaconInfoId] = beacon;
+ return dict;
+ }, {});
+ const processBeaconRelation = (beaconInfoEventId, event) => {
+ if (!_beacon2.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 (!_beacon2.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(_event2.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}
+ */
+ getOrCreateMember(userId, event) {
+ let member = this.members[userId];
+ if (!member) {
+ member = new _roomMember.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;
+ }
+ setStateEvent(event) {
+ if (!this.events.has(event.getType())) {
+ this.events.set(event.getType(), new Map());
+ }
+ this.events.get(event.getType()).set(event.getStateKey(), event);
+ }
+
+ /**
+ * @experimental
+ */
+ setBeacon(event) {
+ const beaconIdentifier = (0, _beacon.getBeaconInfoIdentifier)(event);
+ if (this.beacons.has(beaconIdentifier)) {
+ const beacon = this.beacons.get(beaconIdentifier);
+ if (event.isRedacted()) {
+ if (beacon.beaconInfoId === event.getRedactionEvent()?.redacts) {
+ beacon.destroy();
+ this.beacons.delete(beaconIdentifier);
+ }
+ return;
+ }
+ return beacon.update(event);
+ }
+ if (event.isRedacted()) {
+ return;
+ }
+ const beacon = new _beacon.Beacon(event);
+ this.reEmitter.reEmit(beacon, [_beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]);
+ this.emit(_beacon.BeaconEvent.New, event, beacon);
+ beacon.on(_beacon.BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this));
+ beacon.on(_beacon.BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this));
+ this.beacons.set(beacon.identifier, beacon);
+ }
+
+ /**
+ * @experimental
+ * Check liveness of room beacons
+ * emit RoomStateEvent.BeaconLiveness event
+ */
+ onBeaconLivenessChange() {
+ this._liveBeaconIds = Array.from(this.beacons.values()).filter(beacon => beacon.isLive).map(beacon => beacon.identifier);
+ this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons);
+ }
+ getStateEventMatching(event) {
+ return this.events.get(event.getType())?.get(event.getStateKey()) ?? null;
+ }
+ updateMember(member) {
+ // this member may have a power level already, so set it.
+ const pwrLvlEvent = this.getStateEvents(_event.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
+ */
+ needsOutOfBandMembers() {
+ 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.
+ */
+ outOfBandMembersReady() {
+ 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
+ */
+ markOutOfBandMembersStarted() {
+ 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
+ */
+ markOutOfBandMembersFailed() {
+ if (this.oobMemberFlags.status !== OobStatus.InProgress) {
+ return;
+ }
+ this.oobMemberFlags.status = OobStatus.NotStarted;
+ }
+
+ /**
+ * Clears the loaded out-of-band members
+ */
+ clearOutOfBandMembers() {
+ let count = 0;
+ Object.keys(this.members).forEach(userId => {
+ const member = this.members[userId];
+ if (member.isOutOfBand()) {
+ ++count;
+ delete this.members[userId];
+ }
+ });
+ _logger.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
+ */
+ setOutOfBandMembers(stateEvents) {
+ _logger.logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
+ if (this.oobMemberFlags.status !== OobStatus.InProgress) {
+ return;
+ }
+ _logger.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
+ */
+ setOutOfBandMember(stateEvent) {
+ if (stateEvent.getType() !== _event.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
+ */
+ setTypingEvent(event) {
+ 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
+ */
+ getInviteForThreePidToken(token) {
+ return this.tokenToInvite[token] || null;
+ }
+
+ /**
+ * Update the last modified time to the current time.
+ */
+ updateModifiedTime() {
+ 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
+ */
+ getLastModifiedTime() {
+ 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.
+ */
+ getUserIdsWithDisplayName(displayName) {
+ return this.displayNameToUserIds.get((0, _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
+ */
+ maySendRedactionForEvent(mxEvent, userId) {
+ 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(_event.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
+ */
+ hasSufficientPowerLevelFor(action, powerLevel) {
+ const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, "");
+ let powerLevels = {};
+ if (powerLevelsEvent) {
+ powerLevels = powerLevelsEvent.getContent();
+ }
+ let requiredLevel = 50;
+ if ((0, _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.
+ */
+ maySendMessage(userId) {
+ return this.maySendEventOfType(_event.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.
+ */
+ maySendEvent(eventType, userId) {
+ 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.
+ */
+ mayClientSendStateEvent(stateEventType, cli) {
+ 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.
+ */
+ maySendStateEvent(stateEventType, userId) {
+ 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.
+ */
+ maySendEventOfType(eventType, userId, state) {
+ const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, "");
+ let powerLevels;
+ let eventsLevels = {};
+ 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.
+ */
+ mayTriggerNotifOfType(notifLevelKey, userId) {
+ const member = this.getMember(userId);
+ if (!member) {
+ return false;
+ }
+ const powerLevelsEvent = this.getStateEvents(_event.EventType.RoomPowerLevels, "");
+ let notifLevel = 50;
+ if (powerLevelsEvent && powerLevelsEvent.getContent() && powerLevelsEvent.getContent().notifications && (0, _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
+ */
+ getJoinRule() {
+ const joinRuleEvent = this.getStateEvents(_event.EventType.RoomJoinRules, "");
+ const joinRuleContent = joinRuleEvent?.getContent() ?? {};
+ return joinRuleContent["join_rule"] || _partials.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
+ */
+ getHistoryVisibility() {
+ const historyVisibilityEvent = this.getStateEvents(_event.EventType.RoomHistoryVisibility, "");
+ const historyVisibilityContent = historyVisibilityEvent?.getContent() ?? {};
+ return historyVisibilityContent["history_visibility"] || _partials.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
+ */
+ getGuestAccess() {
+ const guestAccessEvent = this.getStateEvents(_event.EventType.RoomGuestAccess, "");
+ const guestAccessContent = guestAccessEvent?.getContent() ?? {};
+ return guestAccessContent["guest_access"] || _partials.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).
+ */
+ findPredecessor(msc3946ProcessDynamicPredecessor = false) {
+ // Note: the tests for this function are against Room.findPredecessor,
+ // which just calls through to here.
+
+ if (msc3946ProcessDynamicPredecessor) {
+ const predecessorEvent = this.getStateEvents(_event.EventType.RoomPredecessor, "");
+ if (predecessorEvent) {
+ const content = predecessorEvent.getContent();
+ 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(_event.EventType.RoomCreate, "");
+ if (createEvent) {
+ const predecessor = createEvent.getContent()["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;
+ }
+ updateThirdPartyTokenCache(memberEvent) {
+ if (!memberEvent.getContent().third_party_invite) {
+ return;
+ }
+ const token = (memberEvent.getContent().third_party_invite.signed || {}).token;
+ if (!token) {
+ return;
+ }
+ const threePidInvite = this.getStateEvents(_event.EventType.RoomThirdPartyInvite, token);
+ if (!threePidInvite) {
+ return;
+ }
+ this.tokenToInvite[token] = memberEvent;
+ }
+ updateDisplayNameCache(userId, displayName) {
+ 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 = (0, _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 && (0, _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);
+ }
+ }
+}
+exports.RoomState = RoomState; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js
new file mode 100644
index 0000000000..44296632b4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js
@@ -0,0 +1,34 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomSummary = void 0;
+/*
+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.
+*/
+
+/**
+ * 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.
+ */
+class RoomSummary {
+ constructor(roomId, info) {
+ this.roomId = roomId;
+ }
+}
+exports.RoomSummary = RoomSummary; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js
new file mode 100644
index 0000000000..d1bc837971
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js
@@ -0,0 +1,3079 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomNameType = exports.RoomEvent = exports.Room = exports.NotificationCountType = exports.KNOWN_SAFE_ROOM_VERSION = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _eventTimelineSet = require("./event-timeline-set");
+var _eventTimeline = require("./event-timeline");
+var _contentRepo = require("../content-repo");
+var _utils = require("../utils");
+var _event = require("./event");
+var _eventStatus = require("./event-status");
+var _roomMember = require("./room-member");
+var _roomSummary = require("./room-summary");
+var _logger = require("../logger");
+var _ReEmitter = require("../ReEmitter");
+var _event2 = require("../@types/event");
+var _client = require("../client");
+var _filter = require("../filter");
+var _roomState = require("./room-state");
+var _beacon = require("./beacon");
+var _thread = require("./thread");
+var _read_receipts = require("../@types/read_receipts");
+var _relationsContainer = require("./relations-container");
+var _readReceipt = require("./read-receipt");
+var _poll = require("./poll");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// 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.
+const KNOWN_SAFE_ROOM_VERSION = "10";
+exports.KNOWN_SAFE_ROOM_VERSION = KNOWN_SAFE_ROOM_VERSION;
+const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
+// 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;
+let NotificationCountType = /*#__PURE__*/function (NotificationCountType) {
+ NotificationCountType["Highlight"] = "highlight";
+ NotificationCountType["Total"] = "total";
+ return NotificationCountType;
+}({});
+exports.NotificationCountType = NotificationCountType;
+let RoomEvent = /*#__PURE__*/function (RoomEvent) {
+ RoomEvent["MyMembership"] = "Room.myMembership";
+ RoomEvent["Tags"] = "Room.tags";
+ RoomEvent["AccountData"] = "Room.accountData";
+ RoomEvent["Receipt"] = "Room.receipt";
+ RoomEvent["Name"] = "Room.name";
+ RoomEvent["Redaction"] = "Room.redaction";
+ RoomEvent["RedactionCancelled"] = "Room.redactionCancelled";
+ RoomEvent["LocalEchoUpdated"] = "Room.localEchoUpdated";
+ RoomEvent["Timeline"] = "Room.timeline";
+ RoomEvent["TimelineReset"] = "Room.timelineReset";
+ RoomEvent["TimelineRefresh"] = "Room.TimelineRefresh";
+ RoomEvent["OldStateUpdated"] = "Room.OldStateUpdated";
+ RoomEvent["CurrentStateUpdated"] = "Room.CurrentStateUpdated";
+ RoomEvent["HistoryImportedWithinTimeline"] = "Room.historyImportedWithinTimeline";
+ RoomEvent["UnreadNotifications"] = "Room.UnreadNotifications";
+ return RoomEvent;
+}({});
+exports.RoomEvent = RoomEvent;
+class Room extends _readReceipt.ReadReceipt {
+ /**
+ * 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
+ */
+ constructor(roomId, client, myUserId, opts = {}) {
+ 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.roomId = roomId;
+ this.client = client;
+ this.myUserId = myUserId;
+ this.opts = opts;
+ _defineProperty(this, "reEmitter", void 0);
+ _defineProperty(this, "txnToEvent", new Map());
+ // Pending in-flight requests { string: MatrixEvent }
+ _defineProperty(this, "notificationCounts", {});
+ _defineProperty(this, "threadNotifications", new Map());
+ _defineProperty(this, "cachedThreadReadReceipts", new Map());
+ // Useful to know at what point the current user has started using threads in this room
+ _defineProperty(this, "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
+ */
+ _defineProperty(this, "unthreadedReceipts", new Map());
+ _defineProperty(this, "timelineSets", void 0);
+ _defineProperty(this, "polls", new Map());
+ /**
+ * Empty array if the timeline sets have not been initialised. After initialisation:
+ * 0: All threads
+ * 1: Threads the current user has participated in
+ */
+ _defineProperty(this, "threadsTimelineSets", []);
+ // any filtered timeline sets we're maintaining for this room
+ _defineProperty(this, "filteredTimelineSets", {});
+ // filter_id: timelineSet
+ _defineProperty(this, "timelineNeedsRefresh", false);
+ _defineProperty(this, "pendingEventList", void 0);
+ // read by megolm via getter; boolean value - null indicates "use global value"
+ _defineProperty(this, "blacklistUnverifiedDevices", void 0);
+ _defineProperty(this, "selfMembership", void 0);
+ _defineProperty(this, "summaryHeroes", null);
+ // flags to stop logspam about missing m.room.create events
+ _defineProperty(this, "getTypeWarning", false);
+ _defineProperty(this, "getVersionWarning", false);
+ _defineProperty(this, "membersPromise", void 0);
+ // XXX: These should be read-only
+ /**
+ * The human-readable display name for this room.
+ */
+ _defineProperty(this, "name", void 0);
+ /**
+ * The un-homoglyphed name for this room.
+ */
+ _defineProperty(this, "normalizedName", void 0);
+ /**
+ * 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 } }`
+ */
+ _defineProperty(this, "tags", {});
+ // $tagName: { $metadata: $value }
+ /**
+ * accountData Dict of per-room account_data events; the keys are the
+ * event type and the values are the events.
+ */
+ _defineProperty(this, "accountData", new Map());
+ // $eventType: $event
+ /**
+ * The room summary.
+ */
+ _defineProperty(this, "summary", null);
+ /**
+ * The live event timeline for this room, with the oldest event at index 0.
+ *
+ * @deprecated Present for backwards compatibility.
+ * Use getLiveTimeline().getEvents() instead
+ */
+ _defineProperty(this, "timeline", void 0);
+ /**
+ * oldState The state of the room at the time of the oldest event in the live timeline.
+ *
+ * @deprecated Present for backwards compatibility.
+ * Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead
+ */
+ _defineProperty(this, "oldState", void 0);
+ /**
+ * currentState The state of the room at the time of the newest event in the timeline.
+ *
+ * @deprecated Present for backwards compatibility.
+ * Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
+ */
+ _defineProperty(this, "currentState", void 0);
+ _defineProperty(this, "relations", new _relationsContainer.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
+ */
+ _defineProperty(this, "threads", new Map());
+ /**
+ * @deprecated This value is unreliable. It may not contain the last thread.
+ * Use {@link Room.getLastThread} instead.
+ */
+ _defineProperty(this, "lastThread", void 0);
+ /**
+ * 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
+ */
+ _defineProperty(this, "visibilityEvents", new Map());
+ _defineProperty(this, "threadTimelineSetsPromise", null);
+ _defineProperty(this, "threadsReady", false);
+ _defineProperty(this, "updateThreadRootEvents", (thread, toStartOfTimeline, recreateEvent) => {
+ if (thread.length) {
+ this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent);
+ if (thread.hasCurrentUserParticipated) {
+ this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent);
+ }
+ }
+ });
+ _defineProperty(this, "updateThreadRootEvent", (timelineSet, thread, toStartOfTimeline, recreateEvent) => {
+ if (timelineSet && thread.rootEvent) {
+ if (recreateEvent) {
+ timelineSet.removeEvent(thread.id);
+ }
+ if (_thread.Thread.hasServerSideSupport) {
+ timelineSet.addLiveEvent(thread.rootEvent, {
+ duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Replace,
+ fromCache: false,
+ roomState: this.currentState
+ });
+ } else {
+ timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), {
+ toStartOfTimeline
+ });
+ }
+ }
+ });
+ _defineProperty(this, "applyRedaction", event => {
+ 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.
+ }
+ });
+ this.setMaxListeners(100);
+ this.reEmitter = new _ReEmitter.TypedReEmitter(this);
+ opts.pendingEventOrdering = opts.pendingEventOrdering || _client.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.EventTimelineSet(this, opts)];
+ this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ this.fixUpLegacyTimelineFields();
+ if (this.opts.pendingEventOrdering === _client.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 => {
+ const event = mapper(serializedEvent);
+ await client.decryptEventIfNeeded(event);
+ event.setStatus(_eventStatus.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;
+ }
+ }
+ async createThreadsTimelineSets() {
+ if (this.threadTimelineSetsPromise) {
+ return this.threadTimelineSetsPromise;
+ }
+ if (this.client?.supportsThreads()) {
+ try {
+ this.threadTimelineSetsPromise = Promise.all([this.createThreadTimelineSet(), this.createThreadTimelineSet(_thread.ThreadFilterType.My)]);
+ const timelineSets = await this.threadTimelineSetsPromise;
+ this.threadsTimelineSets[0] = timelineSets[0];
+ this.threadsTimelineSets[1] = timelineSets[1];
+ 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
+ */
+ async decryptCriticalEvents() {
+ 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
+ */
+ async decryptAllEvents() {
+ 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
+ */
+ getCreator() {
+ const createEvent = this.currentState.getStateEvents(_event2.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
+ */
+ getVersion() {
+ const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, "");
+ if (!createEvent) {
+ if (!this.getVersionWarning) {
+ _logger.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
+ */
+ shouldUpgradeToVersion() {
+ // 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.
+ */
+ async getRecommendedVersion() {
+ 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] = _client.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.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.logger.warn("No room version capability - assuming upgrade required.");
+ return result;
+ } else {
+ result = this.checkVersionAgainstCapability(versionCap);
+ }
+ }
+ return result;
+ }
+ checkVersionAgainstCapability(versionCap) {
+ const currentVersion = this.getVersion();
+ _logger.logger.log(`[${this.roomId}] Current version: ${currentVersion}`);
+ _logger.logger.log(`[${this.roomId}] Version capability: `, versionCap);
+ const result = {
+ 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.logger.warn(`URGENT upgrade required on ${this.roomId}`);
+ } else {
+ _logger.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
+ */
+ userMayUpgradeRoom(userId) {
+ return this.currentState.maySendStateEvent(_event2.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'
+ */
+ getPendingEvents() {
+ 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.
+ */
+ removePendingEvent(eventId) {
+ if (!this.pendingEventList) {
+ throw new Error("Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering);
+ }
+ const removed = (0, _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.
+ */
+ hasPendingEvent(eventId) {
+ 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.
+ */
+ getPendingEvent(eventId) {
+ return this.pendingEventList?.find(event => event.getId() === eventId) ?? null;
+ }
+
+ /**
+ * Get the live unfiltered timeline for this room.
+ *
+ * @returns live timeline
+ */
+ getLiveTimeline() {
+ return this.getUnfilteredTimelineSet().getLiveTimeline();
+ }
+
+ /**
+ * Get the timestamp of the last message in the room
+ *
+ * @returns the timestamp of the last message in the room
+ */
+ getLastActiveTimestamp() {
+ 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 last live event of this room.
+ * "last" means latest timestamp.
+ * Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
+ * Unfortunately, this information is currently not available in the client.
+ * See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
+ * "live of this room" means from all live timelines: the room and the threads.
+ *
+ * @returns MatrixEvent if there is a last event; else undefined.
+ */
+ getLastLiveEvent() {
+ const roomEvents = this.getLiveTimeline().getEvents();
+ const lastRoomEvent = roomEvents[roomEvents.length - 1];
+ const lastThread = this.getLastThread();
+ if (!lastThread) return lastRoomEvent;
+ const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
+ return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
+ }
+
+ /**
+ * Returns the last thread of this room.
+ * "last" means latest timestamp of the last thread event.
+ * Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
+ * Unfortunately, this information is currently not available in the client.
+ * See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
+ *
+ * @returns the thread with the most recent event in its live time line. undefined if there is no thread.
+ */
+ getLastThread() {
+ return this.getThreads().reduce((lastThread, thread) => {
+ if (!lastThread) return thread;
+ const threadEvent = thread.events[thread.events.length - 1];
+ const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
+ if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) {
+ // Last message of current thread is newer → new last thread.
+ // Equal also means newer, because it was added to the thread map later.
+ return thread;
+ }
+ return lastThread;
+ }, undefined);
+ }
+
+ /**
+ * @returns the membership type (join | leave | invite) for the logged in user
+ */
+ getMyMembership() {
+ 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
+ */
+ getDMInviter() {
+ 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)
+ */
+ guessDMUserId() {
+ 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;
+ }
+ getAvatarFallbackMember() {
+ 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.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
+ */
+ updateMyMembership(membership) {
+ const prevMembership = this.selfMembership;
+ this.selfMembership = membership;
+ if (prevMembership !== membership) {
+ if (membership === "leave") {
+ this.cleanupAfterLeaving();
+ }
+ this.emit(RoomEvent.MyMembership, this, membership, prevMembership);
+ }
+ }
+ async loadMembersFromServer() {
+ const lastSyncToken = this.client.store.getSyncToken();
+ const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken ?? undefined);
+ return response.chunk;
+ }
+ async loadMembers() {
+ // 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.logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`);
+ }
+ const memberEvents = rawMembersEvents.filter(_utils.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.
+ */
+ membersLoaded() {
+ 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
+ */
+ loadMembersIfNeeded() {
+ 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);
+ _logger.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.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.logger.error(err);
+ });
+ this.membersPromise = inMemoryUpdate;
+ return this.membersPromise;
+ }
+
+ /**
+ * Removes the lazily loaded members from storage if needed
+ */
+ async clearLoadedMembersIfNeeded() {
+ 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.
+ */
+ cleanupAfterLeaving() {
+ this.clearLoadedMembersIfNeeded().catch(err => {
+ _logger.logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`);
+ _logger.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()`.
+ */
+ async refreshLiveTimeline() {
+ const liveTimelineBefore = this.getLiveTimeline();
+ const forwardPaginationToken = liveTimelineBefore.getPaginationToken(_eventTimeline.EventTimeline.FORWARDS);
+ const backwardPaginationToken = liveTimelineBefore.getPaginationToken(_eventTimeline.EventTimeline.BACKWARDS);
+ const eventsBefore = liveTimelineBefore.getEvents();
+ const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1];
+ _logger.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;
+ // 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(_eventTimeline.Direction.Forward) === null && liveTimeline.getPaginationToken(_eventTimeline.Direction.Backward) === null && liveTimeline.getEvents().length === 0) {
+ _logger.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.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.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.
+ */
+ resetLiveTimeline(backPaginationToken, forwardPaginationToken) {
+ 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
+ */
+ fixUpLegacyTimelineFields() {
+ 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.EventTimeline.BACKWARDS);
+ this.currentState = this.getLiveTimeline().getState(_eventTimeline.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, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _roomState.RoomStateEvent.Marker, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]);
+ this.reEmitter.reEmit(this.currentState, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _roomState.RoomStateEvent.Marker, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.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
+ */
+ async hasUnverifiedDevices() {
+ 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
+ */
+ getTimelineSets() {
+ return this.timelineSets;
+ }
+
+ /**
+ * Helper to return the main unfiltered timeline set for this room
+ * @returns room's unfiltered timeline set
+ */
+ getUnfilteredTimelineSet() {
+ 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
+ */
+ getTimelineForEvent(eventId) {
+ 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
+ */
+ addTimeline() {
+ 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
+ */
+ setTimelineNeedsRefresh(value) {
+ this.timelineNeedsRefresh = value;
+ }
+
+ /**
+ * Whether the timeline needs to be refreshed in order to pull in new
+ * historical messages that were imported.
+ * @returns .
+ */
+ getTimelineNeedsRefresh() {
+ 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
+ */
+ findEventById(eventId) {
+ 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.
+ */
+ getUnreadNotificationCount(type = NotificationCountType.Total) {
+ 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)
+ */
+ getUnreadCountForEventContext(type = NotificationCountType.Total, event) {
+ 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.
+ */
+ getRoomUnreadNotificationCount(type = NotificationCountType.Total) {
+ 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.
+ */
+ getThreadUnreadNotificationCount(threadId, type = NotificationCountType.Total) {
+ return this.threadNotifications.get(threadId)?.[type] ?? 0;
+ }
+
+ /**
+ * Checks if the current room has unread thread notifications
+ * @returns
+ */
+ hasThreadUnreadNotification() {
+ 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
+ */
+ setThreadUnreadNotificationCount(threadId, type, count) {
+ const notification = _objectSpread({
+ 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
+ */
+ get threadsAggregateNotificationType() {
+ let type = 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
+ */
+ resetThreadUnreadNotificationCount(notificationsToKeep) {
+ 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
+ */
+ setUnreadNotificationCount(type, count) {
+ this.notificationCounts[type] = count;
+ this.emit(RoomEvent.UnreadNotifications, this.notificationCounts);
+ }
+ setUnread(type, count) {
+ return this.setUnreadNotificationCount(type, count);
+ }
+ setSummary(summary) {
+ 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.
+ */
+ setBlacklistUnverifiedDevices(value) {
+ 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.
+ */
+ getBlacklistUnverifiedDevices() {
+ 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.
+ */
+ getAvatarUrl(baseUrl, width, height, resizeMethod, allowDefault = true) {
+ const roomAvatarEvent = this.currentState.getStateEvents(_event2.EventType.RoomAvatar, "");
+ if (!roomAvatarEvent && !allowDefault) {
+ return null;
+ }
+ const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
+ if (mainUrl) {
+ return (0, _contentRepo.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
+ */
+ getMxcAvatarUrl() {
+ return this.currentState.getStateEvents(_event2.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
+ */
+ getCanonicalAlias() {
+ const canonicalAlias = this.currentState.getStateEvents(_event2.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
+ */
+ getAltAliases() {
+ const canonicalAlias = this.currentState.getStateEvents(_event2.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}
+ */
+ addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken) {
+ 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
+ */
+ getThread(eventId) {
+ return this.threads.get(eventId) ?? null;
+ }
+
+ /**
+ * Get all the known threads in the room
+ */
+ getThreads() {
+ 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`.
+ */
+ getMember(userId) {
+ return this.currentState.getMember(userId);
+ }
+
+ /**
+ * Get all currently loaded members from the current
+ * room state.
+ * @returns Room members
+ */
+ getMembers() {
+ return this.currentState.getMembers();
+ }
+
+ /**
+ * Get a list of members whose membership state is "join".
+ * @returns A list of currently joined members.
+ */
+ getJoinedMembers() {
+ 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'
+ */
+ getJoinedMemberCount() {
+ 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'
+ */
+ getInvitedMemberCount() {
+ 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'
+ */
+ getInvitedAndJoinedMemberCount() {
+ 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.
+ */
+ getMembersWithMembership(membership) {
+ 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.
+ */
+ async getEncryptionTargetMembers() {
+ 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
+ */
+ shouldEncryptForInvitedMembers() {
+ const ev = this.currentState.getStateEvents(_event2.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
+ */
+ getDefaultRoomName(userId) {
+ 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.
+ */
+ hasMembershipState(userId, membership) {
+ 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
+ */
+ getOrCreateFilteredTimelineSet(filter, {
+ prepopulateTimeline = true,
+ useSyncEvents = true,
+ pendingEvents = true
+ } = {}) {
+ if (this.filteredTimelineSets[filter.filterId]) {
+ return this.filteredTimelineSets[filter.filterId];
+ }
+ const opts = Object.assign({
+ filter,
+ pendingEvents
+ }, this.opts);
+ const timelineSet = new _eventTimelineSet.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.EventTimeline.BACKWARDS)) {
+ timeline = timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS);
+ }
+ timelineSet.getLiveTimeline().setPaginationToken(timeline.getPaginationToken(_eventTimeline.EventTimeline.BACKWARDS), _eventTimeline.EventTimeline.BACKWARDS);
+ } else if (useSyncEvents) {
+ const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(_eventTimeline.Direction.Forward);
+ timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, _eventTimeline.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;
+ }
+ async getThreadListFilter(filterType = _thread.ThreadFilterType.All) {
+ const myUserId = this.client.getUserId();
+ const filter = new _filter.Filter(myUserId);
+ const definition = {
+ room: {
+ timeline: {
+ [_thread.FILTER_RELATED_BY_REL_TYPES.name]: [_thread.THREAD_RELATION_TYPE.name]
+ }
+ }
+ };
+ if (filterType === _thread.ThreadFilterType.My) {
+ definition.room.timeline[_thread.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;
+ }
+ async createThreadTimelineSet(filterType) {
+ let timelineSet;
+ if (_thread.Thread.hasServerSideListSupport) {
+ timelineSet = new _eventTimelineSet.EventTimelineSet(this, _objectSpread(_objectSpread({}, this.opts), {}, {
+ pendingEvents: false
+ }), undefined, undefined, filterType ?? _thread.ThreadFilterType.All);
+ this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ } else if (_thread.Thread.hasServerSideSupport) {
+ const filter = await this.getThreadListFilter(filterType);
+ timelineSet = this.getOrCreateFilteredTimelineSet(filter, {
+ prepopulateTimeline: false,
+ useSyncEvents: false,
+ pendingEvents: false
+ });
+ } else {
+ timelineSet = new _eventTimelineSet.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 !== _thread.ThreadFilterType.My || currentUserParticipated) {
+ timelineSet.getLiveTimeline().addEvent(thread.rootEvent, {
+ toStartOfTimeline: false
+ });
+ }
+ });
+ }
+ return timelineSet;
+ }
+ /**
+ * Takes the given thread root events and creates threads for them.
+ */
+ processThreadRoots(events, toStartOfTimeline) {
+ for (const rootEvent of events) {
+ _eventTimeline.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.
+ */
+ async fetchRoomThreads() {
+ if (this.threadsReady || !this.client.supportsThreads()) {
+ return;
+ }
+ if (_thread.Thread.hasServerSideListSupport) {
+ await Promise.all([this.fetchRoomThreadList(_thread.ThreadFilterType.All), this.fetchRoomThreadList(_thread.ThreadFilterType.My)]);
+ } else {
+ const allThreadsFilter = await this.getThreadListFilter();
+ const {
+ chunk: events
+ } = await this.client.createMessagesRequest(this.roomId, "", Number.MAX_SAFE_INTEGER, _eventTimeline.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(_thread.THREAD_RELATION_TYPE.name);
+ const threadBMetadata = eventB.getServerAggregatedRelation(_thread.THREAD_RELATION_TYPE.name);
+ return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts;
+ });
+ let latestMyThreadsRootEvent;
+ const roomState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS);
+ for (const rootEvent of threadRoots) {
+ const opts = {
+ duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Ignore,
+ fromCache: false,
+ roomState
+ };
+ this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts);
+ const threadRelationship = rootEvent.getServerAggregatedRelation(_thread.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(_thread.ThreadEvent.NewReply, this.onThreadNewReply);
+ this.on(_thread.ThreadEvent.Delete, this.onThreadDelete);
+ this.threadsReady = true;
+ }
+
+ /**
+ * Calls {@link processPollEvent} for a list of events.
+ *
+ * @param events - List of events
+ */
+ async processPollEvents(events) {
+ for (const event of events) {
+ try {
+ // Continue if the event is a clear text, non-poll event.
+ if (!event.isEncrypted() && !(0, _poll.isPollEvent)(event)) continue;
+
+ /**
+ * Try to decrypt the event. Promise resolution does not guarantee a successful decryption.
+ * Retry is handled in {@link processPollEvent}.
+ */
+ await this.client.decryptEventIfNeeded(event);
+ this.processPollEvent(event);
+ } catch (err) {
+ _logger.logger.warn("Error processing poll event", event.getId(), err);
+ }
+ }
+ }
+
+ /**
+ * Processes poll events:
+ * If the event has a decryption failure, it will listen for decryption and tries again.
+ * If it is a poll start event (`m.poll.start`),
+ * it creates and stores a Poll model and emits a PollEvent.New event.
+ * If the event is related to a poll, it will add it to the poll.
+ * Noop for other cases.
+ *
+ * @param event - Event that could be a poll event
+ */
+ async processPollEvent(event) {
+ if (event.isDecryptionFailure()) {
+ event.once(_event.MatrixEventEvent.Decrypted, maybeDecryptedEvent => {
+ this.processPollEvent(maybeDecryptedEvent);
+ });
+ return;
+ }
+ if (_matrixEventsSdk.M_POLL_START.matches(event.getType())) {
+ try {
+ const poll = new _poll.Poll(event, this.client, this);
+ this.polls.set(event.getId(), poll);
+ this.emit(_poll.PollEvent.New, poll);
+ } catch {}
+ // poll creation can fail for malformed poll start events
+ return;
+ }
+ const relationEventId = event.relationEventId;
+ if (relationEventId && this.polls.has(relationEventId)) {
+ const poll = this.polls.get(relationEventId);
+ poll?.onNewRelation(event);
+ }
+ }
+
+ /**
+ * Fetch a single page of threadlist messages for the specific thread filter
+ * @internal
+ */
+ async fetchRoomThreadList(filter) {
+ if (this.threadsTimelineSets.length === 0) return;
+ const timelineSet = filter === _thread.ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
+ const {
+ chunk: events,
+ end
+ } = await this.client.createThreadListMessagesRequest(this.roomId, null, undefined, _eventTimeline.Direction.Backward, timelineSet.threadListType, timelineSet.getFilter());
+ timelineSet.getLiveTimeline().setPaginationToken(end ?? null, _eventTimeline.Direction.Backward);
+ if (!events.length) return;
+ const matrixEvents = events.map(this.client.getEventMapper());
+ this.processThreadRoots(matrixEvents, true);
+ const roomState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS);
+ for (const rootEvent of matrixEvents) {
+ timelineSet.addLiveEvent(rootEvent, {
+ duplicateStrategy: _eventTimelineSet.DuplicateStrategy.Replace,
+ fromCache: false,
+ roomState
+ });
+ }
+ }
+ onThreadNewReply(thread) {
+ this.updateThreadRootEvents(thread, false, true);
+ }
+ onThreadDelete(thread) {
+ 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.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
+ */
+ removeFilteredTimelineSet(filter) {
+ 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);
+ }
+ }
+ eventShouldLiveIn(event, events, roots) {
+ 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.THREAD_RELATION_TYPE.name)) {
+ return {
+ shouldLiveInRoom: false,
+ shouldLiveInThread: true,
+ threadId: event.threadRootId
+ };
+ }
+ const parentEventId = event.getAssociatedId();
+ let parentEvent;
+ 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);
+ }
+ if (!event.isRelation()) {
+ return {
+ shouldLiveInRoom: true,
+ shouldLiveInThread: false
+ };
+ }
+
+ // 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
+ };
+ }
+ const unsigned = event.getUnsigned();
+ if (typeof unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] === "string") {
+ return {
+ shouldLiveInRoom: false,
+ shouldLiveInThread: true,
+ threadId: unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name]
+ };
+ }
+
+ // We've exhausted all scenarios,
+ // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
+ // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
+ return {
+ shouldLiveInRoom: false,
+ shouldLiveInThread: false
+ };
+ }
+ findThreadForEvent(event) {
+ if (!event) return null;
+ const {
+ threadId
+ } = this.eventShouldLiveIn(event);
+ return threadId ? this.getThread(threadId) : null;
+ }
+ addThreadedEvents(threadId, events, toStartOfTimeline = false) {
+ const thread = this.getThread(threadId);
+ if (thread) {
+ thread.addEvents(events, toStartOfTimeline);
+ } else {
+ const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
+ this.createThread(threadId, rootEvent, events, toStartOfTimeline);
+ }
+ }
+
+ /**
+ * Adds events to a thread's timeline. Will fire "Thread.update"
+ */
+ processThreadedEvents(events, toStartOfTimeline) {
+ events.forEach(this.applyRedaction);
+ const eventsByThread = {};
+ 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));
+ }
+ createThread(threadId, rootEvent, events = [], toStartOfTimeline) {
+ 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(_event2.RelationType.Replace)));
+ }
+ }
+ const thread = new _thread.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, [_thread.ThreadEvent.Delete, _thread.ThreadEvent.Update, _thread.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(_thread.ThreadEvent.New, thread, toStartOfTimeline);
+ return thread;
+ }
+ processLiveEvent(event) {
+ 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.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}
+ */
+ addLiveEvent(event, addLiveEventOptions) {
+ 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() !== _event2.EventType.RoomRedaction) {
+ this.addReceipt((0, _readReceipt.synthesizeReceipt)(event.sender.userId, event, _read_receipts.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}
+ */
+ addPendingEvent(event, txnId) {
+ if (event.status !== _eventStatus.EventStatus.SENDING && event.status !== _eventStatus.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.EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS), false);
+ this.txnToEvent.set(txnId, event);
+ if (this.pendingEventList) {
+ if (this.pendingEventList.some(e => e.status === _eventStatus.EventStatus.NOT_SENT)) {
+ _logger.logger.warn("Setting event as NOT_SENT due to messages in the same state");
+ event.setStatus(_eventStatus.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
+ */
+ savePendingEvents() {
+ if (this.pendingEventList) {
+ const pendingEvents = this.pendingEventList.map(event => {
+ return _objectSpread(_objectSpread({}, event.event), {}, {
+ txn_id: event.getTxnId()
+ });
+ }).filter(event => {
+ // Filter out the unencrypted messages if the room is encrypted
+ const isEventEncrypted = event.type === _event2.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.
+ */
+ aggregateNonLiveRelation(event) {
+ this.relations.aggregateChildEvent(event);
+ }
+ getEventForTxnId(txnId) {
+ 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}
+ */
+ handleRemoteEcho(remoteEvent, localEvent) {
+ const oldEventId = localEvent.getId();
+ const newEventId = remoteEvent.getId();
+ const oldStatus = localEvent.status;
+ _logger.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}
+ */
+ updatePendingEvent(event, newStatus, newEventId) {
+ _logger.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.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.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.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.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);
+ }
+ revertRedactionLocalEcho(redactionEvent) {
+ 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'.
+ */
+
+ /**
+ * @deprecated In favor of the overload with `IAddLiveEventOptions`
+ */
+
+ async addLiveEvents(events, duplicateStrategyOrOpts, fromCache = false) {
+ let duplicateStrategy = duplicateStrategyOrOpts;
+ let timelineWasEmpty = 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.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.EventTimeline.FORWARDS)) {
+ throw new Error("live timeline " + i + " is no longer live - it has a pagination token " + "(" + liveTimeline.getPaginationToken(_eventTimeline.EventTimeline.FORWARDS) + ")");
+ }
+ if (liveTimeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.FORWARDS)) {
+ throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
+ }
+ }
+ const threadRoots = this.findThreadRoots(events);
+ const eventsByThread = {};
+ const options = {
+ duplicateStrategy,
+ fromCache,
+ timelineWasEmpty
+ };
+
+ // List of extra events to check for being parents of any relations encountered
+ const neighbouringEvents = [...events];
+ 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
+ }
+ }
+
+ let {
+ shouldLiveInRoom,
+ shouldLiveInThread,
+ threadId
+ } = this.eventShouldLiveIn(event, neighbouringEvents, threadRoots);
+ if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) {
+ try {
+ const parentEvent = new _event.MatrixEvent(await this.client.fetchRoomEvent(this.roomId, event.relationEventId));
+ neighbouringEvents.push(parentEvent);
+ if (parentEvent.threadRootId) {
+ threadRoots.add(parentEvent.threadRootId);
+ const unsigned = event.getUnsigned();
+ unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId;
+ event.setUnsigned(unsigned);
+ }
+ ({
+ shouldLiveInRoom,
+ shouldLiveInThread,
+ threadId
+ } = this.eventShouldLiveIn(event, neighbouringEvents, threadRoots));
+ } catch (e) {
+ _logger.logger.error("Failed to load parent event of unhandled relation", e);
+ }
+ }
+ if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
+ eventsByThread[threadId ?? ""] = [];
+ }
+ eventsByThread[threadId ?? ""]?.push(event);
+ if (shouldLiveInRoom) {
+ this.addLiveEvent(event, options);
+ } else if (!shouldLiveInThread && event.isRelation()) {
+ this.relations.aggregateChildEvent(event);
+ }
+ }
+ Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => {
+ this.addThreadedEvents(threadId, threadEvents, false);
+ });
+ }
+ partitionThreadedEvents(events) {
+ // Indices to the events array, for readability
+ const ROOM = 0;
+ const THREAD = 1;
+ const UNKNOWN_RELATION = 2;
+ if (this.client.supportsThreads()) {
+ const threadRoots = this.findThreadRoots(events);
+ return events.reduce((memo, event) => {
+ const {
+ shouldLiveInRoom,
+ shouldLiveInThread,
+ threadId
+ } = this.eventShouldLiveIn(event, events, threadRoots);
+ if (shouldLiveInRoom) {
+ memo[ROOM].push(event);
+ }
+ if (shouldLiveInThread) {
+ event.setThreadId(threadId ?? "");
+ memo[THREAD].push(event);
+ }
+ if (!shouldLiveInThread && !shouldLiveInRoom) {
+ memo[UNKNOWN_RELATION].push(event);
+ }
+ return memo;
+ }, [[], [], []]);
+ } else {
+ // When `experimentalThreadSupport` is disabled treat all events as timelineEvents
+ return [events, [], []];
+ }
+ }
+
+ /**
+ * Given some events, find the IDs of all the thread roots that are referred to by them.
+ */
+ findThreadRoots(events) {
+ const threadRoots = new Set();
+ for (const event of events) {
+ if (event.isRelation(_thread.THREAD_RELATION_TYPE.name)) {
+ threadRoots.add(event.relationEventId ?? "");
+ }
+ const unsigned = event.getUnsigned();
+ if (typeof unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name] === "string") {
+ threadRoots.add(unsigned[_event2.UNSIGNED_THREAD_ID_FIELD.name]);
+ }
+ }
+ return threadRoots;
+ }
+
+ /**
+ * Add a receipt event to the room.
+ * @param event - The m.receipt event.
+ * @param synthetic - True if this event is implicit.
+ */
+ addReceipt(event, synthetic = false) {
+ const content = event.getContent();
+ Object.keys(content).forEach(eventId => {
+ Object.keys(content[eventId]).forEach(receiptType => {
+ Object.keys(content[eventId][receiptType]).forEach(userId => {
+ const receipt = content[eventId][receiptType][userId];
+ const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === _read_receipts.MAIN_ROOM_TIMELINE;
+ const receiptDestination = receiptForMainTimeline ? this : this.threads.get(receipt.thread_id ?? "");
+ if (receiptDestination) {
+ receiptDestination.addReceiptToStructure(eventId, 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
+ */
+ addEphemeralEvents(events) {
+ for (const event of events) {
+ if (event.getType() === _event2.EventType.Typing) {
+ this.currentState.setTypingEvent(event);
+ } else if (event.getType() === _event2.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.
+ */
+ removeEvents(eventIds) {
+ 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
+ */
+ removeEvent(eventId) {
+ 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}
+ */
+ recalculate() {
+ // set fake stripped state events if this is an invite room so logic remains
+ // consistent elsewhere.
+ const membershipEvent = this.currentState.getStateEvents(_event2.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 _event.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 = (0, _utils.normalize)(this.name);
+ this.summary = new _roomSummary.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
+ */
+ addTags(event) {
+ // 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
+ */
+ addAccountData(events) {
+ 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
+ */
+ getAccountData(type) {
+ 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.
+ */
+ maySendMessage() {
+ return this.getMyMembership() === "join" && (this.client.isRoomEncrypted(this.roomId) ? this.currentState.maySendEvent(_event2.EventType.RoomMessageEncrypted, this.myUserId) : this.currentState.maySendEvent(_event2.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.
+ */
+ canInvite(userId) {
+ let canInvite = this.getMyMembership() === "join";
+ const powerLevelsEvent = this.currentState.getStateEvents(_event2.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
+ */
+ getJoinRule() {
+ 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
+ */
+ getHistoryVisibility() {
+ 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
+ */
+ getGuestAccess() {
+ 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.
+ */
+ getType() {
+ const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, "");
+ if (!createEvent) {
+ if (!this.getTypeWarning) {
+ _logger.logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event");
+ this.getTypeWarning = true;
+ }
+ return undefined;
+ }
+ return createEvent.getContent()[_event2.RoomCreateTypeField];
+ }
+
+ /**
+ * Returns whether the room is a space-room as defined by MSC1772.
+ * @returns true if the room's type is RoomType.Space
+ */
+ isSpaceRoom() {
+ return this.getType() === _event2.RoomType.Space;
+ }
+
+ /**
+ * Returns whether the room is a call-room as defined by MSC3417.
+ * @returns true if the room's type is RoomType.UnstableCall
+ */
+ isCallRoom() {
+ return this.getType() === _event2.RoomType.UnstableCall;
+ }
+
+ /**
+ * Returns whether the room is a video room.
+ * @returns true if the room's type is RoomType.ElementVideo
+ */
+ isElementVideoRoom() {
+ return this.getType() === _event2.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).
+ */
+ findPredecessor(msc3946ProcessDynamicPredecessor = false) {
+ const currentState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS);
+ if (!currentState) {
+ return null;
+ }
+ return currentState.findPredecessor(msc3946ProcessDynamicPredecessor);
+ }
+ roomNameGenerator(state) {
+ 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.
+ */
+ calculateRoomName(userId, ignoreRoomNameEvent = false) {
+ if (!ignoreRoomNameEvent) {
+ // check for an alias, if any. for now, assume first alias is the
+ // official one.
+ const mRoomName = this.currentState.getStateEvents(_event2.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 = [];
+ const mFunctionalMembers = this.currentState.getStateEvents(_event2.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 = [];
+ 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) => (0, _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(_event2.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;
+ 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.
+ */
+ applyNewVisibilityEvent(event) {
+ 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 = _event2.EVENT_VISIBILITY_CHANGE_TYPE.name && this.currentState.maySendStateEvent(_event2.EVENT_VISIBILITY_CHANGE_TYPE.name, userId) || _event2.EVENT_VISIBILITY_CHANGE_TYPE.altName && this.currentState.maySendStateEvent(_event2.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);
+ }
+ redactVisibilityChangeEvent(event) {
+ // 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.
+ */
+ applyPendingVisibilityEvents(event) {
+ 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
+ */
+ getOldestThreadedReceiptTs() {
+ 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).
+ */
+ getLastUnthreadedReceiptFor(userId) {
+ 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
+ */
+ fixupNotifications(userId) {
+ 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
+exports.Room = Room;
+const ALLOWED_TRANSITIONS = {
+ [_eventStatus.EventStatus.ENCRYPTING]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.CANCELLED],
+ [_eventStatus.EventStatus.SENDING]: [_eventStatus.EventStatus.ENCRYPTING, _eventStatus.EventStatus.QUEUED, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.SENT],
+ [_eventStatus.EventStatus.QUEUED]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.NOT_SENT, _eventStatus.EventStatus.CANCELLED],
+ [_eventStatus.EventStatus.SENT]: [],
+ [_eventStatus.EventStatus.NOT_SENT]: [_eventStatus.EventStatus.SENDING, _eventStatus.EventStatus.QUEUED, _eventStatus.EventStatus.CANCELLED],
+ [_eventStatus.EventStatus.CANCELLED]: []
+};
+let RoomNameType = /*#__PURE__*/function (RoomNameType) {
+ RoomNameType[RoomNameType["EmptyRoom"] = 0] = "EmptyRoom";
+ RoomNameType[RoomNameType["Generated"] = 1] = "Generated";
+ RoomNameType[RoomNameType["Actual"] = 2] = "Actual";
+ return RoomNameType;
+}({});
+exports.RoomNameType = RoomNameType;
+// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn
+function memberNamesToRoomName(names, count) {
+ 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`;
+ }
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js
new file mode 100644
index 0000000000..e0b1137236
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js
@@ -0,0 +1,58 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SearchResult = void 0;
+var _eventContext = require("./event-context");
+/*
+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.
+*/
+
+class SearchResult {
+ /**
+ * Create a SearchResponse from the response to /search
+ */
+
+ static fromJson(jsonObj, eventMapper) {
+ const jsonContext = jsonObj.context || {};
+ let eventsBefore = (jsonContext.events_before || []).map(eventMapper);
+ let eventsAfter = (jsonContext.events_after || []).map(eventMapper);
+ const context = new _eventContext.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
+ */
+ constructor(rank, context) {
+ this.rank = rank;
+ this.context = context;
+ }
+}
+exports.SearchResult = SearchResult; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js
new file mode 100644
index 0000000000..4471d512e2
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js
@@ -0,0 +1,649 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ThreadFilterType = exports.ThreadEvent = exports.Thread = exports.THREAD_RELATION_TYPE = exports.FeatureSupport = exports.FILTER_RELATED_BY_SENDERS = exports.FILTER_RELATED_BY_REL_TYPES = void 0;
+exports.determineFeatureSupport = determineFeatureSupport;
+exports.threadFilterTypeToFilter = threadFilterTypeToFilter;
+var _client = require("../client");
+var _ReEmitter = require("../ReEmitter");
+var _event = require("../@types/event");
+var _event2 = require("./event");
+var _eventTimeline = require("./event-timeline");
+var _eventTimelineSet = require("./event-timeline-set");
+var _room = require("./room");
+var _NamespacedValue = require("../NamespacedValue");
+var _logger = require("../logger");
+var _readReceipt = require("./read-receipt");
+var _read_receipts = require("../@types/read_receipts");
+var _feature = require("../feature");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let ThreadEvent = /*#__PURE__*/function (ThreadEvent) {
+ ThreadEvent["New"] = "Thread.new";
+ ThreadEvent["Update"] = "Thread.update";
+ ThreadEvent["NewReply"] = "Thread.newReply";
+ ThreadEvent["ViewThread"] = "Thread.viewThread";
+ ThreadEvent["Delete"] = "Thread.delete";
+ return ThreadEvent;
+}({});
+exports.ThreadEvent = ThreadEvent;
+let FeatureSupport = /*#__PURE__*/function (FeatureSupport) {
+ FeatureSupport[FeatureSupport["None"] = 0] = "None";
+ FeatureSupport[FeatureSupport["Experimental"] = 1] = "Experimental";
+ FeatureSupport[FeatureSupport["Stable"] = 2] = "Stable";
+ return FeatureSupport;
+}({});
+exports.FeatureSupport = FeatureSupport;
+function determineFeatureSupport(stable, unstable) {
+ if (stable) {
+ return FeatureSupport.Stable;
+ } else if (unstable) {
+ return FeatureSupport.Experimental;
+ } else {
+ return FeatureSupport.None;
+ }
+}
+class Thread extends _readReceipt.ReadReceipt {
+ constructor(id, rootEvent, opts) {
+ super();
+ this.id = id;
+ this.rootEvent = rootEvent;
+ /**
+ * A reference to all the events ID at the bottom of the threads
+ */
+ _defineProperty(this, "timelineSet", void 0);
+ _defineProperty(this, "timeline", []);
+ _defineProperty(this, "_currentUserParticipated", false);
+ _defineProperty(this, "reEmitter", void 0);
+ _defineProperty(this, "lastEvent", void 0);
+ _defineProperty(this, "replyCount", 0);
+ _defineProperty(this, "lastPendingEvent", void 0);
+ _defineProperty(this, "pendingReplyCount", 0);
+ _defineProperty(this, "room", void 0);
+ _defineProperty(this, "client", void 0);
+ _defineProperty(this, "pendingEventOrdering", void 0);
+ _defineProperty(this, "initialEventsFetched", !Thread.hasServerSideSupport);
+ /**
+ * An array of events to add to the timeline once the thread has been initialised
+ * with server suppport.
+ */
+ _defineProperty(this, "replayEvents", []);
+ _defineProperty(this, "onBeforeRedaction", (event, redaction) => {
+ 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);
+ }
+ });
+ _defineProperty(this, "onRedaction", async event => {
+ 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();
+ }
+ });
+ _defineProperty(this, "onTimelineEvent", (event, room, toStartOfTimeline) => {
+ // Add a synthesized receipt when paginating forward in the timeline
+ if (!toStartOfTimeline) {
+ const sender = event.getSender();
+ if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) {
+ room.addLocalEchoReceipt(sender, event, _read_receipts.ReceiptType.Read);
+ }
+ }
+ this.onEcho(event, toStartOfTimeline ?? false);
+ });
+ _defineProperty(this, "onLocalEcho", event => {
+ this.onEcho(event, false);
+ });
+ _defineProperty(this, "onEcho", async (event, toStartOfTimeline) => {
+ 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);
+ });
+ 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 ?? _client.PendingEventOrdering.Chronological;
+ this.timelineSet = new _eventTimelineSet.EventTimelineSet(this.room, {
+ timelineSupport: true,
+ pendingEvents: true
+ }, this.client, this);
+ this.reEmitter = new _ReEmitter.TypedReEmitter(this);
+ this.reEmitter.reEmit(this.timelineSet, [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]);
+ this.room.on(_event2.MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+ this.room.on(_room.RoomEvent.Redaction, this.onRedaction);
+ this.room.on(_room.RoomEvent.LocalEchoUpdated, this.onLocalEcho);
+ this.timelineSet.on(_room.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);
+ }
+ async fetchRootEvent() {
+ 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.logger.error("Failed to fetch thread root to construct thread with", e);
+ }
+ await this.processEvent(this.rootEvent);
+ }
+ static setServerSideSupport(status) {
+ 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);
+ }
+ }
+ static setServerSideListSupport(status) {
+ Thread.hasServerSideListSupport = status;
+ }
+ static setServerSideFwdPaginationSupport(status) {
+ Thread.hasServerSideFwdPaginationSupport = status;
+ }
+ shouldSendLocalEchoReceipt(sender, event) {
+ const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported;
+ if (recursionSupport === _feature.ServerSupport.Unsupported) {
+ // Normally we add a local receipt, but if we don't have
+ // recursion support, then events may arrive out of order, so we
+ // only create a receipt if it's after our existing receipt.
+ const oldReceiptEventId = this.getReadReceiptForUserId(sender)?.eventId;
+ if (oldReceiptEventId) {
+ const receiptEvent = this.findEventById(oldReceiptEventId);
+ if (receiptEvent && receiptEvent.getTs() > event.getTs()) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ get roomState() {
+ return this.room.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS);
+ }
+ addEventToTimeline(event, toStartOfTimeline) {
+ if (!this.findEventById(event.getId())) {
+ this.timelineSet.addEventToTimeline(event, this.liveTimeline, {
+ toStartOfTimeline,
+ fromCache: false,
+ roomState: this.roomState
+ });
+ this.timeline = this.events;
+ }
+ }
+
+ /**
+ * TEMPORARY. Only call this when MSC3981 is not available, and we have some
+ * late-arriving events to insert, because we recursively found them as part
+ * of populating a thread. When we have MSC3981 we won't need it, because
+ * they will all be supplied by the homeserver in one request, and they will
+ * already be in the right order in that response.
+ * This is a copy of addEventToTimeline above, modified to call
+ * insertEventIntoTimeline so this event is inserted into our best guess of
+ * the right place based on timestamp. (We should be using Sync Order but we
+ * don't have it.)
+ *
+ * @internal
+ */
+ insertEventIntoTimeline(event) {
+ const eventId = event.getId();
+ if (!eventId) {
+ return;
+ }
+ // If the event is already in this thread, bail out
+ if (this.findEventById(eventId)) {
+ return;
+ }
+ this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState);
+
+ // As far as we know, timeline should always be the same as events
+ this.timeline = this.events;
+ }
+ addEvents(events, toStartOfTimeline) {
+ 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.
+ */
+ async addEvent(event, toStartOfTimeline, emit = true) {
+ 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(_event.RelationType.Annotation) || event.isRelation(_event.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 {
+ const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported;
+ if (recursionSupport === _feature.ServerSupport.Unsupported) {
+ this.insertEventIntoTimeline(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();
+ }
+ }
+ async processEvent(event) {
+ 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
+ */
+ processReceipts(receipts = []) {
+ for (const {
+ eventId,
+ receiptType,
+ userId,
+ receipt,
+ synthetic
+ } of receipts) {
+ this.addReceiptToStructure(eventId, receiptType, userId, receipt, synthetic);
+ }
+ }
+ getRootEventBundledRelationship(rootEvent = this.rootEvent) {
+ return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name);
+ }
+ async processRootEvent() {
+ 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(_objectSpread(_objectSpread({}, bundledRelationship.latest_event), {}, {
+ room_id: this.roomId
+ }));
+ this.updatePendingReplyCount();
+ await this.processEvent(this.lastEvent);
+ }
+ }
+ updatePendingReplyCount() {
+ const unfilteredPendingEvents = this.pendingEventOrdering === _client.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.
+ */
+ async resetLiveTimeline(backPaginationToken, forwardPaginationToken) {
+ 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;
+ let oldForward;
+ if (backPaginationToken) {
+ const res = await this.client.createMessagesRequest(this.roomId, backPaginationToken, 1, _eventTimeline.Direction.Forward);
+ newBackward = res.end;
+ }
+ if (forwardPaginationToken) {
+ const res = await this.client.createMessagesRequest(this.roomId, forwardPaginationToken, 1, _eventTimeline.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(_eventTimeline.Direction.Forward) === forwardPaginationToken) {
+ oldLive.setPaginationToken(oldForward ?? null, _eventTimeline.Direction.Forward);
+ }
+ if (backPaginationToken && newLive.getPaginationToken(_eventTimeline.Direction.Backward) === backPaginationToken) {
+ newLive.setPaginationToken(newBackward ?? null, _eventTimeline.Direction.Backward);
+ }
+ }
+ async updateThreadMetadata() {
+ 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, _eventTimeline.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(_room.RoomEvent.TimelineReset, this.room, this.timelineSet, true);
+ } catch (e) {
+ _logger.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
+ async fetchEditsWhereNeeded(...events) {
+ const recursionSupport = this.client.canSupport.get(_feature.Feature.RelationsRecursion) ?? _feature.ServerSupport.Unsupported;
+ if (recursionSupport === _feature.ServerSupport.Unsupported) {
+ return Promise.all(events.filter(isAnEncryptedThreadMessage).map(async event => {
+ try {
+ const relations = await this.client.relations(this.roomId, event.getId(), _event.RelationType.Replace, event.getType(), {
+ limit: 1
+ });
+ if (relations.events.length) {
+ const editEvent = relations.events[0];
+ event.makeReplaced(editEvent);
+ this.insertEventIntoTimeline(editEvent);
+ }
+ } catch (e) {
+ _logger.logger.error("Failed to load edits for encrypted thread event", e);
+ }
+ }));
+ }
+ }
+ setEventMetadata(event) {
+ if (event) {
+ _eventTimeline.EventTimeline.setEventMetadata(event, this.roomState, false);
+ event.setThread(this);
+ }
+ }
+ clearEventMetadata(event) {
+ if (event) {
+ event.setThread(undefined);
+ delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
+ }
+ }
+
+ /**
+ * Finds an event by ID in the current thread
+ */
+ findEventById(eventId) {
+ return this.timelineSet.findEventById(eventId);
+ }
+
+ /**
+ * Return last reply to the thread, if known.
+ */
+ lastReply(matches = () => true) {
+ for (let i = this.timeline.length - 1; i >= 0; i--) {
+ const event = this.timeline[i];
+ if (matches(event)) {
+ return event;
+ }
+ }
+ return null;
+ }
+ get roomId() {
+ 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
+ */
+ get length() {
+ 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.
+ */
+ get replyToEvent() {
+ return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
+ }
+ get events() {
+ return this.liveTimeline.getEvents();
+ }
+ has(eventId) {
+ return this.timelineSet.findEventById(eventId) instanceof _event2.MatrixEvent;
+ }
+ get hasCurrentUserParticipated() {
+ return this._currentUserParticipated;
+ }
+ get liveTimeline() {
+ return this.timelineSet.getLiveTimeline();
+ }
+ getUnfilteredTimelineSet() {
+ return this.timelineSet;
+ }
+ addReceipt(event, synthetic) {
+ 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.
+ */
+ getEventReadUpTo(userId, ignoreSynthesized) {
+ 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.
+ */
+ hasUserReadEvent(userId, eventId) {
+ 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);
+ }
+ setUnread(type, count) {
+ return this.room.setThreadUnreadNotificationCount(this.id, type, count);
+ }
+}
+
+/**
+ * Decide whether an event deserves to have its potential edits fetched.
+ *
+ * @returns true if this event is encrypted and is a message that is part of a
+ * thread - either inside it, or a root.
+ */
+exports.Thread = Thread;
+_defineProperty(Thread, "hasServerSideSupport", FeatureSupport.None);
+_defineProperty(Thread, "hasServerSideListSupport", FeatureSupport.None);
+_defineProperty(Thread, "hasServerSideFwdPaginationSupport", FeatureSupport.None);
+function isAnEncryptedThreadMessage(event) {
+ return event.isEncrypted() && (event.isRelation(THREAD_RELATION_TYPE.name) || event.isThreadRoot);
+}
+const FILTER_RELATED_BY_SENDERS = new _NamespacedValue.ServerControlledNamespacedValue("related_by_senders", "io.element.relation_senders");
+exports.FILTER_RELATED_BY_SENDERS = FILTER_RELATED_BY_SENDERS;
+const FILTER_RELATED_BY_REL_TYPES = new _NamespacedValue.ServerControlledNamespacedValue("related_by_rel_types", "io.element.relation_types");
+exports.FILTER_RELATED_BY_REL_TYPES = FILTER_RELATED_BY_REL_TYPES;
+const THREAD_RELATION_TYPE = new _NamespacedValue.ServerControlledNamespacedValue("m.thread", "io.element.thread");
+exports.THREAD_RELATION_TYPE = THREAD_RELATION_TYPE;
+let ThreadFilterType = /*#__PURE__*/function (ThreadFilterType) {
+ ThreadFilterType[ThreadFilterType["My"] = 0] = "My";
+ ThreadFilterType[ThreadFilterType["All"] = 1] = "All";
+ return ThreadFilterType;
+}({});
+exports.ThreadFilterType = ThreadFilterType;
+function threadFilterTypeToFilter(type) {
+ switch (type) {
+ case ThreadFilterType.My:
+ return "participated";
+ default:
+ return "all";
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js
new file mode 100644
index 0000000000..c1160948ba
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js
@@ -0,0 +1,200 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TypedEventEmitter = exports.EventEmitterEvents = void 0;
+var _events = require("events");
+/*
+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
+/** Events emitted by EventEmitter itself */
+let EventEmitterEvents = /*#__PURE__*/function (EventEmitterEvents) {
+ EventEmitterEvents["NewListener"] = "newListener";
+ EventEmitterEvents["RemoveListener"] = "removeListener";
+ EventEmitterEvents["Error"] = "error";
+ return EventEmitterEvents;
+}({});
+/** Base class for types mapping from event name to the type of listeners to that event */
+/**
+ * The expected type of a listener function for a particular event.
+ *
+ * Type parameters:
+ * * `E` - List of all events emitted by the `TypedEventEmitter`. Normally an enum type.
+ * * `A` - A type providing mappings from event names to listener types.
+ * * `T` - The name of the actual event that this listener is for. Normally one of the types in `E` or
+ * {@link EventEmitterEvents}.
+ */
+exports.EventEmitterEvents = EventEmitterEvents;
+/**
+ * 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.
+ *
+ * Type parameters:
+ * * `Events` - List of all events emitted by this `TypedEventEmitter`. Normally an enum type.
+ * * `Arguments` - A {@link ListenerMap} type providing mappings from event names to listener types.
+ * * `SuperclassArguments` - TODO: not really sure. Alternative listener mappings, I think? But only honoured for `.emit`?
+ */
+class TypedEventEmitter extends _events.EventEmitter {
+ /**
+ * Alias for {@link TypedEventEmitter#on}.
+ */
+ addListener(event, listener) {
+ return super.addListener(event, listener);
+ }
+
+ /**
+ * Synchronously calls each of the listeners registered for the event named
+ * `event`, in the order they were registered, passing the supplied arguments
+ * to each.
+ *
+ * @param event - The name of the event to emit
+ * @param args - Arguments to pass to the listener
+ * @returns `true` if the event had listeners, `false` otherwise.
+ */
+
+ emit(event, ...args) {
+ return super.emit(event, ...args);
+ }
+
+ /**
+ * Returns the number of listeners listening to the event named `event`.
+ *
+ * @param event - The name of the event being listened for
+ */
+ listenerCount(event) {
+ return super.listenerCount(event);
+ }
+
+ /**
+ * Returns a copy of the array of listeners for the event named `event`.
+ */
+ listeners(event) {
+ return super.listeners(event);
+ }
+
+ /**
+ * Alias for {@link TypedEventEmitter#removeListener}
+ */
+ off(event, listener) {
+ return super.off(event, listener);
+ }
+
+ /**
+ * Adds the `listener` function to the end of the listeners array for the
+ * event named `event`.
+ *
+ * No checks are made to see if the `listener` has already been added. Multiple calls
+ * passing the same combination of `event` and `listener` will result in the `listener`
+ * being added, and called, multiple times.
+ *
+ * By default, event listeners are invoked in the order they are added. The
+ * {@link TypedEventEmitter#prependListener} method can be used as an alternative to add the
+ * event listener to the beginning of the listeners array.
+ *
+ * @param event - The name of the event.
+ * @param listener - The callback function
+ *
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ on(event, listener) {
+ return super.on(event, listener);
+ }
+
+ /**
+ * Adds a **one-time** `listener` function for the event named `event`. The
+ * next time `event` is triggered, this listener is removed and then invoked.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * By default, event listeners are invoked in the order they are added.
+ * The {@link TypedEventEmitter#prependOnceListener} method can be used as an alternative to add the
+ * event listener to the beginning of the listeners array.
+ *
+ * @param event - The name of the event.
+ * @param listener - The callback function
+ *
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ once(event, listener) {
+ return super.once(event, listener);
+ }
+
+ /**
+ * Adds the `listener` function to the _beginning_ of the listeners array for the
+ * event named `event`.
+ *
+ * No checks are made to see if the `listener` has already been added. Multiple calls
+ * passing the same combination of `event` and `listener` will result in the `listener`
+ * being added, and called, multiple times.
+ *
+ * @param event - The name of the event.
+ * @param listener - The callback function
+ *
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ prependListener(event, listener) {
+ return super.prependListener(event, listener);
+ }
+
+ /**
+ * Adds a **one-time**`listener` function for the event named `event` to the _beginning_ of the listeners array.
+ * The next time `event` is triggered, this listener is removed, and then invoked.
+ *
+ * @param event - The name of the event.
+ * @param listener - The callback function
+ *
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ prependOnceListener(event, listener) {
+ return super.prependOnceListener(event, listener);
+ }
+
+ /**
+ * Removes all listeners, or those of the specified `event`.
+ *
+ * It is bad practice to remove listeners added elsewhere in the code,
+ * particularly when the `EventEmitter` instance was created by some other
+ * component or module (e.g. sockets or file streams).
+ *
+ * @param event - The name of the event. If undefined, all listeners everywhere are removed.
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ removeAllListeners(event) {
+ return super.removeAllListeners(event);
+ }
+
+ /**
+ * Removes the specified `listener` from the listener array for the event named `event`.
+ *
+ * @returns a reference to the `EventEmitter`, so that calls can be chained.
+ */
+ removeListener(event, listener) {
+ return super.removeListener(event, listener);
+ }
+
+ /**
+ * Returns a copy of the array of listeners for the event named `eventName`,
+ * including any wrappers (such as those created by `.once()`).
+ */
+ rawListeners(event) {
+ return super.rawListeners(event);
+ }
+}
+exports.TypedEventEmitter = TypedEventEmitter; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js b/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js
new file mode 100644
index 0000000000..bc941a8bb3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js
@@ -0,0 +1,211 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UserEvent = exports.User = void 0;
+var _typedEventEmitter = require("./typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let UserEvent = /*#__PURE__*/function (UserEvent) {
+ UserEvent["DisplayName"] = "User.displayName";
+ UserEvent["AvatarUrl"] = "User.avatarUrl";
+ UserEvent["Presence"] = "User.presence";
+ UserEvent["CurrentlyActive"] = "User.currentlyActive";
+ UserEvent["LastPresenceTs"] = "User.lastPresenceTs";
+ return UserEvent;
+}({});
+exports.UserEvent = UserEvent;
+class User extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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.
+ */
+ constructor(userId) {
+ super();
+ this.userId = userId;
+ _defineProperty(this, "modified", -1);
+ /**
+ * The 'displayname' of the user if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "displayName", void 0);
+ _defineProperty(this, "rawDisplayName", void 0);
+ /**
+ * The 'avatar_url' of the user if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "avatarUrl", void 0);
+ /**
+ * The presence status message if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "presenceStatusMsg", void 0);
+ /**
+ * The presence enum if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "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
+ */
+ _defineProperty(this, "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
+ */
+ _defineProperty(this, "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
+ */
+ _defineProperty(this, "currentlyActive", false);
+ /**
+ * The events describing this user.
+ * @privateRemarks
+ * Should be read-only
+ */
+ _defineProperty(this, "events", {});
+ 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}
+ */
+ setPresenceEvent(event) {
+ if (event.getType() !== "m.presence") {
+ return;
+ }
+ const firstFire = this.events.presence === null;
+ this.events.presence = event;
+ const eventsToFire = [];
+ 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.
+ */
+ setDisplayName(name) {
+ 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.
+ */
+ setRawDisplayName(name) {
+ 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.
+ */
+ setAvatarUrl(url) {
+ const oldUrl = this.avatarUrl;
+ this.avatarUrl = url;
+ if (url !== oldUrl) {
+ this.updateModifiedTime();
+ }
+ }
+
+ /**
+ * Update the last modified time to the current time.
+ */
+ updateModifiedTime() {
+ 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
+ */
+ getLastModifiedTime() {
+ 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
+ */
+ getLastActiveTs() {
+ return this.lastPresenceTs - this.lastActiveAgo;
+ }
+}
+exports.User = User; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js
new file mode 100644
index 0000000000..bc8e173bf4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js
@@ -0,0 +1,676 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PushProcessor = void 0;
+var _utils = require("./utils");
+var _logger = require("./logger");
+var _PushRules = require("./@types/PushRules");
+var _event = require("./@types/event");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const RULEKINDS_IN_ORDER = [_PushRules.PushRuleKind.Override, _PushRules.PushRuleKind.ContentSpecific, _PushRules.PushRuleKind.RoomSpecific, _PushRules.PushRuleKind.SenderSpecific, _PushRules.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 = [{
+ // For homeservers which don't support MSC2153 yet
+ rule_id: ".m.rule.reaction",
+ default: true,
+ enabled: true,
+ conditions: [{
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "type",
+ pattern: "m.reaction"
+ }],
+ actions: [_PushRules.PushRuleActionName.DontNotify]
+}, {
+ rule_id: _PushRules.RuleId.IsUserMention,
+ default: true,
+ enabled: true,
+ conditions: [{
+ kind: _PushRules.ConditionKind.EventPropertyContains,
+ key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids",
+ value: "" // The user ID is dynamically added in rewriteDefaultRules.
+ }],
+
+ actions: [_PushRules.PushRuleActionName.Notify, {
+ set_tweak: _PushRules.TweakName.Highlight
+ }]
+}, {
+ rule_id: _PushRules.RuleId.IsRoomMention,
+ default: true,
+ enabled: true,
+ conditions: [{
+ kind: _PushRules.ConditionKind.EventPropertyIs,
+ key: "content.org\\.matrix\\.msc3952\\.mentions.room",
+ value: true
+ }, {
+ kind: _PushRules.ConditionKind.SenderNotificationPermission,
+ key: "room"
+ }],
+ actions: [_PushRules.PushRuleActionName.Notify, {
+ set_tweak: _PushRules.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: _PushRules.ConditionKind.EventMatch,
+ key: "type",
+ pattern: _event.EventType.RoomServerAcl
+ }, {
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "state_key",
+ pattern: ""
+ }],
+ actions: []
+}];
+const DEFAULT_UNDERRIDE_RULES = [{
+ // For homeservers which don't support MSC3914 yet
+ rule_id: ".org.matrix.msc3914.rule.room.call",
+ default: true,
+ enabled: true,
+ conditions: [{
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "type",
+ pattern: "org.matrix.msc3401.call"
+ }, {
+ kind: _PushRules.ConditionKind.CallStarted
+ }],
+ actions: [_PushRules.PushRuleActionName.Notify, {
+ set_tweak: _PushRules.TweakName.Sound,
+ value: "default"
+ }]
+}];
+class PushProcessor {
+ /**
+ * Construct a Push Processor.
+ * @param client - The Matrix client object to use
+ */
+ constructor(client) {
+ this.client = client;
+ /**
+ * Maps the original key from the push rules to a list of property names
+ * after unescaping.
+ */
+ _defineProperty(this, "parsedKeys", new Map());
+ }
+ /**
+ * 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
+ */
+ static actionListToActionsObject(actionList) {
+ const actionObj = {
+ notify: false,
+ tweaks: {}
+ };
+ for (const action of actionList) {
+ if (action === _PushRules.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
+ */
+ static rewriteDefaultRules(incomingRules, userId = undefined) {
+ let newRules = 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 = {};
+ if (!newRules.global) newRules.global = {};
+ 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;
+ if (originalOverride.rule_id === _PushRules.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.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.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.
+ */
+ updateCachedPushRuleKeys(newRules) {
+ // These lines are mostly to make the tests happy. We shouldn't run into these
+ // properties missing in practice.
+ if (!newRules) newRules = {};
+ if (!newRules.global) newRules.global = {};
+ 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 !== _PushRules.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));
+ }
+ // $glob: RegExp
+
+ matchingRuleFromKindSet(ev, kindset) {
+ 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 _objectSpread(_objectSpread({}, rule), {}, {
+ kind
+ });
+ }
+ }
+ }
+ return null;
+ }
+ templateRuleToRaw(kind, tprule) {
+ const rawrule = {
+ rule_id: tprule.rule_id,
+ actions: tprule.actions,
+ conditions: []
+ };
+ switch (kind) {
+ case _PushRules.PushRuleKind.Underride:
+ case _PushRules.PushRuleKind.Override:
+ rawrule.conditions = tprule.conditions;
+ break;
+ case _PushRules.PushRuleKind.RoomSpecific:
+ if (!tprule.rule_id) {
+ return null;
+ }
+ rawrule.conditions.push({
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "room_id",
+ value: tprule.rule_id
+ });
+ break;
+ case _PushRules.PushRuleKind.SenderSpecific:
+ if (!tprule.rule_id) {
+ return null;
+ }
+ rawrule.conditions.push({
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "user_id",
+ value: tprule.rule_id
+ });
+ break;
+ case _PushRules.PushRuleKind.ContentSpecific:
+ if (!tprule.pattern) {
+ return null;
+ }
+ rawrule.conditions.push({
+ kind: _PushRules.ConditionKind.EventMatch,
+ key: "content.body",
+ pattern: tprule.pattern
+ });
+ break;
+ }
+ return rawrule;
+ }
+ eventFulfillsCondition(cond, ev) {
+ switch (cond.kind) {
+ case _PushRules.ConditionKind.EventMatch:
+ return this.eventFulfillsEventMatchCondition(cond, ev);
+ case _PushRules.ConditionKind.EventPropertyIs:
+ return this.eventFulfillsEventPropertyIsCondition(cond, ev);
+ case _PushRules.ConditionKind.EventPropertyContains:
+ return this.eventFulfillsEventPropertyContains(cond, ev);
+ case _PushRules.ConditionKind.ContainsDisplayName:
+ return this.eventFulfillsDisplayNameCondition(cond, ev);
+ case _PushRules.ConditionKind.RoomMemberCount:
+ return this.eventFulfillsRoomMemberCountCondition(cond, ev);
+ case _PushRules.ConditionKind.SenderNotificationPermission:
+ return this.eventFulfillsSenderNotifPermCondition(cond, ev);
+ case _PushRules.ConditionKind.CallStarted:
+ case _PushRules.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;
+ }
+ eventFulfillsSenderNotifPermCondition(cond, ev) {
+ 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());
+ }
+ eventFulfillsRoomMemberCountCondition(cond, ev) {
+ 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;
+ }
+ }
+ eventFulfillsDisplayNameCondition(cond, ev) {
+ 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)" + (0, _utils.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.
+ */
+ eventFulfillsEventMatchCondition(cond, ev) {
+ 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.
+ */
+ eventFulfillsEventPropertyIsCondition(cond, ev) {
+ 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.
+ */
+ eventFulfillsEventPropertyContains(cond, ev) {
+ 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);
+ }
+ eventFulfillsCallStartedCondition(_cond, ev) {
+ // 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"] || (0, _utils.deepCompare)(ev.getPrevContent(), {}));
+ }
+ createCachedRegex(prefix, glob, suffix) {
+ if (PushProcessor.cachedGlobToRegex[glob]) {
+ return PushProcessor.cachedGlobToRegex[glob];
+ }
+ PushProcessor.cachedGlobToRegex[glob] = new RegExp(prefix + (0, _utils.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
+ */
+ static partsForDottedKey(str) {
+ 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.
+ */
+ valueForDottedKey(key, ev) {
+ // 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;
+
+ // 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 ((0, _utils.isNullOrUndefined)(val)) {
+ return undefined;
+ }
+ const thisPart = parts[currentIndex];
+ val = val[thisPart];
+ }
+ return val;
+ }
+ matchingRuleForEventWithRulesets(ev, rulesets) {
+ if (!rulesets) {
+ return null;
+ }
+ if (ev.getSender() === this.client.getSafeUserId()) {
+ return null;
+ }
+ return this.matchingRuleFromKindSet(ev, rulesets.global);
+ }
+ pushActionsForEventAndRulesets(ev, rulesets) {
+ const rule = this.matchingRuleForEventWithRulesets(ev, rulesets);
+ if (!rule) {
+ return {};
+ }
+ 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 == _PushRules.PushRuleKind.ContentSpecific;
+ }
+ return {
+ actions: actionObj,
+ rule
+ };
+ }
+ ruleMatchesEvent(rule, ev) {
+ // 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 === _PushRules.RuleId.ContainsUserName || rule.rule_id === _PushRules.RuleId.ContainsDisplayName || rule.rule_id === _PushRules.RuleId.AtRoomNotification)) {
+ return false;
+ }
+ return !rule.conditions?.some(cond => !this.eventFulfillsCondition(cond, ev));
+ }
+
+ /**
+ * Get the user's push actions for the given event
+ */
+ actionsForEvent(ev) {
+ const {
+ actions
+ } = this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
+ return actions || {};
+ }
+ actionsAndRuleForEvent(ev) {
+ 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
+ */
+ getPushRuleById(ruleId) {
+ 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
+ */
+ getPushRuleAndKindById(ruleId) {
+ for (const scope of ["global"]) {
+ 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;
+ }
+}
+exports.PushProcessor = PushProcessor;
+_defineProperty(PushProcessor, "cachedGlobToRegex", {}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js b/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js
new file mode 100644
index 0000000000..2e2032a10a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js
@@ -0,0 +1,44 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.randomLowercaseString = randomLowercaseString;
+exports.randomString = randomString;
+exports.randomUppercaseString = randomUppercaseString;
+/*
+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";
+function randomString(len) {
+ return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS);
+}
+function randomLowercaseString(len) {
+ return randomStringFrom(len, LOWERCASE);
+}
+function randomUppercaseString(len) {
+ return randomStringFrom(len, UPPERCASE);
+}
+function randomStringFrom(len, chars) {
+ let ret = "";
+ for (let i = 0; i < len; ++i) {
+ ret += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return ret;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js b/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js
new file mode 100644
index 0000000000..56b9ef0a1c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js
@@ -0,0 +1,179 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.clearTimeout = clearTimeout;
+exports.setTimeout = setTimeout;
+var _logger = require("./logger");
+/*
+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.
+ */
+
+// 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;
+// a sorted list of the callbacks to be run.
+// each is an object with keys [runAt, func, params, key].
+const callbackList = [];
+
+// var debuglog = logger.log.bind(logger);
+/* istanbul ignore next */
+const debuglog = function (...params) {};
+
+/**
+ * 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.
+ */
+function setTimeout(func, delayMs, ...params) {
+ 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
+ */
+function clearTimeout(key) {
+ if (callbackList.length === 0) {
+ return;
+ }
+
+ // remove the element from the list
+ let i;
+ 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() {
+ if (realCallbackKey) {
+ global.clearTimeout(realCallbackKey);
+ }
+ 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() {
+ const timestamp = Date.now();
+ debuglog("runCallbacks: now:", timestamp);
+
+ // get the list of things to call
+ const callbacksToRun = [];
+ // 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.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(array, func) {
+ // 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;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js b/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js
new file mode 100644
index 0000000000..f5b42f750b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js
@@ -0,0 +1,169 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ReceiptAccumulator = void 0;
+var _event = require("./@types/event");
+var _utils = require("./utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Summarises the read receipts within a room. Used by the sync accumulator.
+ *
+ * Given receipts for users, picks the most recently-received one and provides
+ * the results in a new fake receipt event returned from
+ * buildAccumulatedReceiptEvent().
+ *
+ * Handles unthreaded receipts and receipts in each thread separately, so the
+ * returned event contains the most recently received unthreaded receipt, and
+ * the most recently received receipt in each thread.
+ */
+class ReceiptAccumulator {
+ constructor() {
+ /** user_id -\> most-recently-received unthreaded receipt */
+ _defineProperty(this, "unthreadedReadReceipts", new Map());
+ /** thread_id -\> user_id -\> most-recently-received receipt for this thread */
+ _defineProperty(this, "threadedReadReceipts", new _utils.MapWithDefault(() => new Map()));
+ }
+ /**
+ * Provide an unthreaded receipt for this user. Overwrites any other
+ * unthreaded receipt we have for this user.
+ */
+ setUnthreaded(userId, receipt) {
+ this.unthreadedReadReceipts.set(userId, receipt);
+ }
+
+ /**
+ * Provide a receipt for this user in this thread. Overwrites any other
+ * receipt we have for this user in this thread.
+ */
+ setThreaded(threadId, userId, receipt) {
+ this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt);
+ }
+
+ /**
+ * @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
+ * most recently-received unthreaded receipts for each user.
+ */
+ allUnthreaded() {
+ return this.unthreadedReadReceipts.entries();
+ }
+
+ /**
+ * @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
+ * most recently-received threaded receipts for each user, in all
+ * threads.
+ */
+ *allThreaded() {
+ for (const receiptsForThread of this.threadedReadReceipts.values()) {
+ for (const e of receiptsForThread.entries()) {
+ yield e;
+ }
+ }
+ }
+
+ /**
+ * Given a list of ephemeral events, find the receipts and store the
+ * relevant ones to be returned later from buildAccumulatedReceiptEvent().
+ */
+ consumeEphemeralEvents(events) {
+ events?.forEach(e => {
+ if (e.type !== _event.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(e.content[eventId]).forEach(([key, value]) => {
+ if (!(0, _utils.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,
+ eventId
+ };
+
+ // In a world that supports threads, read receipts normally have
+ // a `thread_id` which is either the thread they belong in or
+ // `MAIN_ROOM_TIMELINE`, so we normally use `setThreaded(...)`
+ // here. The `MAIN_ROOM_TIMELINE` is just treated as another
+ // thread.
+ //
+ // We still encounter read receipts that are "unthreaded"
+ // (missing the `thread_id` property). These come from clients
+ // that don't support threads, and from threaded clients that
+ // are doing a "Mark room as read" operation. Unthreaded
+ // receipts mark everything "before" them as read, in all
+ // threads, where "before" means in Sync Order i.e. the order
+ // the events were received from the homeserver in a sync.
+ // [Note: we have some bugs where we use timestamp order instead
+ // of Sync Order, because we don't correctly remember the Sync
+ // Order. See #3325.]
+ //
+ // Calling the wrong method will cause incorrect behavior like
+ // messages re-appearing as "new" when you already read them
+ // previously.
+ if (!data.thread_id) {
+ this.setUnthreaded(userId, receipt);
+ } else {
+ this.setThreaded(data.thread_id, userId, receipt);
+ }
+ }
+ });
+ });
+ });
+ }
+
+ /**
+ * Build a receipt event that contains all relevant information for this
+ * room, taking the most recently received receipt for each user in an
+ * unthreaded context, and in each thread.
+ */
+ buildAccumulatedReceiptEvent(roomId) {
+ const receiptEvent = {
+ type: _event.EventType.Receipt,
+ room_id: roomId,
+ content: {
+ // $event_id: { "m.read": { $user_id: $json } }
+ }
+ };
+ const receiptEventContent = new _utils.MapWithDefault(() => new _utils.MapWithDefault(() => new Map()));
+ for (const [userId, receiptData] of this.allUnthreaded()) {
+ receiptEventContent.getOrCreate(receiptData.eventId).getOrCreate(receiptData.type).set(userId, receiptData.data);
+ }
+ for (const [userId, receiptData] of this.allThreaded()) {
+ receiptEventContent.getOrCreate(receiptData.eventId).getOrCreate(receiptData.type).set(userId, receiptData.data);
+ }
+ receiptEvent.content = (0, _utils.recursiveMapToObject)(receiptEventContent);
+ return receiptEventContent.size > 0 ? receiptEvent : null;
+ }
+}
+exports.ReceiptAccumulator = ReceiptAccumulator; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js
new file mode 100644
index 0000000000..50da6ab883
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js
@@ -0,0 +1,240 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MSC3906Rendezvous = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _ = require(".");
+var _client = require("../client");
+var _feature = require("../feature");
+var _logger = require("../logger");
+var _utils = require("../utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+var PayloadType = /*#__PURE__*/function (PayloadType) {
+ PayloadType["Start"] = "m.login.start";
+ PayloadType["Finish"] = "m.login.finish";
+ PayloadType["Progress"] = "m.login.progress";
+ return PayloadType;
+}(PayloadType || {});
+var Outcome = /*#__PURE__*/function (Outcome) {
+ Outcome["Success"] = "success";
+ Outcome["Failure"] = "failure";
+ Outcome["Verified"] = "verified";
+ Outcome["Declined"] = "declined";
+ Outcome["Unsupported"] = "unsupported";
+ return Outcome;
+}(Outcome || {});
+const LOGIN_TOKEN_PROTOCOL = new _matrixEventsSdk.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.
+ */
+class MSC3906Rendezvous {
+ /**
+ * @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
+ */
+ constructor(channel, client, onFailure) {
+ this.channel = channel;
+ this.client = client;
+ this.onFailure = onFailure;
+ _defineProperty(this, "newDeviceId", void 0);
+ _defineProperty(this, "newDeviceKey", void 0);
+ _defineProperty(this, "ourIntent", _.RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE);
+ _defineProperty(this, "_code", void 0);
+ }
+
+ /**
+ * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
+ */
+ get code() {
+ return this._code;
+ }
+
+ /**
+ * Generate the code including doing partial set up of the channel where required.
+ */
+ async generateCode() {
+ if (this._code) {
+ return;
+ }
+ this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent));
+ }
+ async startAfterShowingCode() {
+ const checksum = await this.channel.connect();
+ _logger.logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
+
+ // in r1 of MSC3882 the availability is exposed as a capability
+ const capabilities = await this.client.getCapabilities();
+ // in r0 of MSC3882 the availability is exposed as a feature flag
+ const features = await (0, _feature.buildFeatureSupportMap)(await this.client.getVersions());
+ const capability = _client.UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities);
+
+ // determine available protocols
+ if (!capability?.enabled && features.get(_feature.Feature.LoginTokenRequest) === _feature.ServerSupport.Unsupported) {
+ _logger.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.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;
+ }
+ async receive() {
+ return await this.channel.receive();
+ }
+ async send(payload) {
+ await this.channel.send(payload);
+ }
+ async declineLoginOnExistingDevice() {
+ _logger.logger.info("User declined sign in");
+ await this.send({
+ type: PayloadType.Finish,
+ outcome: Outcome.Declined
+ });
+ }
+ async approveLoginOnExistingDevice(loginToken) {
+ // eslint-disable-next-line camelcase
+ await this.send({
+ type: PayloadType.Progress,
+ login_token: loginToken,
+ homeserver: this.client.baseUrl
+ });
+ _logger.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;
+ }
+ async verifyAndCrossSignDevice(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.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
+ */
+ async verifyNewDeviceOnExistingDevice(timeout = 10 * 1000) {
+ if (!this.newDeviceId) {
+ throw new Error("No new device to sign");
+ }
+ if (!this.newDeviceKey) {
+ _logger.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.logger.info("Going to wait for new device to be online");
+ await (0, _utils.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");
+ }
+ async cancel(reason) {
+ this.onFailure?.(reason);
+ await this.channel.cancel(reason);
+ }
+ async close() {
+ await this.channel.close();
+ }
+}
+exports.MSC3906Rendezvous = MSC3906Rendezvous; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js
new file mode 100644
index 0000000000..5190ebbb76
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js
@@ -0,0 +1,29 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RendezvousError = void 0;
+/*
+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.
+*/
+
+class RendezvousError extends Error {
+ constructor(message, code) {
+ super(message);
+ this.code = code;
+ }
+}
+exports.RendezvousError = RendezvousError; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js
new file mode 100644
index 0000000000..ee6a9b987f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js
@@ -0,0 +1,36 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RendezvousFailureReason = void 0;
+/*
+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 RendezvousFailureReason = /*#__PURE__*/function (RendezvousFailureReason) {
+ RendezvousFailureReason["UserDeclined"] = "user_declined";
+ RendezvousFailureReason["OtherDeviceNotSignedIn"] = "other_device_not_signed_in";
+ RendezvousFailureReason["OtherDeviceAlreadySignedIn"] = "other_device_already_signed_in";
+ RendezvousFailureReason["Unknown"] = "unknown";
+ RendezvousFailureReason["Expired"] = "expired";
+ RendezvousFailureReason["UserCancelled"] = "user_cancelled";
+ RendezvousFailureReason["InvalidCode"] = "invalid_code";
+ RendezvousFailureReason["UnsupportedAlgorithm"] = "unsupported_algorithm";
+ RendezvousFailureReason["DataMismatch"] = "data_mismatch";
+ RendezvousFailureReason["UnsupportedTransport"] = "unsupported_transport";
+ RendezvousFailureReason["HomeserverLacksSupport"] = "homeserver_lacks_support";
+ return RendezvousFailureReason;
+}({});
+exports.RendezvousFailureReason = RendezvousFailureReason; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js
new file mode 100644
index 0000000000..5393ac57b9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js
@@ -0,0 +1,27 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RendezvousIntent = void 0;
+/*
+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 RendezvousIntent = /*#__PURE__*/function (RendezvousIntent) {
+ RendezvousIntent["LOGIN_ON_NEW_DEVICE"] = "login.start";
+ RendezvousIntent["RECIPROCATE_LOGIN_ON_EXISTING_DEVICE"] = "login.reciprocate";
+ return RendezvousIntent;
+}({});
+exports.RendezvousIntent = RendezvousIntent; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js
new file mode 100644
index 0000000000..3c9e5793bc
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js
@@ -0,0 +1,194 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MSC3903ECDHv2RendezvousChannel = void 0;
+var _ = require("..");
+var _olmlib = require("../../crypto/olmlib");
+var _crypto = require("../../crypto/crypto");
+var _SASDecimal = require("../../crypto/verification/SASDecimal");
+var _NamespacedValue = require("../../NamespacedValue");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const ECDH_V2 = new _NamespacedValue.UnstableValue("m.rendezvous.v2.curve25519-aes-sha256", "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256");
+async function importKey(key) {
+ if (!_crypto.subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+ const imported = _crypto.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.
+ */
+class MSC3903ECDHv2RendezvousChannel {
+ constructor(transport, theirPublicKey, onFailure) {
+ this.transport = transport;
+ this.theirPublicKey = theirPublicKey;
+ this.onFailure = onFailure;
+ _defineProperty(this, "olmSAS", void 0);
+ _defineProperty(this, "ourPublicKey", void 0);
+ _defineProperty(this, "aesKey", void 0);
+ _defineProperty(this, "connected", false);
+ this.olmSAS = new global.Olm.SAS();
+ this.ourPublicKey = (0, _olmlib.decodeBase64)(this.olmSAS.get_pubkey());
+ }
+ async generateCode(intent) {
+ if (this.transport.ready) {
+ throw new Error("Code already generated");
+ }
+ await this.transport.send({
+ algorithm: ECDH_V2.name
+ });
+ const rendezvous = {
+ rendezvous: {
+ algorithm: ECDH_V2.name,
+ key: (0, _olmlib.encodeUnpaddedBase64)(this.ourPublicKey),
+ transport: await this.transport.details()
+ },
+ intent
+ };
+ return rendezvous;
+ }
+ async connect() {
+ 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;
+ const {
+ key,
+ algorithm
+ } = res;
+ if (!algorithm || !ECDH_V2.matches(algorithm) || !key) {
+ throw new _.RendezvousError("Unsupported algorithm: " + algorithm, _.RendezvousFailureReason.UnsupportedAlgorithm);
+ }
+ this.theirPublicKey = (0, _olmlib.decodeBase64)(key);
+ } else {
+ // send our public key unencrypted
+ await this.transport.send({
+ algorithm: ECDH_V2.name,
+ key: (0, _olmlib.encodeUnpaddedBase64)(this.ourPublicKey)
+ });
+ }
+ this.connected = true;
+ this.olmSAS.set_their_key((0, _olmlib.encodeUnpaddedBase64)(this.theirPublicKey));
+ const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey;
+ const recipientKey = isInitiator ? this.theirPublicKey : this.ourPublicKey;
+ let aesInfo = ECDH_V2.name;
+ aesInfo += `|${(0, _olmlib.encodeUnpaddedBase64)(initiatorKey)}`;
+ aesInfo += `|${(0, _olmlib.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 (0, _SASDecimal.generateDecimalSas)(Array.from(rawChecksum)).join("-");
+ }
+ async encrypt(data) {
+ if (!_crypto.subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+ const iv = new Uint8Array(32);
+ _crypto.crypto.getRandomValues(iv);
+ const encodedData = new _crypto.TextEncoder().encode(JSON.stringify(data));
+ const ciphertext = await _crypto.subtleCrypto.encrypt({
+ name: "AES-GCM",
+ iv,
+ tagLength: 128
+ }, this.aesKey, encodedData);
+ return {
+ iv: (0, _olmlib.encodeUnpaddedBase64)(iv),
+ ciphertext: (0, _olmlib.encodeUnpaddedBase64)(ciphertext)
+ };
+ }
+ async send(payload) {
+ 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));
+ }
+ async decrypt({
+ iv,
+ ciphertext
+ }) {
+ if (!ciphertext || !iv) {
+ throw new Error("Missing ciphertext and/or iv");
+ }
+ const ciphertextBytes = (0, _olmlib.decodeBase64)(ciphertext);
+ if (!_crypto.subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+ const plaintext = await _crypto.subtleCrypto.decrypt({
+ name: "AES-GCM",
+ iv: (0, _olmlib.decodeBase64)(iv),
+ tagLength: 128
+ }, this.aesKey, ciphertextBytes);
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext)));
+ }
+ async receive() {
+ 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;
+ if (data.ciphertext && data.iv) {
+ return this.decrypt(data);
+ }
+ throw new Error("Data received but no ciphertext");
+ }
+ async close() {
+ if (this.olmSAS) {
+ this.olmSAS.free();
+ this.olmSAS = undefined;
+ }
+ }
+ async cancel(reason) {
+ try {
+ await this.transport.cancel(reason);
+ } finally {
+ await this.close();
+ }
+ }
+}
+exports.MSC3903ECDHv2RendezvousChannel = MSC3903ECDHv2RendezvousChannel; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js
new file mode 100644
index 0000000000..e2d30513fd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js
@@ -0,0 +1,16 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _MSC3903ECDHv2RendezvousChannel = require("./MSC3903ECDHv2RendezvousChannel");
+Object.keys(_MSC3903ECDHv2RendezvousChannel).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MSC3903ECDHv2RendezvousChannel[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _MSC3903ECDHv2RendezvousChannel[key];
+ }
+ });
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js
new file mode 100644
index 0000000000..141c9da4fc
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js
@@ -0,0 +1,82 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _MSC3906Rendezvous = require("./MSC3906Rendezvous");
+Object.keys(_MSC3906Rendezvous).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MSC3906Rendezvous[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _MSC3906Rendezvous[key];
+ }
+ });
+});
+var _RendezvousChannel = require("./RendezvousChannel");
+Object.keys(_RendezvousChannel).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousChannel[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousChannel[key];
+ }
+ });
+});
+var _RendezvousCode = require("./RendezvousCode");
+Object.keys(_RendezvousCode).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousCode[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousCode[key];
+ }
+ });
+});
+var _RendezvousError = require("./RendezvousError");
+Object.keys(_RendezvousError).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousError[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousError[key];
+ }
+ });
+});
+var _RendezvousFailureReason = require("./RendezvousFailureReason");
+Object.keys(_RendezvousFailureReason).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousFailureReason[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousFailureReason[key];
+ }
+ });
+});
+var _RendezvousIntent = require("./RendezvousIntent");
+Object.keys(_RendezvousIntent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousIntent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousIntent[key];
+ }
+ });
+});
+var _RendezvousTransport = require("./RendezvousTransport");
+Object.keys(_RendezvousTransport).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _RendezvousTransport[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _RendezvousTransport[key];
+ }
+ });
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js
new file mode 100644
index 0000000000..6347229aca
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js
@@ -0,0 +1,176 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MSC3886SimpleHttpRendezvousTransport = void 0;
+var _matrixEventsSdk = require("matrix-events-sdk");
+var _logger = require("../../logger");
+var _utils = require("../../utils");
+var _ = require("..");
+var _httpApi = require("../../http-api");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const TYPE = new _matrixEventsSdk.UnstableValue("http.v1", "org.matrix.msc3886.http.v1");
+/**
+ * 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.
+ */
+class MSC3886SimpleHttpRendezvousTransport {
+ constructor({
+ onFailure,
+ client,
+ fallbackRzServer,
+ fetchFn
+ }) {
+ _defineProperty(this, "uri", void 0);
+ _defineProperty(this, "etag", void 0);
+ _defineProperty(this, "expiresAt", void 0);
+ _defineProperty(this, "client", void 0);
+ _defineProperty(this, "fallbackRzServer", void 0);
+ _defineProperty(this, "fetchFn", void 0);
+ _defineProperty(this, "cancelled", false);
+ _defineProperty(this, "_ready", false);
+ _defineProperty(this, "onFailure", void 0);
+ this.fetchFn = fetchFn;
+ this.onFailure = onFailure;
+ this.client = client;
+ this.fallbackRzServer = fallbackRzServer;
+ }
+ get ready() {
+ return this._ready;
+ }
+ async details() {
+ if (!this.uri) {
+ throw new Error("Rendezvous not set up");
+ }
+ return {
+ type: TYPE.name,
+ uri: this.uri
+ };
+ }
+ fetch(resource, options) {
+ if (this.fetchFn) {
+ return this.fetchFn(resource, options);
+ }
+ return global.fetch(resource, options);
+ }
+ async getPostEndpoint() {
+ try {
+ if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) {
+ return `${this.client.baseUrl}${_httpApi.ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`;
+ }
+ } catch (err) {
+ _logger.logger.warn("Failed to get unstable features", err);
+ }
+ return this.fallbackRzServer;
+ }
+ async send(data) {
+ 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 = {
+ "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;
+ }
+ }
+ async receive() {
+ 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 = {};
+ 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 (0, _utils.sleep)(1000);
+ }
+ }
+ async cancel(reason) {
+ 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.logger.warn(e);
+ }
+ }
+ }
+}
+exports.MSC3886SimpleHttpRendezvousTransport = MSC3886SimpleHttpRendezvousTransport; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js
new file mode 100644
index 0000000000..a1a4c56c64
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js
@@ -0,0 +1,16 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _MSC3886SimpleHttpRendezvousTransport = require("./MSC3886SimpleHttpRendezvousTransport");
+Object.keys(_MSC3886SimpleHttpRendezvousTransport).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _MSC3886SimpleHttpRendezvousTransport[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function () {
+ return _MSC3886SimpleHttpRendezvousTransport[key];
+ }
+ });
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js b/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js
new file mode 100644
index 0000000000..3e7c0c0094
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js
@@ -0,0 +1,133 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomHierarchy = void 0;
+var _event = require("./@types/event");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class RoomHierarchy {
+ /**
+ * 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.
+ */
+ constructor(root, pageSize, maxDepth, suggestedOnly = false) {
+ this.root = root;
+ this.pageSize = pageSize;
+ this.maxDepth = maxDepth;
+ this.suggestedOnly = suggestedOnly;
+ // Map from room id to list of servers which are listed as a via somewhere in the loaded hierarchy
+ _defineProperty(this, "viaMap", new Map());
+ // Map from room id to list of rooms which claim this room as their child
+ _defineProperty(this, "backRefs", new Map());
+ // Map from room id to object
+ _defineProperty(this, "roomMap", new Map());
+ _defineProperty(this, "loadRequest", void 0);
+ _defineProperty(this, "nextBatch", void 0);
+ _defineProperty(this, "_rooms", void 0);
+ _defineProperty(this, "serverSupportError", void 0);
+ }
+ get noSupport() {
+ return !!this.serverSupportError;
+ }
+ get canLoadMore() {
+ return !!this.serverSupportError || !!this.nextBatch || !this._rooms;
+ }
+ get loading() {
+ return !!this.loadRequest;
+ }
+ get rooms() {
+ return this._rooms;
+ }
+ async load(pageSize = this.pageSize) {
+ 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;
+ try {
+ ({
+ rooms,
+ next_batch: this.nextBatch
+ } = await this.loadRequest);
+ } catch (e) {
+ if (e.errcode === "M_UNRECOGNIZED") {
+ this.serverSupportError = 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 !== _event.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;
+ }
+ getRelation(parentId, childId) {
+ return this.roomMap.get(parentId)?.children_state.find(e => e.state_key === childId);
+ }
+ isSuggested(parentId, childId) {
+ return this.getRelation(parentId, childId)?.content.suggested;
+ }
+
+ // locally remove a relation as a form of local echo
+ removeRelation(parentId, childId) {
+ 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);
+ }
+ }
+}
+exports.RoomHierarchy = RoomHierarchy; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js
new file mode 100644
index 0000000000..3bb17a0cef
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js
@@ -0,0 +1,93 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.CrossSigningIdentity = void 0;
+var _logger = require("../logger");
+/*
+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.
+*/
+
+/** Manages the cross-signing keys for our own user.
+ */
+class CrossSigningIdentity {
+ constructor(olmMachine, outgoingRequestProcessor) {
+ this.olmMachine = olmMachine;
+ this.outgoingRequestProcessor = outgoingRequestProcessor;
+ }
+
+ /**
+ * Initialise our cross-signing keys by creating new keys if they do not exist, and uploading to the server
+ */
+ async bootstrapCrossSigning(opts) {
+ if (opts.setupNewCrossSigning) {
+ await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
+ return;
+ }
+ const olmDeviceStatus = await this.olmMachine.crossSigningStatus();
+ const privateKeysInSecretStorage = false; // TODO
+ const olmDeviceHasKeys = olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
+
+ // Log all relevant state for easier parsing of debug logs.
+ _logger.logger.log("bootStrapCrossSigning: starting", {
+ setupNewCrossSigning: opts.setupNewCrossSigning,
+ olmDeviceHasMaster: olmDeviceStatus.hasMaster,
+ olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
+ olmDeviceHasSelfSigning: olmDeviceStatus.hasSelfSigning,
+ privateKeysInSecretStorage
+ });
+ if (!olmDeviceHasKeys && !privateKeysInSecretStorage) {
+ _logger.logger.log("bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys");
+ await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
+ } else if (olmDeviceHasKeys) {
+ _logger.logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage");
+ await this.exportCrossSigningKeysToStorage();
+ } else if (privateKeysInSecretStorage) {
+ _logger.logger.log("bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " + "in secret storage, reading storage and caching locally");
+ throw new Error("TODO");
+ }
+
+ // TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
+ // server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
+ _logger.logger.log("bootStrapCrossSigning: complete");
+ }
+
+ /** Reset our cross-signing keys
+ *
+ * This method will:
+ * * Tell the OlmMachine to create new keys
+ * * Upload the new public keys and the device signature to the server
+ * * Upload the private keys to SSSS, if it is set up
+ */
+ async resetCrossSigning(authUploadDeviceSigningKeys) {
+ const outgoingRequests = await this.olmMachine.bootstrapCrossSigning(true);
+ _logger.logger.log("bootStrapCrossSigning: publishing keys to server");
+ for (const req of outgoingRequests) {
+ await this.outgoingRequestProcessor.makeOutgoingRequest(req, authUploadDeviceSigningKeys);
+ }
+ await this.exportCrossSigningKeysToStorage();
+ }
+
+ /**
+ * Extract the cross-signing keys from the olm machine and save them to secret storage, if it is configured
+ *
+ * (If secret storage is *not* configured, we assume that the export will happen when it is set up)
+ */
+ async exportCrossSigningKeysToStorage() {
+ // TODO
+ }
+}
+exports.CrossSigningIdentity = CrossSigningIdentity; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js
new file mode 100644
index 0000000000..a560259504
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js
@@ -0,0 +1,78 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.KeyClaimManager = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+/**
+ * KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
+ *
+ * We have one of these per `RustCrypto` (and hence per `MatrixClient`).
+ */
+class KeyClaimManager {
+ constructor(olmMachine, outgoingRequestProcessor) {
+ this.olmMachine = olmMachine;
+ this.outgoingRequestProcessor = outgoingRequestProcessor;
+ _defineProperty(this, "currentClaimPromise", void 0);
+ _defineProperty(this, "stopped", false);
+ 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.
+ */
+ stop() {
+ 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
+ */
+ ensureSessionsForUsers(userList) {
+ // 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;
+ }
+ async ensureSessionsForUsersInner(userList) {
+ // 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);
+ }
+ }
+}
+exports.KeyClaimManager = KeyClaimManager; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js
new file mode 100644
index 0000000000..cbf10b51ba
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js
@@ -0,0 +1,117 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.OutgoingRequestProcessor = void 0;
+var _matrixSdkCryptoJs = require("@matrix-org/matrix-sdk-crypto-js");
+var _logger = require("../logger");
+var _httpApi = require("../http-api");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
+ */
+
+/**
+ * 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.
+ */
+class OutgoingRequestProcessor {
+ constructor(olmMachine, http) {
+ this.olmMachine = olmMachine;
+ this.http = http;
+ }
+ async makeOutgoingRequest(msg, uiaCallback) {
+ let resp;
+
+ /* 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 _matrixSdkCryptoJs.KeysUploadRequest) {
+ resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.KeysQueryRequest) {
+ resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.KeysClaimRequest) {
+ resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.SignatureUploadRequest) {
+ resp = await this.rawJsonRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.KeysBackupRequest) {
+ resp = await this.rawJsonRequest(_httpApi.Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.ToDeviceRequest) {
+ const path = `/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` + encodeURIComponent(msg.txn_id);
+ resp = await this.rawJsonRequest(_httpApi.Method.Put, path, {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.RoomMessageRequest) {
+ const path = `/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` + `${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
+ resp = await this.rawJsonRequest(_httpApi.Method.Put, path, {}, msg.body);
+ } else if (msg instanceof _matrixSdkCryptoJs.SigningKeysUploadRequest) {
+ resp = await this.makeRequestWithUIA(_httpApi.Method.Post, "/_matrix/client/v3/keys/device_signing/upload", {}, msg.body, uiaCallback);
+ } else {
+ _logger.logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
+ resp = "";
+ }
+ if (msg.id) {
+ await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
+ }
+ }
+ async makeRequestWithUIA(method, path, queryParams, body, uiaCallback) {
+ if (!uiaCallback) {
+ return await this.rawJsonRequest(method, path, queryParams, body);
+ }
+ const parsedBody = JSON.parse(body);
+ const makeRequest = async auth => {
+ const newBody = _objectSpread(_objectSpread({}, parsedBody), {}, {
+ auth
+ });
+ const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
+ return JSON.parse(resp);
+ };
+ const resp = await uiaCallback(makeRequest);
+ return JSON.stringify(resp);
+ }
+ async rawJsonRequest(method, path, queryParams, body) {
+ 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(method, path, queryParams, body, opts);
+ _logger.logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
+ return response;
+ } catch (e) {
+ _logger.logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
+ throw e;
+ }
+ }
+}
+exports.OutgoingRequestProcessor = OutgoingRequestProcessor; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js
new file mode 100644
index 0000000000..48a8665761
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js
@@ -0,0 +1,124 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RoomEncryptor = void 0;
+var _matrixSdkCryptoJs = require("@matrix-org/matrix-sdk-crypto-js");
+var _event = require("../@types/event");
+var _logger = require("../logger");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * RoomEncryptor: responsible for encrypting messages to a given room
+ */
+class RoomEncryptor {
+ /**
+ * @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
+ */
+ constructor(olmMachine, keyClaimManager, outgoingRequestProcessor, room, encryptionSettings) {
+ this.olmMachine = olmMachine;
+ this.keyClaimManager = keyClaimManager;
+ this.outgoingRequestProcessor = outgoingRequestProcessor;
+ this.room = room;
+ this.encryptionSettings = encryptionSettings;
+ _defineProperty(this, "prefixedLogger", void 0);
+ this.prefixedLogger = _logger.logger.withPrefix(`[${room.roomId} encryption]`);
+ }
+
+ /**
+ * Handle a new `m.room.encryption` event in this room
+ *
+ * @param config - The content of the encryption event
+ */
+ onCryptoEvent(config) {
+ 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
+ */
+ onRoomMembership(member) {
+ 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 _matrixSdkCryptoJs.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.
+ */
+ async ensureEncryptionSession() {
+ 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 _matrixSdkCryptoJs.UserId(u.userId));
+ await this.keyClaimManager.ensureSessionsForUsers(userList);
+ this.prefixedLogger.debug("Sessions for users are ready; now sharing room key");
+ const rustEncryptionSettings = new _matrixSdkCryptoJs.EncryptionSettings();
+ /* FIXME historyVisibility, rotation, etc */
+
+ const shareMessages = await this.olmMachine.shareRoomKey(new _matrixSdkCryptoJs.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
+ */
+ async forceDiscardSession() {
+ const r = await this.olmMachine.invalidateGroupSession(new _matrixSdkCryptoJs.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.
+ */
+ async encryptEvent(event) {
+ await this.ensureEncryptionSession();
+ const encryptedContent = await this.olmMachine.encryptRoomEvent(new _matrixSdkCryptoJs.RoomId(this.room.roomId), event.getType(), JSON.stringify(event.getContent()));
+ event.makeEncrypted(_event.EventType.RoomMessageEncrypted, JSON.parse(encryptedContent), this.olmMachine.identityKeys.curve25519.toBase64(), this.olmMachine.identityKeys.ed25519.toBase64());
+ }
+}
+exports.RoomEncryptor = RoomEncryptor; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js
new file mode 100644
index 0000000000..5876cbad6a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.initRustCrypto = initRustCrypto;
+/*
+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.
+ */
+
+async function initRustCrypto(_http, _userId, _deviceId) {
+ throw new Error("Rust crypto is not supported under browserify.");
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js
new file mode 100644
index 0000000000..cd1599ff0b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js
@@ -0,0 +1,25 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RUST_SDK_STORE_PREFIX = void 0;
+/*
+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 */
+const RUST_SDK_STORE_PREFIX = "matrix-js-sdk";
+exports.RUST_SDK_STORE_PREFIX = RUST_SDK_STORE_PREFIX; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js
new file mode 100644
index 0000000000..83623fca0a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js
@@ -0,0 +1,121 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.deviceKeysToDeviceMap = deviceKeysToDeviceMap;
+exports.downloadDeviceToJsDevice = downloadDeviceToJsDevice;
+exports.rustDeviceToJsDevice = rustDeviceToJsDevice;
+var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js"));
+var _device = require("../models/device");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+/**
+ * Convert a {@link RustSdkCryptoJs.Device} to a {@link Device}
+ * @param device - Rust Sdk device
+ * @param userId - owner of the device
+ */
+function rustDeviceToJsDevice(device, userId) {
+ // Copy rust device keys to Device.keys
+ const keys = new Map();
+ for (const [keyId, key] of device.keys.entries()) {
+ keys.set(keyId.toString(), key.toBase64());
+ }
+
+ // Compute verified from device state
+ let verified = _device.DeviceVerification.Unverified;
+ if (device.isBlacklisted()) {
+ verified = _device.DeviceVerification.Blocked;
+ } else if (device.isVerified()) {
+ verified = _device.DeviceVerification.Verified;
+ }
+
+ // Convert rust signatures to Device.signatures
+ const signatures = new Map();
+ const mayBeSignatureMap = device.signatures.get(userId);
+ if (mayBeSignatureMap) {
+ const convertedSignatures = new Map();
+ // Convert maybeSignatures map to a Map<string, string>
+ for (const [key, value] of mayBeSignatureMap.entries()) {
+ if (value.isValid() && value.signature) {
+ convertedSignatures.set(key, value.signature.toBase64());
+ }
+ }
+ signatures.set(userId.toString(), convertedSignatures);
+ }
+
+ // Convert rust algorithms to algorithms
+ const rustAlgorithms = device.algorithms;
+ // Use set to ensure that algorithms are not duplicated
+ const algorithms = new Set();
+ rustAlgorithms.forEach(algorithm => {
+ switch (algorithm) {
+ case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2:
+ algorithms.add("m.megolm.v1.aes-sha2");
+ break;
+ case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2:
+ default:
+ algorithms.add("m.olm.v1.curve25519-aes-sha2");
+ break;
+ }
+ });
+ return new _device.Device({
+ deviceId: device.deviceId.toString(),
+ userId: userId.toString(),
+ keys,
+ algorithms: Array.from(algorithms),
+ verified,
+ signatures,
+ displayName: device.displayName
+ });
+}
+
+/**
+ * Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>`
+ * @param deviceKeys - Device keys object to convert
+ */
+function deviceKeysToDeviceMap(deviceKeys) {
+ return new Map(Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]));
+}
+
+// Device from `/keys/query` request
+
+/**
+ * Convert `/keys/query` {@link QueryDevice} device to {@link Device}
+ * @param device - Device from `/keys/query` request
+ */
+function downloadDeviceToJsDevice(device) {
+ const keys = new Map(Object.entries(device.keys));
+ const displayName = device.unsigned?.device_display_name;
+ const signatures = new Map();
+ if (device.signatures) {
+ for (const userId in device.signatures) {
+ signatures.set(userId, new Map(Object.entries(device.signatures[userId])));
+ }
+ }
+ return new _device.Device({
+ deviceId: device.device_id,
+ userId: device.user_id,
+ keys,
+ algorithms: device.algorithms,
+ verified: _device.DeviceVerification.Unverified,
+ signatures,
+ displayName
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js
new file mode 100644
index 0000000000..2ba47c5f40
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js
@@ -0,0 +1,54 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.initRustCrypto = initRustCrypto;
+var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js"));
+var _rustCrypto = require("./rust-crypto");
+var _logger = require("../logger");
+var _constants = require("./constants");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+/*
+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.
+*/
+
+/**
+ * Create a new `RustCrypto` implementation
+ *
+ * @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
+ * We expect it to set the access token, etc.
+ * @param userId - The local user's User ID.
+ * @param deviceId - The local user's Device ID.
+ * @param secretStorage - Interface to server-side secret storage.
+ */
+async function initRustCrypto(http, userId, deviceId, secretStorage) {
+ // 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.logger.info("Init OlmMachine");
+
+ // TODO: use the pickle key for the passphrase
+ const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, _constants.RUST_SDK_STORE_PREFIX, "test pass");
+ const rustCrypto = new _rustCrypto.RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
+ await olmMachine.registerRoomKeyUpdatedCallback(sessions => rustCrypto.onRoomKeysUpdated(sessions));
+ _logger.logger.info("Completed rust crypto-sdk setup");
+ return rustCrypto;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js
new file mode 100644
index 0000000000..b55d2fb64b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js
@@ -0,0 +1,574 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RustCrypto = void 0;
+var RustSdkCryptoJs = _interopRequireWildcard(require("@matrix-org/matrix-sdk-crypto-js"));
+var _logger = require("../logger");
+var _httpApi = require("../http-api");
+var _CrossSigning = require("../crypto/CrossSigning");
+var _RoomEncryptor = require("./RoomEncryptor");
+var _OutgoingRequestProcessor = require("./OutgoingRequestProcessor");
+var _KeyClaimManager = require("./KeyClaimManager");
+var _utils = require("../utils");
+var _cryptoApi = require("../crypto-api");
+var _deviceConverter = require("./device-converter");
+var _api = require("../crypto/api");
+var _CrossSigningIdentity = require("./CrossSigningIdentity");
+function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
+function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+/**
+ * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
+ */
+class RustCrypto {
+ constructor( /** The `OlmMachine` from the underlying rust crypto sdk. */
+ olmMachine,
+ /**
+ * Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
+ *
+ * We expect it to set the access token, etc.
+ */
+ http, /** The local user's User ID. */
+ _userId, /** The local user's Device ID. */
+ _deviceId, /** Interface to server-side secret storage */
+ _secretStorage) {
+ this.olmMachine = olmMachine;
+ this.http = http;
+ _defineProperty(this, "globalErrorOnUnknownDevices", false);
+ _defineProperty(this, "_trustCrossSignedDevices", true);
+ /** whether {@link stop} has been called */
+ _defineProperty(this, "stopped", false);
+ /** whether {@link outgoingRequestLoop} is currently running */
+ _defineProperty(this, "outgoingRequestLoopRunning", false);
+ /** mapping of roomId → encryptor class */
+ _defineProperty(this, "roomEncryptors", {});
+ _defineProperty(this, "eventDecryptor", void 0);
+ _defineProperty(this, "keyClaimManager", void 0);
+ _defineProperty(this, "outgoingRequestProcessor", void 0);
+ _defineProperty(this, "crossSigningIdentity", void 0);
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // CryptoApi implementation
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ _defineProperty(this, "globalBlacklistUnverifiedDevices", false);
+ this.outgoingRequestProcessor = new _OutgoingRequestProcessor.OutgoingRequestProcessor(olmMachine, http);
+ this.keyClaimManager = new _KeyClaimManager.KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
+ this.eventDecryptor = new EventDecryptor(olmMachine);
+ this.crossSigningIdentity = new _CrossSigningIdentity.CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor);
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // CryptoBackend implementation
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ stop() {
+ // 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();
+ }
+ async encryptEvent(event, _room) {
+ 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);
+ }
+ async decryptEvent(event) {
+ 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");
+ }
+ return await this.eventDecryptor.attemptEventDecryption(event);
+ }
+ getEventEncryptionInfo(event) {
+ // TODO: make this work properly. Or better, replace it.
+
+ const ret = {};
+ ret.senderKey = event.getSenderKey() ?? undefined;
+ ret.algorithm = event.getWireContent().algorithm;
+ if (!ret.senderKey || !ret.algorithm) {
+ ret.encrypted = false;
+ return ret;
+ }
+ ret.encrypted = true;
+ ret.authenticated = true;
+ ret.mismatchedSender = true;
+ return ret;
+ }
+ checkUserTrust(userId) {
+ // TODO
+ return new _CrossSigning.UserTrustLevel(false, false, false);
+ }
+
+ /**
+ * 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
+ */
+ findVerificationRequestDMInProgress(roomId) {
+ // TODO
+ return;
+ }
+
+ /**
+ * 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.
+ */
+ getStoredCrossSigningForUser(userId) {
+ // TODO
+ return null;
+ }
+ async userHasCrossSigningKeys() {
+ // TODO
+ return false;
+ }
+ prepareToEncrypt(room) {
+ const encryptor = this.roomEncryptors[room.roomId];
+ if (encryptor) {
+ encryptor.ensureEncryptionSession();
+ }
+ }
+ forceDiscardSession(roomId) {
+ return this.roomEncryptors[roomId]?.forceDiscardSession();
+ }
+ async exportRoomKeys() {
+ // TODO
+ return [];
+ }
+
+ /**
+ * Get the device information for the given list of users.
+ *
+ * @param userIds - The users to fetch.
+ * @param downloadUncached - If true, download the device list for users whose device list we are not
+ * currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
+ *
+ * @returns A map `{@link DeviceMap}`.
+ */
+ async getUserDeviceInfo(userIds, downloadUncached = false) {
+ const deviceMapByUserId = new Map();
+ const rustTrackedUsers = await this.olmMachine.trackedUsers();
+
+ // Convert RustSdkCryptoJs.UserId to a `Set<string>`
+ const trackedUsers = new Set();
+ rustTrackedUsers.forEach(rustUserId => trackedUsers.add(rustUserId.toString()));
+
+ // Keep untracked user to download their keys after
+ const untrackedUsers = new Set();
+ for (const userId of userIds) {
+ // if this is a tracked user, we can just fetch the device list from the rust-sdk
+ // (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's
+ // devices: the rust-sdk will return the last-known device list, which will be good enough.)
+ if (trackedUsers.has(userId)) {
+ deviceMapByUserId.set(userId, await this.getUserDevices(userId));
+ } else {
+ untrackedUsers.add(userId);
+ }
+ }
+
+ // for any users whose device lists we are not tracking, fall back to downloading the device list
+ // over HTTP.
+ if (downloadUncached && untrackedUsers.size >= 1) {
+ const queryResult = await this.downloadDeviceList(untrackedUsers);
+ Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) => deviceMapByUserId.set(userId, (0, _deviceConverter.deviceKeysToDeviceMap)(deviceKeys)));
+ }
+ return deviceMapByUserId;
+ }
+
+ /**
+ * Get the device list for the given user from the olm machine
+ * @param userId - Rust SDK UserId
+ */
+ async getUserDevices(userId) {
+ const rustUserId = new RustSdkCryptoJs.UserId(userId);
+ const devices = await this.olmMachine.getUserDevices(rustUserId);
+ return new Map(devices.devices().map(device => [device.deviceId.toString(), (0, _deviceConverter.rustDeviceToJsDevice)(device, rustUserId)]));
+ }
+
+ /**
+ * Download the given user keys by calling `/keys/query` request
+ * @param untrackedUsers - download keys of these users
+ */
+ async downloadDeviceList(untrackedUsers) {
+ const queryBody = {
+ device_keys: {}
+ };
+ untrackedUsers.forEach(user => queryBody.device_keys[user] = []);
+ return await this.http.authedRequest(_httpApi.Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, {
+ prefix: ""
+ });
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#getTrustCrossSignedDevices}.
+ */
+ getTrustCrossSignedDevices() {
+ return this._trustCrossSignedDevices;
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#setTrustCrossSignedDevices}.
+ */
+ setTrustCrossSignedDevices(val) {
+ this._trustCrossSignedDevices = val;
+ // TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged
+ // events. Maybe we need to do the same?
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#getDeviceVerificationStatus}.
+ */
+ async getDeviceVerificationStatus(userId, deviceId) {
+ const device = await this.olmMachine.getDevice(new RustSdkCryptoJs.UserId(userId), new RustSdkCryptoJs.DeviceId(deviceId));
+ if (!device) return null;
+ return new _cryptoApi.DeviceVerificationStatus({
+ signedByOwner: device.isCrossSignedByOwner(),
+ crossSigningVerified: device.isCrossSigningTrusted(),
+ localVerified: device.isLocallyTrusted(),
+ trustCrossSignedDevices: this._trustCrossSignedDevices
+ });
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#isCrossSigningReady}
+ */
+ async isCrossSigningReady() {
+ return false;
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#getCrossSigningKeyId}
+ */
+ async getCrossSigningKeyId(type = _api.CrossSigningKey.Master) {
+ // TODO
+ return null;
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#boostrapCrossSigning}
+ */
+ async bootstrapCrossSigning(opts) {
+ await this.crossSigningIdentity.bootstrapCrossSigning(opts);
+ }
+
+ /**
+ * Implementation of {@link CryptoApi#isSecretStorageReady}
+ */
+ async isSecretStorageReady() {
+ return false;
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // 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
+ * @param devices - the received device list updates
+ * @returns A list of preprocessed to-device messages.
+ */
+ async receiveSyncChanges({
+ events,
+ oneTimeKeysCounts = new Map(),
+ unusedFallbackKeys,
+ devices = new RustSdkCryptoJs.DeviceLists()
+ }) {
+ const result = await this.olmMachine.receiveSyncChanges(events ? JSON.stringify(events) : "[]", devices, 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.
+ */
+ preprocessToDeviceMessages(events) {
+ // 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 process one time key counts and unused fallback keys
+ *
+ * @param oneTimeKeysCounts - the received one time key counts
+ * @param unusedFallbackKeys - the received unused fallback keys
+ */
+ async processKeyCounts(oneTimeKeysCounts, unusedFallbackKeys) {
+ const mapOneTimeKeysCount = oneTimeKeysCounts && new Map(Object.entries(oneTimeKeysCounts));
+ const setUnusedFallbackKeys = unusedFallbackKeys && new Set(unusedFallbackKeys);
+ if (mapOneTimeKeysCount !== undefined || setUnusedFallbackKeys !== undefined) {
+ await this.receiveSyncChanges({
+ oneTimeKeysCounts: mapOneTimeKeysCount,
+ unusedFallbackKeys: setUnusedFallbackKeys
+ });
+ }
+ }
+
+ /** called by the sync loop to process the notification that device lists have
+ * been changed.
+ *
+ * @param deviceLists - device_lists field from /sync
+ */
+ async processDeviceLists(deviceLists) {
+ const devices = new RustSdkCryptoJs.DeviceLists(deviceLists.changed?.map(userId => new RustSdkCryptoJs.UserId(userId)), deviceLists.left?.map(userId => new RustSdkCryptoJs.UserId(userId)));
+ await this.receiveSyncChanges({
+ devices
+ });
+ }
+
+ /** 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
+ */
+ async onCryptoEvent(room, event) {
+ const config = event.getContent();
+ const existingEncryptor = this.roomEncryptors[room.roomId];
+ if (existingEncryptor) {
+ existingEncryptor.onCryptoEvent(config);
+ } else {
+ this.roomEncryptors[room.roomId] = new _RoomEncryptor.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.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.
+ */
+ onSyncCompleted(syncState) {
+ // 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.
+ */
+ onRoomMembership(event, member, oldMembership) {
+ const enc = this.roomEncryptors[event.getRoomId()];
+ if (!enc) {
+ // not encrypting in this room
+ return;
+ }
+ enc.onRoomMembership(member);
+ }
+
+ /** Callback for OlmMachine.registerRoomKeyUpdatedCallback
+ *
+ * Called by the rust-sdk whenever there is an update to (megolm) room keys. We
+ * check if we have any events waiting for the given keys, and schedule them for
+ * a decryption retry if so.
+ *
+ * @param keys - details of the updated keys
+ */
+ async onRoomKeysUpdated(keys) {
+ for (const key of keys) {
+ this.onRoomKeyUpdated(key);
+ }
+ }
+ onRoomKeyUpdated(key) {
+ _logger.logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`);
+ const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key);
+ if (pendingList.length === 0) return;
+ _logger.logger.debug("Retrying decryption on events:", pendingList.map(e => `${e.getId()}`));
+
+ // Have another go at decrypting events with this key.
+ //
+ // We don't want to end up blocking the callback from Rust, which could otherwise end up dropping updates,
+ // so we don't wait for the decryption to complete. In any case, there is no need to wait:
+ // MatrixEvent.attemptDecryption ensures that there is only one decryption attempt happening at once,
+ // and deduplicates repeated attempts for the same event.
+ for (const ev of pendingList) {
+ ev.attemptDecryption(this, {
+ isRetry: true
+ }).catch(_e => {
+ _logger.logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
+ });
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // Outgoing requests
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ async outgoingRequestLoop() {
+ if (this.outgoingRequestLoopRunning) {
+ return;
+ }
+ this.outgoingRequestLoopRunning = true;
+ try {
+ while (!this.stopped) {
+ const outgoingRequests = 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);
+ }
+ }
+ } catch (e) {
+ _logger.logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
+ } finally {
+ this.outgoingRequestLoopRunning = false;
+ }
+ }
+}
+exports.RustCrypto = RustCrypto;
+class EventDecryptor {
+ constructor(olmMachine) {
+ this.olmMachine = olmMachine;
+ /**
+ * Events which we couldn't decrypt due to unknown sessions / indexes.
+ *
+ * Map from senderKey to sessionId to Set of MatrixEvents
+ */
+ _defineProperty(this, "eventsPendingKey", new _utils.MapWithDefault(() => new _utils.MapWithDefault(() => new Set())));
+ }
+ async attemptEventDecryption(event) {
+ _logger.logger.info("Attempting decryption of event", event);
+ // add the event to the pending list *before* attempting to decrypt.
+ // 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);
+ 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()));
+
+ // Success. We can remove the event from the pending list, if
+ // that hasn't already happened.
+ this.removeEventFromPendingList(event);
+ return {
+ clearEvent: JSON.parse(res.event),
+ claimedEd25519Key: res.senderClaimedEd25519Key,
+ senderCurve25519Key: res.senderCurve25519Key,
+ forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain
+ };
+ }
+
+ /**
+ * Look for events which are waiting for a given megolm session
+ *
+ * Returns a list of events which were encrypted by `session` and could not be decrypted
+ *
+ * @param session -
+ */
+ getEventsPendingRoomKey(session) {
+ const senderPendingEvents = this.eventsPendingKey.get(session.senderKey.toBase64());
+ if (!senderPendingEvents) return [];
+ const sessionPendingEvents = senderPendingEvents.get(session.sessionId);
+ if (!sessionPendingEvents) return [];
+ const roomId = session.roomId.toString();
+ return [...sessionPendingEvents].filter(ev => ev.getRoomId() === roomId);
+ }
+
+ /**
+ * Add an event to the list of those awaiting their session keys.
+ */
+ addEventToPendingList(event) {
+ const content = event.getWireContent();
+ const senderKey = content.sender_key;
+ const sessionId = content.session_id;
+ const senderPendingEvents = this.eventsPendingKey.getOrCreate(senderKey);
+ const sessionPendingEvents = senderPendingEvents.getOrCreate(sessionId);
+ sessionPendingEvents.add(event);
+ }
+
+ /**
+ * Remove an event from the list of those awaiting their session keys.
+ */
+ removeEventFromPendingList(event) {
+ const content = event.getWireContent();
+ const senderKey = content.sender_key;
+ const sessionId = content.session_id;
+ const senderPendingEvents = this.eventsPendingKey.get(senderKey);
+ if (!senderPendingEvents) return;
+ const sessionPendingEvents = senderPendingEvents.get(sessionId);
+ if (!sessionPendingEvents) return;
+ sessionPendingEvents.delete(event);
+
+ // also clean up the higher-level maps if they are now empty
+ if (sessionPendingEvents.size === 0) {
+ senderPendingEvents.delete(sessionId);
+ if (senderPendingEvents.size === 0) {
+ this.eventsPendingKey.delete(senderKey);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js
new file mode 100644
index 0000000000..424ca31e2e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js
@@ -0,0 +1,314 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MatrixScheduler = void 0;
+var _logger = require("./logger");
+var _event = require("./@types/event");
+var _utils = require("./utils");
+var _httpApi = require("./http-api");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const DEBUG = false; // set true to enable console logging.
+
+/**
+ * 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.
+ */
+
+// eslint-disable-next-line camelcase
+class MatrixScheduler {
+ /**
+ * 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
+ static RETRY_BACKOFF_RATELIMIT(event, attempts, err) {
+ 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 _httpApi.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
+ static QUEUE_MESSAGES(event) {
+ // enqueue messages or events that associate with another event (redactions and relations)
+ if (event.getType() === _event.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
+ // }, ...]
+
+ /**
+ * 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}.
+ */
+ 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.
+ */
+ 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.
+ */
+ queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES) {
+ this.retryAlgorithm = retryAlgorithm;
+ this.queueAlgorithm = queueAlgorithm;
+ _defineProperty(this, "queues", {});
+ _defineProperty(this, "activeQueues", []);
+ _defineProperty(this, "procFn", null);
+ _defineProperty(this, "processQueue", queueName => {
+ // 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!)
+ _logger.logger.info("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);
+ }
+ });
+ });
+ }
+
+ /**
+ * 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.
+ */
+ getQueueForEvent(event) {
+ 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.
+ */
+ removeEventFromQueue(event) {
+ const name = this.queueAlgorithm(event);
+ if (!name || !this.queues[name]) {
+ return false;
+ }
+ let removed = false;
+ (0, _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.
+ */
+ setProcessFunction(fn) {
+ 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.
+ */
+ queueEvent(event) {
+ 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 deferred = (0, _utils.defer)();
+ this.queues[queueName].push({
+ event: event,
+ defer: deferred,
+ attempts: 0
+ });
+ debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
+ this.startProcessingQueues();
+ return deferred.promise;
+ }
+ startProcessingQueues() {
+ 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);
+ });
+ }
+ disableQueue(queueName) {
+ // queue is empty. Mark as inactive and stop recursing.
+ const index = this.activeQueues.indexOf(queueName);
+ if (index >= 0) {
+ this.activeQueues.splice(index, 1);
+ }
+ _logger.logger.info("Stopping queue '%s' as it is now empty", queueName);
+ }
+ clearQueue(queueName, err) {
+ _logger.logger.info("clearing queue '%s'", queueName);
+ let obj;
+ while (obj = this.removeNextEvent(queueName)) {
+ obj.defer.reject(err);
+ }
+ this.disableQueue(queueName);
+ }
+ peekNextEvent(queueName) {
+ const queue = this.queues[queueName];
+ if (!Array.isArray(queue)) {
+ return undefined;
+ }
+ return queue[0];
+ }
+ removeNextEvent(queueName) {
+ const queue = this.queues[queueName];
+ if (!Array.isArray(queue)) {
+ return undefined;
+ }
+ return queue.shift();
+ }
+}
+
+/* istanbul ignore next */
+exports.MatrixScheduler = MatrixScheduler;
+function debuglog(...args) {
+ if (DEBUG) {
+ _logger.logger.log(...args);
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js b/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js
new file mode 100644
index 0000000000..badd379148
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js
@@ -0,0 +1,431 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ServerSideSecretStorageImpl = exports.SECRET_STORAGE_ALGORITHM_V1_AES = void 0;
+exports.trimTrailingEquals = trimTrailingEquals;
+var _client = require("./client");
+var _aes = require("./crypto/aes");
+var _randomstring = require("./randomstring");
+var _logger = require("./logger");
+/*
+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
+ */
+
+const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
+
+/**
+ * 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
+ */
+
+/**
+ * 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
+ */
+
+/**
+ * Union type for secret storage keys.
+ *
+ * For now, this is only {@link SecretStorageKeyDescriptionAesV1}, but other interfaces may be added in future.
+ */
+
+/**
+ * Information on how to generate the key from a passphrase.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#deriving-keys-from-passphrases
+ */
+
+/**
+ * Options for {@link ServerSideSecretStorageImpl#addKey}.
+ */
+
+/**
+ * Return type for {@link ServerSideSecretStorageImpl#getKey}.
+ */
+
+/**
+ * Return type for {@link ServerSideSecretStorageImpl#addKey}.
+ */
+
+/** Interface for managing account data on the server.
+ *
+ * A subset of {@link MatrixClient}.
+ */
+
+/**
+ * Application callbacks for use with {@link SecretStorage.ServerSideSecretStorageImpl}
+ */
+
+/**
+ * Interface provided by SecretStorage implementations
+ *
+ * Normally this will just be an {@link ServerSideSecretStorageImpl}, but for backwards
+ * compatibility some methods allow other implementations.
+ */
+exports.SECRET_STORAGE_ALGORITHM_V1_AES = SECRET_STORAGE_ALGORITHM_V1_AES;
+/**
+ * Implementation of Server-side secret storage.
+ *
+ * Secret *sharing* is *not* implemented here: this class is strictly about the storage component of
+ * SSSS.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#storage
+ */
+class ServerSideSecretStorageImpl {
+ /**
+ * Construct a new `SecretStorage`.
+ *
+ * Normally, it is unnecessary to call this directly, since MatrixClient automatically constructs one.
+ * However, it may be useful to construct a new `SecretStorage`, if custom `callbacks` are required, for example.
+ *
+ * @param accountDataAdapter - interface for fetching and setting account data on the server. Normally an instance
+ * of {@link MatrixClient}.
+ * @param callbacks - application level callbacks for retrieving secret keys
+ */
+ constructor(accountDataAdapter, callbacks) {
+ this.accountDataAdapter = accountDataAdapter;
+ this.callbacks = callbacks;
+ }
+
+ /**
+ * Get the current default key ID for encrypting secrets.
+ *
+ * @returns The default key ID or null if no default key ID is set
+ */
+ async getDefaultKeyId() {
+ const defaultKey = await this.accountDataAdapter.getAccountDataFromServer("m.secret_storage.default_key");
+ if (!defaultKey) return null;
+ return defaultKey.key;
+ }
+
+ /**
+ * Set the default key ID for encrypting secrets.
+ *
+ * @param keyId - The new default key ID
+ */
+ setDefaultKeyId(keyId) {
+ return new Promise((resolve, reject) => {
+ const listener = ev => {
+ if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
+ this.accountDataAdapter.removeListener(_client.ClientEvent.AccountData, listener);
+ resolve();
+ }
+ };
+ this.accountDataAdapter.on(_client.ClientEvent.AccountData, listener);
+ this.accountDataAdapter.setAccountData("m.secret_storage.default_key", {
+ key: keyId
+ }).catch(e => {
+ this.accountDataAdapter.removeListener(_client.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)
+ */
+ async addKey(algorithm, opts = {}, keyId) {
+ if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
+ throw new Error(`Unknown key algorithm ${algorithm}`);
+ }
+ const keyInfo = {
+ algorithm
+ };
+ if (opts.name) {
+ keyInfo.name = opts.name;
+ }
+ if (opts.passphrase) {
+ keyInfo.passphrase = opts.passphrase;
+ }
+ if (opts.key) {
+ const {
+ iv,
+ mac
+ } = await (0, _aes.calculateKeyCheck)(opts.key);
+ keyInfo.iv = iv;
+ keyInfo.mac = mac;
+ }
+
+ // Create a unique key id. XXX: this is racey.
+ if (!keyId) {
+ do {
+ keyId = (0, _randomstring.randomString)(32);
+ } while (await this.accountDataAdapter.getAccountDataFromServer(`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?
+ */
+ async getKey(keyId) {
+ if (!keyId) {
+ keyId = await this.getDefaultKeyId();
+ }
+ if (!keyId) {
+ return null;
+ }
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("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.
+ */
+ async hasKey(keyId) {
+ const key = await this.getKey(keyId);
+ return Boolean(key);
+ }
+
+ /**
+ * 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
+ */
+ async checkKey(key, info) {
+ if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ if (info.mac) {
+ const {
+ mac
+ } = await (0, _aes.calculateKeyCheck)(key, info.iv);
+ return trimTrailingEquals(info.mac) === trimTrailingEquals(mac);
+ } 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.
+ *
+ * Details of the encryption keys to be used must previously have been stored in account data
+ * (for example, via {@link ServerSideSecretStorageImpl#addKey}. {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
+ * key to decrypt the secret.
+ *
+ * @param name - The name of the secret - i.e., the "event type" to be stored in the account data
+ * @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.
+ */
+ async store(name, secret, keys) {
+ const encrypted = {};
+ 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("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.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, and decrypt it.
+ *
+ * {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
+ * key to decrypt the secret.
+ *
+ * @param name - the name of the secret - i.e., the "event type" stored in the account data
+ *
+ * @returns the decrypted contents of the secret, or "undefined" if `name` is not found in
+ * the user's account data.
+ */
+ async get(name) {
+ const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name);
+ if (!secretInfo) {
+ return;
+ }
+ if (!secretInfo.encrypted) {
+ throw new Error("Content is not encrypted!");
+ }
+
+ // get possible keys to decrypt
+ const keys = {};
+ for (const keyId of Object.keys(secretInfo.encrypted)) {
+ // get key information from key storage
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer("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
+ */
+ async isStored(name) {
+ // check if secret exists
+ const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name);
+ if (!secretInfo?.encrypted) return null;
+ const ret = {};
+
+ // 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("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;
+ }
+ async getSecretStorageKey(keys, name) {
+ if (!this.callbacks.getSecretStorageKey) {
+ throw new Error("No getSecretStorageKey callback supplied");
+ }
+ const returned = await this.callbacks.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) {
+ return (0, _aes.encryptAES)(secret, privateKey, name);
+ },
+ decrypt: function (encInfo) {
+ return (0, _aes.decryptAES)(encInfo, privateKey, name);
+ }
+ };
+ return [keyId, decryption];
+ } else {
+ throw new Error("Unknown key type: " + keys[keyId].algorithm);
+ }
+ }
+}
+
+/** trim trailing instances of '=' from a string
+ *
+ * @internal
+ *
+ * @param input - input string
+ */
+exports.ServerSideSecretStorageImpl = ServerSideSecretStorageImpl;
+function trimTrailingEquals(input) {
+ // according to Sonar and CodeQL, a regex such as /=+$/ is superlinear.
+ // Not sure I believe it, but it's easy enough to work around.
+
+ // find the number of characters before the trailing =
+ let i = input.length;
+ while (i >= 1 && input.charCodeAt(i - 1) == 0x3d) i--;
+
+ // trim to the calculated length
+ if (i < input.length) {
+ return input.substring(0, i);
+ } else {
+ return input;
+ }
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js b/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js
new file mode 100644
index 0000000000..fd01a116bf
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js
@@ -0,0 +1,27 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SERVICE_TYPES = void 0;
+/*
+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.
+*/
+let SERVICE_TYPES = /*#__PURE__*/function (SERVICE_TYPES) {
+ SERVICE_TYPES["IS"] = "SERVICE_TYPE_IS";
+ SERVICE_TYPES["IM"] = "SERVICE_TYPE_IM";
+ return SERVICE_TYPES;
+}({}); // An integration manager
+exports.SERVICE_TYPES = SERVICE_TYPES; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js
new file mode 100644
index 0000000000..9d5ec1453d
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js
@@ -0,0 +1,861 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SlidingSyncSdk = void 0;
+var _room = require("./models/room");
+var _logger = require("./logger");
+var _utils = require("./utils");
+var _eventTimeline = require("./models/event-timeline");
+var _client = require("./client");
+var _sync = require("./sync");
+var _httpApi = require("./http-api");
+var _slidingSync = require("./sliding-sync");
+var _event = require("./@types/event");
+var _roomState = require("./models/room-state");
+var _roomMember = require("./models/room-member");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// 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;
+class ExtensionE2EE {
+ constructor(crypto) {
+ this.crypto = crypto;
+ }
+ name() {
+ return "e2ee";
+ }
+ when() {
+ return _slidingSync.ExtensionState.PreProcess;
+ }
+ onRequest(isInitial) {
+ if (!isInitial) {
+ return undefined;
+ }
+ return {
+ enabled: true // this is sticky so only send it on the initial request
+ };
+ }
+
+ async onResponse(data) {
+ // Handle device list updates
+ if (data.device_lists) {
+ await this.crypto.processDeviceLists(data.device_lists);
+ }
+
+ // Handle one_time_keys_count and unused_fallback_key_types
+ await this.crypto.processKeyCounts(data.device_one_time_keys_count, data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]);
+ this.crypto.onSyncCompleted({});
+ }
+}
+class ExtensionToDevice {
+ constructor(client, cryptoCallbacks) {
+ this.client = client;
+ this.cryptoCallbacks = cryptoCallbacks;
+ _defineProperty(this, "nextBatch", null);
+ }
+ name() {
+ return "to_device";
+ }
+ when() {
+ return _slidingSync.ExtensionState.PreProcess;
+ }
+ onRequest(isInitial) {
+ const extReq = {
+ since: this.nextBatch !== null ? this.nextBatch : undefined
+ };
+ if (isInitial) {
+ extReq["limit"] = 100;
+ extReq["enabled"] = true;
+ }
+ return extReq;
+ }
+ async onResponse(data) {
+ const cancelledKeyVerificationTxns = [];
+ 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 = 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.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(_client.ClientEvent.ToDeviceEvent, toDeviceEvent);
+ });
+ this.nextBatch = data.next_batch;
+ }
+}
+class ExtensionAccountData {
+ constructor(client) {
+ this.client = client;
+ }
+ name() {
+ return "account_data";
+ }
+ when() {
+ return _slidingSync.ExtensionState.PostProcess;
+ }
+ onRequest(isInitial) {
+ if (!isInitial) {
+ return undefined;
+ }
+ return {
+ enabled: true
+ };
+ }
+ onResponse(data) {
+ 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.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(_client.ClientEvent.Event, e);
+ });
+ }
+ }
+ processGlobalAccountData(globalAccountData) {
+ const events = mapEvents(this.client, undefined, globalAccountData);
+ const prevEventsMap = events.reduce((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() === _event.EventType.PushRules) {
+ const rules = accountDataEvent.getContent();
+ this.client.setPushRules(rules);
+ }
+ const prevEvent = prevEventsMap[accountDataEvent.getType()];
+ this.client.emit(_client.ClientEvent.AccountData, accountDataEvent, prevEvent);
+ return accountDataEvent;
+ });
+ }
+}
+class ExtensionTyping {
+ constructor(client) {
+ this.client = client;
+ }
+ name() {
+ return "typing";
+ }
+ when() {
+ return _slidingSync.ExtensionState.PostProcess;
+ }
+ onRequest(isInitial) {
+ if (!isInitial) {
+ return undefined; // don't send a JSON object for subsequent requests, we don't need to.
+ }
+
+ return {
+ enabled: true
+ };
+ }
+ onResponse(data) {
+ if (!data?.rooms) {
+ return;
+ }
+ for (const roomId in data.rooms) {
+ processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]);
+ }
+ }
+}
+class ExtensionReceipts {
+ constructor(client) {
+ this.client = client;
+ }
+ name() {
+ return "receipts";
+ }
+ when() {
+ return _slidingSync.ExtensionState.PostProcess;
+ }
+ onRequest(isInitial) {
+ if (isInitial) {
+ return {
+ enabled: true
+ };
+ }
+ return undefined; // don't send a JSON object for subsequent requests, we don't need to.
+ }
+
+ onResponse(data) {
+ 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.
+ */
+class SlidingSyncSdk {
+ // accumulator of sync events in the current sync response
+
+ constructor(slidingSync, client, opts, syncOpts) {
+ this.slidingSync = slidingSync;
+ this.client = client;
+ _defineProperty(this, "opts", void 0);
+ _defineProperty(this, "syncOpts", void 0);
+ _defineProperty(this, "syncState", null);
+ _defineProperty(this, "syncStateData", void 0);
+ _defineProperty(this, "lastPos", null);
+ _defineProperty(this, "failCount", 0);
+ _defineProperty(this, "notifEvents", []);
+ this.opts = (0, _sync.defaultClientOpts)(opts);
+ this.syncOpts = (0, _sync.defaultSyncApiOpts)(syncOpts);
+ if (client.getNotifTimelineSet()) {
+ client.reEmitter.reEmit(client.getNotifTimelineSet(), [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]);
+ }
+ this.slidingSync.on(_slidingSync.SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this));
+ this.slidingSync.on(_slidingSync.SlidingSyncEvent.RoomData, this.onRoomData.bind(this));
+ const extensions = [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);
+ });
+ }
+ onRoomData(roomId, roomData) {
+ let room = this.client.store.getRoom(roomId);
+ if (!room) {
+ if (!roomData.initial) {
+ _logger.logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData);
+ return;
+ }
+ room = (0, _sync._createAndReEmitRoom)(this.client, roomId, this.opts);
+ }
+ this.processRoomData(this.client, room, roomData);
+ }
+ onLifecycle(state, resp, err) {
+ if (err) {
+ _logger.logger.debug("onLifecycle", state, err);
+ }
+ switch (state) {
+ case _slidingSync.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(_sync.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(_sync.SyncState.Syncing, {
+ oldSyncToken: this.lastPos,
+ nextSyncToken: resp.pos,
+ catchingUp: false,
+ fromCache: false
+ });
+ this.lastPos = resp.pos;
+ break;
+ case _slidingSync.SlidingSyncState.RequestFinished:
+ if (err) {
+ this.failCount += 1;
+ this.updateSyncState(this.failCount > FAILED_SYNC_ERROR_THRESHOLD ? _sync.SyncState.Error : _sync.SyncState.Reconnecting, {
+ error: new _httpApi.MatrixError(err)
+ });
+ if (this.shouldAbortSync(new _httpApi.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.
+ */
+ async syncLeftRooms() {
+ 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.
+ */
+ async peek(_roomId) {
+ return null; // TODO
+ }
+
+ /**
+ * Stop polling for updates in the peeked room. NOPs if there is no room being
+ * peeked.
+ */
+ stopPeeking() {
+ // TODO
+ }
+
+ /**
+ * Returns the current state of this sync object
+ * @see MatrixClient#event:"sync"
+ */
+ getSyncState() {
+ 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.
+ */
+ getSyncStateData() {
+ return this.syncStateData ?? null;
+ }
+
+ // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts
+
+ createRoom(roomId) {
+ // XXX cargoculted from sync.ts
+ const {
+ timelineSupport
+ } = this.client;
+ const room = new _room.Room(roomId, this.client, this.client.getUserId(), {
+ lazyLoadMembers: this.opts.lazyLoadMembers,
+ pendingEventOrdering: this.opts.pendingEventOrdering,
+ timelineSupport
+ });
+ this.client.reEmitter.reEmit(room, [_room.RoomEvent.Name, _room.RoomEvent.Redaction, _room.RoomEvent.RedactionCancelled, _room.RoomEvent.Receipt, _room.RoomEvent.Tags, _room.RoomEvent.LocalEchoUpdated, _room.RoomEvent.AccountData, _room.RoomEvent.MyMembership, _room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]);
+ this.registerStateListeners(room);
+ return room;
+ }
+ registerStateListeners(room) {
+ // 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, [_roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update]);
+ room.currentState.on(_roomState.RoomStateEvent.NewMember, (event, state, member) => {
+ member.user = this.client.getUser(member.userId) ?? undefined;
+ this.client.reEmitter.reEmit(member, [_roomMember.RoomMemberEvent.Name, _roomMember.RoomMemberEvent.Typing, _roomMember.RoomMemberEvent.PowerLevel, _roomMember.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);
+ } */
+
+ shouldAbortSync(error) {
+ if (error.errcode === "M_UNKNOWN_TOKEN") {
+ // The logout already happened, we just need to stop.
+ _logger.logger.warn("Token no longer valid - assuming logout");
+ this.stop();
+ this.updateSyncState(_sync.SyncState.Error, {
+ error
+ });
+ return true;
+ }
+ return false;
+ }
+ async processRoomData(client, room, roomData) {
+ 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 = []; // 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();
+ 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 = [];
+ const newEvents = [];
+ 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(_room.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(_room.NotificationCountType.Highlight) <= 0) {
+ room.setUnreadNotificationCount(_room.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);
+ await this.injectRoomEvents(room, inviteStateEvents);
+ if (roomData.initial) {
+ room.recalculate();
+ this.client.store.storeRoom(room);
+ this.client.emit(_client.ClientEvent.Room, room);
+ }
+ inviteStateEvents.forEach(e => {
+ this.client.emit(_client.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.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);
+ }
+ } */
+
+ await 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(_client.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 => {
+ client.emit(_client.ClientEvent.Event, e);
+ if (e.isState() && e.getType() == _event.EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) {
+ await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
+ }
+ };
+ await (0, _utils.promiseMapSeries)(stateEvents, processRoomEvent);
+ await (0, _utils.promiseMapSeries)(timelineEvents, processRoomEvent);
+ ephemeralEvents.forEach(function (e) {
+ client.emit(_client.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.
+ */
+ async injectRoomEvents(room, stateEventList, timelineEventList, numLive) {
+ 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 = [];
+ 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.
+ await room.addLiveEvents(timelineEventList, {
+ fromCache: true
+ });
+ if (liveTimelineEvents.length > 0) {
+ await room.addLiveEvents(liveTimelineEvents, {
+ fromCache: false
+ });
+ }
+ room.recalculate();
+
+ // resolve invites now we have set the latest state
+ this.resolveInvites(room);
+ }
+ resolveInvites(room) {
+ 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.
+ });
+ });
+ }
+ retryImmediately() {
+ return true;
+ }
+
+ /**
+ * Main entry point. Blocks until stop() is called.
+ */
+ async sync() {
+ _logger.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.logger.debug("Getting push rules...");
+ const result = await this.client.getPushRules();
+ _logger.logger.debug("Got push rules");
+ this.client.pushRules = result;
+ break;
+ } catch (err) {
+ _logger.logger.error("Getting push rules failed", err);
+ if (this.shouldAbortSync(err)) {
+ return;
+ }
+ }
+ }
+
+ // start syncing
+ await this.slidingSync.start();
+ }
+
+ /**
+ * Stops the sync object from syncing.
+ */
+ stop() {
+ _logger.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
+ */
+ updateSyncState(newState, data) {
+ const old = this.syncState;
+ this.syncState = newState;
+ this.syncStateData = data;
+ this.client.emit(_client.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.
+ */
+ addNotifications(timelineEventList) {
+ // 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.
+ */
+ purgeNotifications() {
+ this.notifEvents.sort(function (a, b) {
+ return a.getTs() - b.getTs();
+ });
+ this.notifEvents.forEach(event => {
+ this.client.getNotifTimelineSet()?.addLiveEvent(event);
+ });
+ this.notifEvents = [];
+ }
+}
+exports.SlidingSyncSdk = SlidingSyncSdk;
+function ensureNameEvent(client, roomId, roomData) {
+ // 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 === _event.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: _event.EventType.RoomName,
+ content: {
+ name: roomData.name
+ },
+ sender: client.getUserId(),
+ origin_server_ts: new Date().getTime()
+ });
+ return roomData;
+}
+// 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, roomId, events, decrypt = true) {
+ const mapper = client.getEventMapper({
+ decrypt
+ });
+ return events.map(function (e) {
+ e.room_id = roomId;
+ return mapper(e);
+ });
+}
+function processEphemeralEvents(client, roomId, ephEvents) {
+ const ephemeralEvents = mapEvents(client, roomId, ephEvents);
+ const room = client.getRoom(roomId);
+ if (!room) {
+ _logger.logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId);
+ return;
+ }
+ room.addEphemeralEvents(ephemeralEvents);
+ ephemeralEvents.forEach(e => {
+ client.emit(_client.ClientEvent.Event, e);
+ });
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js
new file mode 100644
index 0000000000..b6b77cc924
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js
@@ -0,0 +1,795 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SlidingSyncState = exports.SlidingSyncEvent = exports.SlidingSync = exports.MSC3575_WILDCARD = exports.MSC3575_STATE_KEY_ME = exports.MSC3575_STATE_KEY_LAZY = exports.ExtensionState = void 0;
+var _logger = require("./logger");
+var _typedEventEmitter = require("./models/typed-event-emitter");
+var _utils = require("./utils");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// /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;
+const MSC3575_WILDCARD = "*";
+exports.MSC3575_WILDCARD = MSC3575_WILDCARD;
+const MSC3575_STATE_KEY_ME = "$ME";
+exports.MSC3575_STATE_KEY_ME = MSC3575_STATE_KEY_ME;
+const MSC3575_STATE_KEY_LAZY = "$LAZY";
+
+/**
+ * Represents a subscription to a room or set of rooms. Controls which events are returned.
+ */
+
+/**
+ * Controls which rooms are returned in a given list.
+ */
+
+/**
+ * Represents a list subscription.
+ */
+
+/**
+ * A complete Sliding Sync request.
+ */
+
+/**
+ * A complete Sliding Sync response
+ */
+exports.MSC3575_STATE_KEY_LAZY = MSC3575_STATE_KEY_LAZY;
+let SlidingSyncState = /*#__PURE__*/function (SlidingSyncState) {
+ SlidingSyncState["RequestFinished"] = "FINISHED";
+ SlidingSyncState["Complete"] = "COMPLETE";
+ return SlidingSyncState;
+}({});
+/**
+ * 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.
+ */
+exports.SlidingSyncState = SlidingSyncState;
+class SlidingList {
+ /**
+ * Construct a new sliding list.
+ * @param list - The range, sort and filter values to use for this list.
+ */
+ constructor(list) {
+ _defineProperty(this, "list", void 0);
+ _defineProperty(this, "isModified", void 0);
+ // returned data
+ _defineProperty(this, "roomIndexToRoomId", {});
+ _defineProperty(this, "joinedCount", 0);
+ 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.
+ */
+ setModified(modified) {
+ 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
+ */
+ updateListRange(newRanges) {
+ 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
+ */
+ replaceList(list) {
+ 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.
+ */
+ getList(forceIncludeAllParams) {
+ 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
+ */
+ isIndexInRange(i) {
+ 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.
+ */
+let ExtensionState = /*#__PURE__*/function (ExtensionState) {
+ ExtensionState["PreProcess"] = "ExtState.PreProcess";
+ ExtensionState["PostProcess"] = "ExtState.PostProcess";
+ return ExtensionState;
+}({});
+/**
+ * An interface that must be satisfied to register extensions
+ */
+exports.ExtensionState = 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)
+ */
+let SlidingSyncEvent = /*#__PURE__*/function (SlidingSyncEvent) {
+ SlidingSyncEvent["RoomData"] = "SlidingSync.RoomData";
+ SlidingSyncEvent["Lifecycle"] = "SlidingSync.Lifecycle";
+ SlidingSyncEvent["List"] = "SlidingSync.List";
+ return SlidingSyncEvent;
+}({});
+exports.SlidingSyncEvent = SlidingSyncEvent;
+/**
+ * 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.
+ */
+class SlidingSync extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * 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.
+ */
+ constructor(proxyBaseUrl, lists, roomSubscriptionInfo, client, timeoutMS) {
+ super();
+ this.proxyBaseUrl = proxyBaseUrl;
+ this.roomSubscriptionInfo = roomSubscriptionInfo;
+ this.client = client;
+ this.timeoutMS = timeoutMS;
+ _defineProperty(this, "lists", void 0);
+ _defineProperty(this, "listModifiedCount", 0);
+ _defineProperty(this, "terminated", false);
+ // flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
+ _defineProperty(this, "needsResend", false);
+ // the txn_id to send with the next request.
+ _defineProperty(this, "txnId", 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.
+ _defineProperty(this, "txnIdDefers", []);
+ // map of extension name to req/resp handler
+ _defineProperty(this, "extensions", {});
+ _defineProperty(this, "desiredRoomSubscriptions", new Set());
+ // the *desired* room subscriptions
+ _defineProperty(this, "confirmedRoomSubscriptions", new Set());
+ // map of custom subscription name to the subscription
+ _defineProperty(this, "customSubscriptions", new Map());
+ // map of room ID to custom subscription name
+ _defineProperty(this, "roomIdToCustomSubscription", new Map());
+ _defineProperty(this, "pendingReq", void 0);
+ _defineProperty(this, "abortController", void 0);
+ this.lists = new Map();
+ 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.
+ */
+ addCustomSubscription(name, sub) {
+ if (this.customSubscriptions.has(name)) {
+ _logger.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.
+ */
+ useCustomSubscription(roomId, name) {
+ // 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
+ */
+ getListData(key) {
+ 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.
+ */
+ getListParams(key) {
+ 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)
+ */
+ setListRanges(key, ranges) {
+ 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)
+ */
+ setList(key, list) {
+ 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.
+ */
+ getRoomSubscriptions() {
+ 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)
+ */
+ modifyRoomSubscriptions(s) {
+ 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)
+ */
+ modifyRoomSubscriptionInfo(rs) {
+ this.roomSubscriptionInfo = rs;
+ this.confirmedRoomSubscriptions = new Set();
+ return this.resend();
+ }
+
+ /**
+ * Register an extension to send with the /sync request.
+ * @param ext - The extension to register.
+ */
+ registerExtension(ext) {
+ if (this.extensions[ext.name()]) {
+ throw new Error(`registerExtension: ${ext.name()} already exists as an extension`);
+ }
+ this.extensions[ext.name()] = ext;
+ }
+ getExtensionRequest(isInitial) {
+ const ext = {};
+ Object.keys(this.extensions).forEach(extName => {
+ ext[extName] = this.extensions[extName].onRequest(isInitial);
+ });
+ return ext;
+ }
+ onPreExtensionsResponse(ext) {
+ Object.keys(ext).forEach(extName => {
+ if (this.extensions[extName].when() == ExtensionState.PreProcess) {
+ this.extensions[extName].onResponse(ext[extName]);
+ }
+ });
+ }
+ onPostExtensionsResponse(ext) {
+ 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.
+ */
+ invokeRoomDataListeners(roomId, roomData) {
+ 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.
+ */
+ invokeLifecycleListeners(state, resp, err) {
+ this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
+ }
+ shiftRight(listKey, hi, low) {
+ 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];
+ }
+ }
+ }
+ shiftLeft(listKey, hi, low) {
+ 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];
+ }
+ }
+ }
+ removeEntry(listKey, index) {
+ 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];
+ }
+ addEntry(listKey, index) {
+ 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);
+ }
+ processListOps(list, listKey) {
+ let gapIndex = -1;
+ const listData = this.lists.get(listKey);
+ if (!listData) {
+ return;
+ }
+ list.ops.forEach(op => {
+ if (!listData) {
+ return;
+ }
+ switch (op.op) {
+ case "DELETE":
+ {
+ _logger.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.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.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.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.
+ */
+ resend() {
+ 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 = (0, _utils.defer)();
+ this.txnIdDefers.push(_objectSpread(_objectSpread({}, d), {}, {
+ txnId: this.txnId
+ }));
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+ return d.promise;
+ }
+ resolveTransactionDefers(txnId) {
+ 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.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.
+ */
+ stop() {
+ 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.
+ */
+ resetup() {
+ _logger.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(); // 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.
+ */
+ async start() {
+ this.abortController = new AbortController();
+ let currentPos;
+ while (!this.terminated) {
+ this.needsResend = false;
+ let doNotUpdateList = false;
+ let resp;
+ try {
+ const listModifiedCount = this.listModifiedCount;
+ const reqLists = {};
+ this.lists.forEach((l, key) => {
+ reqLists[key] = l.getList(false);
+ });
+ const reqBody = {
+ 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.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 => {
+ const list = this.lists.get(key);
+ if (!list || !resp) {
+ return;
+ }
+ list.joinedCount = resp.lists[key].count;
+ });
+ this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp);
+ } catch (err) {
+ if (err.httpStatus) {
+ this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, err);
+ if (err.httpStatus === 400) {
+ // session probably expired TODO: assign an errcode
+ // so drop state and re-request
+ this.resetup();
+ currentPos = undefined;
+ await (0, _utils.sleep)(50); // in case the 400 was for something else; don't tightloop
+ continue;
+ } // else fallthrough to generic error handling
+ } else if (this.needsResend || err.name === "AbortError") {
+ continue; // don't sleep as we caused this error by abort()ing the request.
+ }
+
+ _logger.logger.error(err);
+ await (0, _utils.sleep)(5000);
+ }
+ if (!resp) {
+ continue;
+ }
+ this.onPreExtensionsResponse(resp.extensions);
+ Object.keys(resp.rooms).forEach(roomId => {
+ this.invokeRoomDataListeners(roomId, resp.rooms[roomId]);
+ });
+ const listKeysWithUpdates = 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 => {
+ 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);
+ }
+ }
+}
+exports.SlidingSync = SlidingSync;
+const difference = (setA, setB) => {
+ const diff = new Set(setA);
+ for (const elem of setB) {
+ diff.delete(elem);
+ }
+ return diff;
+}; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js
new file mode 100644
index 0000000000..ecc5538734
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js
@@ -0,0 +1,569 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.LocalIndexedDBStoreBackend = void 0;
+var _syncAccumulator = require("../sync-accumulator");
+var _utils = require("../utils");
+var _indexeddbHelpers = require("../indexeddb-helpers");
+var _logger = require("../logger");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const DB_MIGRATIONS = [db => {
+ // 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 => {
+ const oobMembersStore = db.createObjectStore("oob_membership_events", {
+ keyPath: ["room_id", "state_key"]
+ });
+ oobMembersStore.createIndex("room", "room_id");
+}, db => {
+ db.createObjectStore("client_options", {
+ keyPath: ["clobber"]
+ });
+}, db => {
+ 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(store, keyRange, resultMapper) {
+ const query = store.openCursor(keyRange);
+ return new Promise((resolve, reject) => {
+ const results = [];
+ query.onerror = () => {
+ reject(new Error("Query failed: " + query.error));
+ };
+ // collect results
+ query.onsuccess = () => {
+ const cursor = query.result;
+ if (!cursor) {
+ resolve(results);
+ return; // end of results
+ }
+
+ results.push(resultMapper(cursor));
+ cursor.continue();
+ };
+ });
+}
+function txnAsPromise(txn) {
+ return new Promise((resolve, reject) => {
+ txn.oncomplete = function (event) {
+ resolve(event);
+ };
+ txn.onerror = function () {
+ reject(txn.error);
+ };
+ });
+}
+function reqAsEventPromise(req) {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = function (event) {
+ resolve(event);
+ };
+ req.onerror = function () {
+ reject(req.error);
+ };
+ });
+}
+function reqAsPromise(req) {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req);
+ req.onerror = err => reject(err);
+ });
+}
+function reqAsCursorPromise(req) {
+ return reqAsEventPromise(req).then(event => req.result);
+}
+class LocalIndexedDBStoreBackend {
+ static exists(indexedDB, dbName) {
+ dbName = "matrix-js-sdk:" + (dbName || "default");
+ return (0, _indexeddbHelpers.exists)(indexedDB, dbName);
+ }
+ /**
+ * 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.
+ */
+ constructor(indexedDB, dbName = "default") {
+ this.indexedDB = indexedDB;
+ _defineProperty(this, "dbName", void 0);
+ _defineProperty(this, "syncAccumulator", void 0);
+ _defineProperty(this, "db", void 0);
+ _defineProperty(this, "disconnected", true);
+ _defineProperty(this, "_isNewlyCreated", false);
+ _defineProperty(this, "syncToDatabasePromise", void 0);
+ _defineProperty(this, "pendingUserPresenceData", []);
+ this.dbName = "matrix-js-sdk:" + dbName;
+ this.syncAccumulator = new _syncAccumulator.SyncAccumulator();
+ }
+
+ /**
+ * Attempt to connect to the database. This can fail if the user does not
+ * grant permission.
+ * @returns Promise which resolves if successfully connected.
+ */
+ connect(onClose) {
+ if (!this.disconnected) {
+ _logger.logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`);
+ return Promise.resolve();
+ }
+ this.disconnected = false;
+ _logger.logger.log(`LocalIndexedDBStoreBackend.connect: connecting...`);
+ const req = this.indexedDB.open(this.dbName, VERSION);
+ req.onupgradeneeded = ev => {
+ const db = req.result;
+ const oldVersion = ev.oldVersion;
+ _logger.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 = () => {
+ _logger.logger.log(`can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`);
+ };
+ _logger.logger.log(`LocalIndexedDBStoreBackend.connect: awaiting connection...`);
+ return reqAsEventPromise(req).then(async () => {
+ _logger.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 = () => {
+ this.db?.close(); // this does not call onclose
+ this.disconnected = true;
+ this.db = undefined;
+ onClose?.();
+ };
+ this.db.onclose = () => {
+ this.disconnected = true;
+ this.db = undefined;
+ onClose?.();
+ };
+ await this.init();
+ });
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ isNewlyCreated() {
+ return Promise.resolve(this._isNewlyCreated);
+ }
+
+ /**
+ * Having connected, load initial data from the database and prepare for use
+ * @returns Promise which resolves on success
+ */
+ init() {
+ return Promise.all([this.loadAccountData(), this.loadSyncData()]).then(([accountData, syncData]) => {
+ _logger.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
+ */
+ getOutOfBandMembers(roomId) {
+ return new Promise((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 = [];
+ // 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 = () => {
+ 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 => {
+ reject(err);
+ };
+ }).then(events => {
+ _logger.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
+ */
+ async setOutOfBandMembers(roomId, membershipEvents) {
+ _logger.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.logger.log(`LL: backend done storing for ${roomId}!`);
+ }
+ async clearOutOfBandMembers(roomId) {
+ // 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 => (cursor?.primaryKey)[1]);
+ const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then(cursor => (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.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.
+ */
+ clearDatabase() {
+ return new Promise(resolve => {
+ _logger.logger.log(`Removing indexeddb instance: ${this.dbName}`);
+ const req = this.indexedDB.deleteDatabase(this.dbName);
+ req.onblocked = () => {
+ _logger.logger.log(`can't yet delete indexeddb ${this.dbName} because it is open elsewhere`);
+ };
+ req.onerror = () => {
+ // 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.logger.warn(`unable to delete js-sdk store indexeddb: ${req.error}`);
+ resolve();
+ };
+ req.onsuccess = () => {
+ _logger.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.
+ */
+ getSavedSync(copy = true) {
+ 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((0, _utils.deepCopy)(data));
+ } else {
+ return Promise.resolve(data);
+ }
+ }
+ getNextBatchToken() {
+ return Promise.resolve(this.syncAccumulator.getNextBatchToken());
+ }
+ setSyncData(syncData) {
+ 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.
+ */
+ async syncToDatabase(userTuples) {
+ if (this.syncToDatabasePromise) {
+ _logger.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;
+ }
+ async doSyncToDatabase(userTuples) {
+ 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.
+ */
+ persistSyncData(nextBatch, roomsData) {
+ _logger.logger.log("Persisting sync data up to", nextBatch);
+ return (0, _utils.promiseTry)(() => {
+ 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.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.
+ */
+ persistAccountData(accountData) {
+ return (0, _utils.promiseTry)(() => {
+ 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.
+ */
+ persistUserPresenceEvents(tuples) {
+ return (0, _utils.promiseTry)(() => {
+ 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.
+ */
+ getUserPresenceEvents() {
+ return (0, _utils.promiseTry)(() => {
+ 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.
+ */
+ loadAccountData() {
+ _logger.logger.log(`LocalIndexedDBStoreBackend: loading account data...`);
+ return (0, _utils.promiseTry)(() => {
+ const txn = this.db.transaction(["accountData"], "readonly");
+ const store = txn.objectStore("accountData");
+ return selectQuery(store, undefined, cursor => {
+ return cursor.value;
+ }).then(result => {
+ _logger.logger.log(`LocalIndexedDBStoreBackend: loaded account data`);
+ return result;
+ });
+ });
+ }
+
+ /**
+ * Load the sync data from the database.
+ * @returns An object with "roomsData" and "nextBatch" keys.
+ */
+ loadSyncData() {
+ _logger.logger.log(`LocalIndexedDBStoreBackend: loading sync data...`);
+ return (0, _utils.promiseTry)(() => {
+ const txn = this.db.transaction(["sync"], "readonly");
+ const store = txn.objectStore("sync");
+ return selectQuery(store, undefined, cursor => {
+ return cursor.value;
+ }).then(results => {
+ _logger.logger.log(`LocalIndexedDBStoreBackend: loaded sync data`);
+ if (results.length > 1) {
+ _logger.logger.warn("loadSyncData: More than 1 sync row found.");
+ }
+ return results.length > 0 ? results[0] : {};
+ });
+ });
+ }
+ getClientOptions() {
+ 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]);
+ });
+ }
+ async storeClientOptions(options) {
+ 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);
+ }
+ async saveToDeviceBatches(batches) {
+ 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);
+ }
+ async getOldestToDeviceBatch() {
+ 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;
+ return {
+ id: cursor.key,
+ txnId: resultBatch.txnId,
+ eventType: resultBatch.eventType,
+ batch: resultBatch.batch
+ };
+ }
+ async removeToDeviceBatch(id) {
+ const txn = this.db.transaction(["to_device_queue"], "readwrite");
+ const store = txn.objectStore("to_device_queue");
+ store.delete(id);
+ await txnAsPromise(txn);
+ }
+
+ /*
+ * Close the database
+ */
+ async destroy() {
+ this.db?.close();
+ }
+}
+exports.LocalIndexedDBStoreBackend = LocalIndexedDBStoreBackend; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js
new file mode 100644
index 0000000000..378a41e8d1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js
@@ -0,0 +1,200 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.RemoteIndexedDBStoreBackend = void 0;
+var _logger = require("../logger");
+var _utils = require("../utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class RemoteIndexedDBStoreBackend {
+ // Callback for when the IndexedDB gets closed unexpectedly
+
+ /**
+ * 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.
+ */
+ constructor(workerFactory, dbName) {
+ this.workerFactory = workerFactory;
+ this.dbName = dbName;
+ _defineProperty(this, "worker", void 0);
+ _defineProperty(this, "nextSeq", 0);
+ // The currently in-flight requests to the actual backend
+ _defineProperty(this, "inFlight", {});
+ // seq: promise
+ // Once we start connecting, we keep the promise and re-use it
+ // if we try to connect again
+ _defineProperty(this, "startPromise", void 0);
+ _defineProperty(this, "onWorkerMessage", ev => {
+ 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.logger.error("Got reply from worker with no seq");
+ return;
+ }
+ const def = this.inFlight[msg.seq];
+ if (def === undefined) {
+ _logger.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.logger.warn("Unrecognised message from worker: ", msg);
+ }
+ });
+ }
+
+ /**
+ * Attempt to connect to the database. This can fail if the user does not
+ * grant permission.
+ * @returns Promise which resolves if successfully connected.
+ */
+ connect(onClose) {
+ 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.
+ */
+ clearDatabase() {
+ return this.ensureStarted().then(() => this.doCmd("clearDatabase"));
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ isNewlyCreated() {
+ 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.
+ */
+ getSavedSync() {
+ return this.doCmd("getSavedSync");
+ }
+ getNextBatchToken() {
+ return this.doCmd("getNextBatchToken");
+ }
+ setSyncData(syncData) {
+ return this.doCmd("setSyncData", [syncData]);
+ }
+ syncToDatabase(userTuples) {
+ 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
+ */
+ getOutOfBandMembers(roomId) {
+ 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
+ */
+ setOutOfBandMembers(roomId, membershipEvents) {
+ return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]);
+ }
+ clearOutOfBandMembers(roomId) {
+ return this.doCmd("clearOutOfBandMembers", [roomId]);
+ }
+ getClientOptions() {
+ return this.doCmd("getClientOptions");
+ }
+ storeClientOptions(options) {
+ 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.
+ */
+ getUserPresenceEvents() {
+ return this.doCmd("getUserPresenceEvents");
+ }
+ async saveToDeviceBatches(batches) {
+ return this.doCmd("saveToDeviceBatches", [batches]);
+ }
+ async getOldestToDeviceBatch() {
+ return this.doCmd("getOldestToDeviceBatch");
+ }
+ async removeToDeviceBatch(id) {
+ return this.doCmd("removeToDeviceBatch", [id]);
+ }
+ ensureStarted() {
+ 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.logger.log("IndexedDB worker is ready");
+ });
+ }
+ return this.startPromise;
+ }
+ doCmd(command, args) {
+ // wrap in a q so if the postMessage throws,
+ // the promise automatically gets rejected
+ return Promise.resolve().then(() => {
+ const seq = this.nextSeq++;
+ const def = (0, _utils.defer)();
+ this.inFlight[seq] = def;
+ this.worker?.postMessage({
+ command,
+ seq,
+ args
+ });
+ return def.promise;
+ });
+ }
+ /*
+ * Destroy the web worker
+ */
+ async destroy() {
+ this.worker?.terminate();
+ }
+}
+exports.RemoteIndexedDBStoreBackend = RemoteIndexedDBStoreBackend; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js
new file mode 100644
index 0000000000..4708d58936
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js
@@ -0,0 +1,151 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.IndexedDBStoreWorker = void 0;
+var _indexeddbLocalBackend = require("./indexeddb-local-backend");
+var _logger = require("../logger");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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 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.
+ *
+ */
+class IndexedDBStoreWorker {
+ /**
+ * @param postMessage - The web worker postMessage function that
+ * should be used to communicate back to the main script.
+ */
+ constructor(postMessage) {
+ this.postMessage = postMessage;
+ _defineProperty(this, "backend", void 0);
+ _defineProperty(this, "onClose", () => {
+ 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
+ */
+ _defineProperty(this, "onMessage", ev => {
+ const msg = ev.data;
+ let prom;
+ 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 _indexeddbLocalBackend.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.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
+ }
+ });
+ });
+ });
+ }
+}
+exports.IndexedDBStoreWorker = IndexedDBStoreWorker; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js
new file mode 100644
index 0000000000..3c52a70546
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js
@@ -0,0 +1,329 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.IndexedDBStore = void 0;
+var _memory = require("./memory");
+var _indexeddbLocalBackend = require("./indexeddb-local-backend");
+var _indexeddbRemoteBackend = require("./indexeddb-remote-backend");
+var _user = require("../models/user");
+var _event = require("../models/event");
+var _logger = require("../logger");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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 */
+/**
+ * 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
+
+class IndexedDBStore extends _memory.MemoryStore {
+ static exists(indexedDB, dbName) {
+ return _indexeddbLocalBackend.LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
+ }
+
+ /**
+ * The backend instance.
+ * Call through to this API if you need to perform specific indexeddb actions like deleting the database.
+ */
+
+ /**
+ * 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.
+ */
+ constructor(opts) {
+ super(opts);
+ _defineProperty(this, "backend", void 0);
+ _defineProperty(this, "startedUp", false);
+ _defineProperty(this, "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.
+ _defineProperty(this, "userModifiedMap", {});
+ // user_id : timestamp
+ _defineProperty(this, "emitter", new _typedEventEmitter.TypedEventEmitter());
+ _defineProperty(this, "on", this.emitter.on.bind(this.emitter));
+ _defineProperty(this, "onClose", () => {
+ 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.
+ */
+ _defineProperty(this, "getSavedSync", this.degradable(() => {
+ return this.backend.getSavedSync();
+ }, "getSavedSync"));
+ /** @returns whether or not the database was newly created in this session. */
+ _defineProperty(this, "isNewlyCreated", this.degradable(() => {
+ return this.backend.isNewlyCreated();
+ }, "isNewlyCreated"));
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ _defineProperty(this, "getSavedSyncToken", this.degradable(() => {
+ return this.backend.getNextBatchToken();
+ }, "getSavedSyncToken"));
+ /**
+ * Delete all data from this store.
+ * @returns Promise which resolves if the data was deleted from the database.
+ */
+ _defineProperty(this, "deleteAllData", this.degradable(() => {
+ super.deleteAllData();
+ return this.backend.clearDatabase().then(() => {
+ _logger.logger.log("Deleted indexeddb data.");
+ }, err => {
+ _logger.logger.error(`Failed to delete indexeddb data: ${err}`);
+ throw err;
+ });
+ }));
+ _defineProperty(this, "reallySave", this.degradable(() => {
+ 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 = [];
+ 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);
+ }));
+ _defineProperty(this, "setSyncData", this.degradable(syncData => {
+ 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
+ */
+ _defineProperty(this, "getOutOfBandMembers", this.degradable(roomId => {
+ 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
+ */
+ _defineProperty(this, "setOutOfBandMembers", this.degradable((roomId, membershipEvents) => {
+ super.setOutOfBandMembers(roomId, membershipEvents);
+ return this.backend.setOutOfBandMembers(roomId, membershipEvents);
+ }, "setOutOfBandMembers"));
+ _defineProperty(this, "clearOutOfBandMembers", this.degradable(roomId => {
+ super.clearOutOfBandMembers(roomId);
+ return this.backend.clearOutOfBandMembers(roomId);
+ }, "clearOutOfBandMembers"));
+ _defineProperty(this, "getClientOptions", this.degradable(() => {
+ return this.backend.getClientOptions();
+ }, "getClientOptions"));
+ _defineProperty(this, "storeClientOptions", this.degradable(options => {
+ super.storeClientOptions(options);
+ return this.backend.storeClientOptions(options);
+ }, "storeClientOptions"));
+ if (!opts.indexedDB) {
+ throw new Error("Missing required option: indexedDB");
+ }
+ if (opts.workerFactory) {
+ this.backend = new _indexeddbRemoteBackend.RemoteIndexedDBStoreBackend(opts.workerFactory, opts.dbName);
+ } else {
+ this.backend = new _indexeddbLocalBackend.LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName);
+ }
+ }
+ /**
+ * @returns Resolved when loaded from indexed db.
+ */
+ startup() {
+ if (this.startedUp) {
+ _logger.logger.log(`IndexedDBStore.startup: already started`);
+ return Promise.resolve();
+ }
+ _logger.logger.log(`IndexedDBStore.startup: connecting to backend`);
+ return this.backend.connect(this.onClose).then(() => {
+ _logger.logger.log(`IndexedDBStore.startup: loading presence events`);
+ return this.backend.getUserPresenceEvents();
+ }).then(userPresenceEvents => {
+ _logger.logger.log(`IndexedDBStore.startup: processing presence events`);
+ userPresenceEvents.forEach(([userId, rawEvent]) => {
+ const u = new _user.User(userId);
+ if (rawEvent) {
+ u.setPresenceEvent(new _event.MatrixEvent(rawEvent));
+ }
+ this.userModifiedMap[u.userId] = u.getLastModifiedTime();
+ this.storeUser(u);
+ });
+ this.startedUp = true;
+ });
+ }
+
+ /*
+ * Close the database and destroy any associated workers
+ */
+ destroy() {
+ return this.backend.destroy();
+ }
+ /**
+ * 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).
+ */
+ wantsSave() {
+ 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)
+ */
+ save(force = false) {
+ if (force || this.wantsSave()) {
+ return this.reallySave();
+ }
+ return Promise.resolve();
+ }
+ /**
+ * 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.
+ */
+ degradable(func, fallback) {
+ const fallbackFn = fallback ? super[fallback] : null;
+ return async (...args) => {
+ try {
+ return await func.call(this, ...args);
+ } catch (e) {
+ _logger.logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
+ this.emitter.emit("degraded", e);
+ 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.logger.log("IndexedDBStore trying to delete degraded data");
+ await this.backend.clearDatabase();
+ _logger.logger.log("IndexedDBStore delete after degrading succeeded");
+ } catch (e) {
+ _logger.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.
+ async getPendingEvents(roomId) {
+ if (!this.localStorage) return super.getPendingEvents(roomId);
+ const serialized = this.localStorage.getItem(pendingEventsKey(roomId));
+ if (serialized) {
+ try {
+ return JSON.parse(serialized);
+ } catch (e) {
+ _logger.logger.error("Could not parse persisted pending events", e);
+ }
+ }
+ return [];
+ }
+ async setPendingEvents(roomId, events) {
+ 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));
+ }
+ }
+ saveToDeviceBatches(batches) {
+ return this.backend.saveToDeviceBatches(batches);
+ }
+ getOldestToDeviceBatch() {
+ return this.backend.getOldestToDeviceBatch();
+ }
+ removeToDeviceBatch(id) {
+ return this.backend.removeToDeviceBatch(id);
+ }
+}
+
+/**
+ * @param roomId - ID of the current room
+ * @returns Storage key to retrieve pending events
+ */
+exports.IndexedDBStore = IndexedDBStore;
+function pendingEventsKey(roomId) {
+ return `mx_pending_events_${roomId}`;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js
new file mode 100644
index 0000000000..bb366d32dd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js
@@ -0,0 +1,43 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.localStorageErrorsEventsEmitter = exports.LocalStorageErrors = void 0;
+var _typedEventEmitter = require("../models/typed-event-emitter");
+/*
+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.
+*/
+let LocalStorageErrors = /*#__PURE__*/function (LocalStorageErrors) {
+ LocalStorageErrors["Global"] = "Global";
+ LocalStorageErrors["SetItemError"] = "setItem";
+ LocalStorageErrors["GetItemError"] = "getItem";
+ LocalStorageErrors["RemoveItemError"] = "removeItem";
+ LocalStorageErrors["ClearError"] = "clear";
+ LocalStorageErrors["QuotaExceededError"] = "QuotaExceededError";
+ return LocalStorageErrors;
+}({});
+exports.LocalStorageErrors = LocalStorageErrors;
+/**
+ * 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.TypedEventEmitter {}
+const localStorageErrorsEventsEmitter = new LocalStorageErrorsEventsEmitter();
+exports.localStorageErrorsEventsEmitter = localStorageErrorsEventsEmitter; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js
new file mode 100644
index 0000000000..68836ac093
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js
@@ -0,0 +1,418 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MemoryStore = void 0;
+var _user = require("../models/user");
+var _roomState = require("../models/room-state");
+var _utils = require("../utils");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+function isValidFilterId(filterId) {
+ 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";
+}
+class MemoryStore {
+ /**
+ * Construct a new in-memory data store for the Matrix Client.
+ * @param opts - Config options
+ */
+ constructor(opts = {}) {
+ _defineProperty(this, "rooms", {});
+ // roomId: Room
+ _defineProperty(this, "users", {});
+ // userId: User
+ _defineProperty(this, "syncToken", null);
+ // userId: {
+ // filterId: Filter
+ // }
+ _defineProperty(this, "filters", new _utils.MapWithDefault(() => new Map()));
+ _defineProperty(this, "accountData", new Map());
+ // type: content
+ _defineProperty(this, "localStorage", void 0);
+ _defineProperty(this, "oobMembers", new Map());
+ // roomId: [member events]
+ _defineProperty(this, "pendingEvents", {});
+ _defineProperty(this, "clientOptions", void 0);
+ _defineProperty(this, "pendingToDeviceBatches", []);
+ _defineProperty(this, "nextToDeviceBatchId", 0);
+ /**
+ * Called when a room member in a room being tracked by this store has been
+ * updated.
+ */
+ _defineProperty(this, "onRoomMember", (event, state, member) => {
+ 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.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;
+ });
+ this.localStorage = opts.localStorage;
+ }
+
+ /**
+ * Retrieve the token to stream from.
+ * @returns The token or null.
+ */
+ getSyncToken() {
+ return this.syncToken;
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ isNewlyCreated() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Set the token to stream from.
+ * @param token - The token to stream from.
+ */
+ setSyncToken(token) {
+ this.syncToken = token;
+ }
+
+ /**
+ * Store the given room.
+ * @param room - The room to be stored. All properties must be stored.
+ */
+ storeRoom(room) {
+ 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(_roomState.RoomStateEvent.Members, this.onRoomMember);
+ // add existing members
+ room.currentState.getMembers().forEach(m => {
+ this.onRoomMember(null, room.currentState, m);
+ });
+ }
+ /**
+ * Retrieve a room by its' room ID.
+ * @param roomId - The room ID.
+ * @returns The room or null.
+ */
+ getRoom(roomId) {
+ return this.rooms[roomId] || null;
+ }
+
+ /**
+ * Retrieve all known rooms.
+ * @returns A list of rooms, which may be empty.
+ */
+ getRooms() {
+ return Object.values(this.rooms);
+ }
+
+ /**
+ * Permanently delete a room.
+ */
+ removeRoom(roomId) {
+ if (this.rooms[roomId]) {
+ this.rooms[roomId].currentState.removeListener(_roomState.RoomStateEvent.Members, this.onRoomMember);
+ }
+ delete this.rooms[roomId];
+ }
+
+ /**
+ * Retrieve a summary of all the rooms.
+ * @returns A summary of each room.
+ */
+ getRoomSummaries() {
+ return Object.values(this.rooms).map(function (room) {
+ return room.summary;
+ });
+ }
+
+ /**
+ * Store a User.
+ * @param user - The user to store.
+ */
+ storeUser(user) {
+ this.users[user.userId] = user;
+ }
+
+ /**
+ * Retrieve a User by its' user ID.
+ * @param userId - The user ID.
+ * @returns The user or null.
+ */
+ getUser(userId) {
+ return this.users[userId] || null;
+ }
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, which may be empty.
+ */
+ getUsers() {
+ 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.
+ */
+ scrollback(room, limit) {
+ 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.
+ */
+ storeEvents(room, events, token, toStart) {
+ // no-op because they've already been added to the room instance.
+ }
+
+ /**
+ * Store a filter.
+ */
+ storeFilter(filter) {
+ if (!filter?.userId || !filter?.filterId) return;
+ this.filters.getOrCreate(filter.userId).set(filter.filterId, filter);
+ }
+
+ /**
+ * Retrieve a filter.
+ * @returns A filter or null.
+ */
+ getFilter(userId, filterId) {
+ 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.
+ */
+ getFilterIdByName(filterName) {
+ 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.
+ */
+ setFilterIdByName(filterName, filterId) {
+ 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.
+ */
+ storeAccountDataEvents(events) {
+ 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
+ */
+ getAccountData(eventType) {
+ 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.
+ */
+ setSyncData(syncData) {
+ return Promise.resolve();
+ }
+
+ /**
+ * We never want to save becase we have nothing to save to.
+ *
+ * @returns If the store wants to save
+ */
+ wantsSave() {
+ 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)
+ */
+ save(force) {
+ return Promise.resolve();
+ }
+
+ /**
+ * Startup does nothing as this store doesn't require starting up.
+ * @returns An immediately resolved promise.
+ */
+ startup() {
+ 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.
+ */
+ getSavedSync() {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ getSavedSyncToken() {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Delete all data from this store.
+ * @returns An immediately resolved promise.
+ */
+ deleteAllData() {
+ this.rooms = {
+ // roomId: Room
+ };
+ this.users = {
+ // userId: User
+ };
+ this.syncToken = null;
+ this.filters = new _utils.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
+ */
+ getOutOfBandMembers(roomId) {
+ 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
+ */
+ setOutOfBandMembers(roomId, membershipEvents) {
+ this.oobMembers.set(roomId, membershipEvents);
+ return Promise.resolve();
+ }
+ clearOutOfBandMembers(roomId) {
+ this.oobMembers.delete(roomId);
+ return Promise.resolve();
+ }
+ getClientOptions() {
+ return Promise.resolve(this.clientOptions);
+ }
+ storeClientOptions(options) {
+ this.clientOptions = Object.assign({}, options);
+ return Promise.resolve();
+ }
+ async getPendingEvents(roomId) {
+ return this.pendingEvents[roomId] ?? [];
+ }
+ async setPendingEvents(roomId, events) {
+ this.pendingEvents[roomId] = events;
+ }
+ saveToDeviceBatches(batches) {
+ for (const batch of batches) {
+ this.pendingToDeviceBatches.push({
+ id: this.nextToDeviceBatchId++,
+ eventType: batch.eventType,
+ txnId: batch.txnId,
+ batch: batch.batch
+ });
+ }
+ return Promise.resolve();
+ }
+ async getOldestToDeviceBatch() {
+ if (this.pendingToDeviceBatches.length === 0) return null;
+ return this.pendingToDeviceBatches[0];
+ }
+ removeToDeviceBatch(id) {
+ this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter(batch => batch.id !== id);
+ return Promise.resolve();
+ }
+ async destroy() {
+ // Nothing to do
+ }
+}
+exports.MemoryStore = MemoryStore; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js b/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js
new file mode 100644
index 0000000000..9bf1df937d
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js
@@ -0,0 +1,262 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.StubStore = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+ */
+
+/**
+ * Construct a stub store. This does no-ops on most store methods.
+ */
+class StubStore {
+ constructor() {
+ _defineProperty(this, "accountData", new Map());
+ // stub
+ _defineProperty(this, "fromToken", null);
+ }
+ /** @returns whether or not the database was newly created in this session. */
+ isNewlyCreated() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Get the sync token.
+ */
+ getSyncToken() {
+ return this.fromToken;
+ }
+
+ /**
+ * Set the sync token.
+ */
+ setSyncToken(token) {
+ this.fromToken = token;
+ }
+
+ /**
+ * No-op.
+ */
+ storeRoom(room) {}
+
+ /**
+ * No-op.
+ */
+ getRoom(roomId) {
+ return null;
+ }
+
+ /**
+ * No-op.
+ * @returns An empty array.
+ */
+ getRooms() {
+ return [];
+ }
+
+ /**
+ * Permanently delete a room.
+ */
+ removeRoom(roomId) {
+ return;
+ }
+
+ /**
+ * No-op.
+ * @returns An empty array.
+ */
+ getRoomSummaries() {
+ return [];
+ }
+
+ /**
+ * No-op.
+ */
+ storeUser(user) {}
+
+ /**
+ * No-op.
+ */
+ getUser(userId) {
+ return null;
+ }
+
+ /**
+ * No-op.
+ */
+ getUsers() {
+ return [];
+ }
+
+ /**
+ * No-op.
+ */
+ scrollback(room, limit) {
+ 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.
+ */
+ storeEvents(room, events, token, toStart) {}
+
+ /**
+ * Store a filter.
+ */
+ storeFilter(filter) {}
+
+ /**
+ * Retrieve a filter.
+ * @returns A filter or null.
+ */
+ getFilter(userId, filterId) {
+ return null;
+ }
+
+ /**
+ * Retrieve a filter ID with the given name.
+ * @param filterName - The filter name.
+ * @returns The filter ID or null.
+ */
+ getFilterIdByName(filterName) {
+ return null;
+ }
+
+ /**
+ * Set a filter name to ID mapping.
+ */
+ setFilterIdByName(filterName, filterId) {}
+
+ /**
+ * Store user-scoped account data events
+ * @param events - The events to store.
+ */
+ storeAccountDataEvents(events) {}
+
+ /**
+ * Get account data event by event type
+ * @param eventType - The event type being queried
+ */
+ getAccountData(eventType) {
+ return undefined;
+ }
+
+ /**
+ * setSyncData does nothing as there is no backing data store.
+ *
+ * @param syncData - The sync data
+ * @returns An immediately resolved promise.
+ */
+ setSyncData(syncData) {
+ return Promise.resolve();
+ }
+
+ /**
+ * We never want to save because we have nothing to save to.
+ *
+ * @returns If the store wants to save
+ */
+ wantsSave() {
+ return false;
+ }
+
+ /**
+ * Save does nothing as there is no backing data store.
+ */
+ save() {
+ return Promise.resolve();
+ }
+
+ /**
+ * Startup does nothing.
+ * @returns An immediately resolved promise.
+ */
+ startup() {
+ 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.
+ */
+ getSavedSync() {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ getSavedSyncToken() {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Delete all data from this store. Does nothing since this store
+ * doesn't store anything.
+ * @returns An immediately resolved promise.
+ */
+ deleteAllData() {
+ return Promise.resolve();
+ }
+ getOutOfBandMembers() {
+ return Promise.resolve(null);
+ }
+ setOutOfBandMembers(roomId, membershipEvents) {
+ return Promise.resolve();
+ }
+ clearOutOfBandMembers() {
+ return Promise.resolve();
+ }
+ getClientOptions() {
+ return Promise.resolve(undefined);
+ }
+ storeClientOptions(options) {
+ return Promise.resolve();
+ }
+ async getPendingEvents(roomId) {
+ return [];
+ }
+ setPendingEvents(roomId, events) {
+ return Promise.resolve();
+ }
+ async saveToDeviceBatches(batch) {
+ return Promise.resolve();
+ }
+ getOldestToDeviceBatch() {
+ return Promise.resolve(null);
+ }
+ async removeToDeviceBatch(id) {
+ return Promise.resolve();
+ }
+ async destroy() {
+ // Nothing to do
+ }
+}
+exports.StubStore = StubStore; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js
new file mode 100644
index 0000000000..8fb0d93909
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js
@@ -0,0 +1,474 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SyncAccumulator = exports.Category = void 0;
+var _logger = require("./logger");
+var _utils = require("./utils");
+var _sync = require("./@types/sync");
+var _receiptAccumulator = require("./receipt-accumulator");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ Copyright 2017 - 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 SyncAccumulator} for the public class.
+ */
+/* eslint-disable camelcase */
+/* eslint-enable camelcase */
+let Category = /*#__PURE__*/function (Category) {
+ Category["Invite"] = "invite";
+ Category["Leave"] = "leave";
+ Category["Join"] = "join";
+ return Category;
+}({});
+exports.Category = Category;
+function isTaggedEvent(event) {
+ 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.
+ */
+class SyncAccumulator {
+ constructor(opts = {}) {
+ this.opts = opts;
+ _defineProperty(this, "accountData", {});
+ // $event_type: Object
+ _defineProperty(this, "inviteRooms", {});
+ // $roomId: { ... sync 'invite' json data ... }
+ _defineProperty(this, "joinRooms", {});
+ // 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.
+ _defineProperty(this, "nextBatch", null);
+ this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50;
+ }
+ accumulate(syncResponse, fromDatabase = false) {
+ this.accumulateRooms(syncResponse, fromDatabase);
+ this.accumulateAccountData(syncResponse);
+ this.nextBatch = syncResponse.next_batch;
+ }
+ accumulateAccountData(syncResponse) {
+ 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
+ */
+ accumulateRooms(syncResponse, fromDatabase = false) {
+ 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);
+ });
+ }
+ }
+ accumulateRoom(roomId, category, data, fromDatabase = false) {
+ // 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);
+ 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, fromDatabase);
+ break;
+ case Category.Leave:
+ if (this.inviteRooms[roomId]) {
+ // (4)
+ delete this.inviteRooms[roomId];
+ } else {
+ // (2)
+ delete this.joinRooms[roomId];
+ }
+ break;
+ default:
+ _logger.logger.error("Unknown cateogory: ", category);
+ }
+ }
+ accumulateInviteState(roomId, data) {
+ 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.
+ accumulateJoinState(roomId, data, fromDatabase = false) {
+ // 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: {},
+ _receipts: new _receiptAccumulator.ReceiptAccumulator()
+ };
+ }
+ 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[_sync.UNREAD_THREAD_NOTIFICATIONS.stable] ?? data[_sync.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];
+ }
+
+ // 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).
+
+ // Persist the receipts
+ currentData._receipts.consumeEphemeralEvents(data.ephemeral?.events);
+
+ // 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;
+ 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.
+ */
+ getJSON(forDatabase = false) {
+ const data = {
+ 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 = {
+ ephemeral: {
+ events: []
+ },
+ account_data: {
+ events: []
+ },
+ state: {
+ events: []
+ },
+ timeline: {
+ events: [],
+ prev_batch: null
+ },
+ unread_notifications: roomData._unreadNotifications,
+ unread_thread_notifications: roomData._unreadThreadNotifications,
+ summary: roomData._summary
+ };
+ // Add account data
+ Object.keys(roomData._accountData).forEach(evType => {
+ roomJson.account_data.events.push(roomData._accountData[evType]);
+ });
+ const receiptEvent = roomData._receipts.buildAccumulatedReceiptEvent(roomId);
+
+ // add only if we have some receipt data
+ if (receiptEvent) {
+ roomJson.ephemeral.events.push(receiptEvent);
+ }
+
+ // 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;
+ 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.state_key === null || timelineEvent.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 = (0, _utils.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 = [];
+ Object.keys(this.accountData).forEach(evType => {
+ accData.push(this.accountData[evType]);
+ });
+ return {
+ nextBatch: this.nextBatch,
+ roomsData: data,
+ accountData: accData
+ };
+ }
+ getNextBatchToken() {
+ return this.nextBatch;
+ }
+}
+exports.SyncAccumulator = SyncAccumulator;
+function setState(eventMap, event) {
+ if (event.state_key === null || event.state_key === undefined || !event.type) {
+ return;
+ }
+ if (!eventMap[event.type]) {
+ eventMap[event.type] = Object.create(null);
+ }
+ eventMap[event.type][event.state_key] = event;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js b/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js
new file mode 100644
index 0000000000..0e68952eeb
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/sync.js
@@ -0,0 +1,1594 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SyncState = exports.SyncApi = void 0;
+exports._createAndReEmitRoom = _createAndReEmitRoom;
+exports.defaultClientOpts = defaultClientOpts;
+exports.defaultSyncApiOpts = defaultSyncApiOpts;
+var _user = require("./models/user");
+var _room = require("./models/room");
+var _utils = require("./utils");
+var _filter = require("./filter");
+var _eventTimeline = require("./models/event-timeline");
+var _logger = require("./logger");
+var _errors = require("./errors");
+var _client = require("./client");
+var _httpApi = require("./http-api");
+var _event = require("./@types/event");
+var _roomState = require("./models/room-state");
+var _roomMember = require("./models/room-member");
+var _beacon = require("./models/beacon");
+var _sync = require("./@types/sync");
+var _feature = require("./feature");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+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;
+let SyncState = /*#__PURE__*/function (SyncState) {
+ SyncState["Error"] = "ERROR";
+ SyncState["Prepared"] = "PREPARED";
+ SyncState["Stopped"] = "STOPPED";
+ SyncState["Syncing"] = "SYNCING";
+ SyncState["Catchup"] = "CATCHUP";
+ SyncState["Reconnecting"] = "RECONNECTING";
+ return SyncState;
+}({}); // 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.
+exports.SyncState = SyncState;
+const MSC2716_ROOM_VERSIONS = ["org.matrix.msc2716v3"];
+function getFilterName(userId, suffix) {
+ // 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) {
+ if (!DEBUG) return;
+ _logger.logger.log(...params);
+}
+
+/**
+ * Options passed into the constructor of SyncApi by MatrixClient
+ */
+var SetPresence = /*#__PURE__*/function (SetPresence) {
+ SetPresence["Offline"] = "offline";
+ SetPresence["Online"] = "online";
+ SetPresence["Unavailable"] = "unavailable";
+ return SetPresence;
+}(SetPresence || {});
+/** add default settings to an IStoredClientOpts */
+function defaultClientOpts(opts) {
+ return _objectSpread({
+ initialSyncLimit: 8,
+ resolveInvitesToProfiles: false,
+ pollTimeout: 30 * 1000,
+ pendingEventOrdering: _client.PendingEventOrdering.Chronological,
+ threadSupport: false
+ }, opts);
+}
+function defaultSyncApiOpts(syncOpts) {
+ return _objectSpread({
+ canResetEntireTimeline: _roomId => false
+ }, syncOpts);
+}
+class SyncApi {
+ // 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
+ */
+ constructor(client, opts, syncOpts) {
+ this.client = client;
+ _defineProperty(this, "opts", void 0);
+ _defineProperty(this, "syncOpts", void 0);
+ _defineProperty(this, "_peekRoom", null);
+ _defineProperty(this, "currentSyncRequest", void 0);
+ _defineProperty(this, "abortController", void 0);
+ _defineProperty(this, "syncState", null);
+ _defineProperty(this, "syncStateData", void 0);
+ // additional data (eg. error object for failed sync)
+ _defineProperty(this, "catchingUp", false);
+ _defineProperty(this, "running", false);
+ _defineProperty(this, "keepAliveTimer", void 0);
+ _defineProperty(this, "connectionReturnedDefer", void 0);
+ _defineProperty(this, "notifEvents", []);
+ // accumulator of sync events in the current sync response
+ _defineProperty(this, "failedSyncCount", 0);
+ // Number of consecutive failed /sync requests
+ _defineProperty(this, "storeIsInvalid", false);
+ _defineProperty(this, "getPushRules", async () => {
+ try {
+ debuglog("Getting push rules...");
+ const result = await this.client.getPushRules();
+ debuglog("Got push rules");
+ this.client.pushRules = result;
+ } catch (err) {
+ _logger.logger.error("Getting push rules failed", err);
+ if (this.shouldAbortSync(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, err);
+ return this.getPushRules(); // try again
+ }
+ });
+ _defineProperty(this, "buildDefaultFilter", () => {
+ const filter = new _filter.Filter(this.client.credentials.userId);
+ if (this.client.canSupport.get(_feature.Feature.ThreadUnreadNotifications) !== _feature.ServerSupport.Unsupported) {
+ filter.setUnreadThreadNotifications(true);
+ }
+ return filter;
+ });
+ _defineProperty(this, "checkLazyLoadStatus", async () => {
+ 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 _errors.InvalidStoreError(_errors.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.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.logger.error("Storing client options failed", err);
+ throw err;
+ }
+ });
+ _defineProperty(this, "getFilter", async () => {
+ debuglog("Getting filter...");
+ let filter;
+ if (this.opts.filter) {
+ filter = this.opts.filter;
+ } else {
+ filter = this.buildDefaultFilter();
+ }
+ let filterId;
+ try {
+ filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId), filter);
+ } catch (err) {
+ _logger.logger.error("Getting filter failed", err);
+ if (this.shouldAbortSync(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, err);
+ return this.getFilter(); // try again
+ }
+
+ return {
+ filter,
+ filterId
+ };
+ });
+ _defineProperty(this, "savedSyncPromise", void 0);
+ /**
+ * 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.
+ */
+ _defineProperty(this, "onOnline", () => {
+ debuglog("Browser thinks we are back online");
+ this.startKeepAlives(0);
+ });
+ this.opts = defaultClientOpts(opts);
+ this.syncOpts = defaultSyncApiOpts(syncOpts);
+ if (client.getNotifTimelineSet()) {
+ client.reEmitter.reEmit(client.getNotifTimelineSet(), [_room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset]);
+ }
+ }
+ createRoom(roomId) {
+ const room = _createAndReEmitRoom(this.client, roomId, this.opts);
+ room.on(_roomState.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
+ */
+ onMarkerStateEvent(room, markerEvent, {
+ timelineWasEmpty
+ } = {}) {
+ // 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.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.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(_room.RoomEvent.HistoryImportedWithinTimeline, markerEvent, room);
+ } else {
+ _logger.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.
+ */
+ async syncLeftRooms() {
+ const client = this.client;
+
+ // grab a filter with limit=1 and include_leave=true
+ const filter = new _filter.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 = {
+ timeout: 0,
+ // don't want to block since this is a single isolated req
+ filter: filterId
+ };
+ const data = await client.http.authedRequest(_httpApi.Method.Get, "/sync", qps, undefined, {
+ localTimeoutMs
+ });
+ let leaveRooms = [];
+ 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.EventTimeline.BACKWARDS);
+ await this.injectRoomEvents(room, stateEvents, events);
+ room.recalculate();
+ client.store.storeRoom(room);
+ client.emit(_client.ClientEvent.Room, room);
+ this.processEventsForNotifs(room, events);
+ return room;
+ }));
+ return rooms.filter(Boolean);
+ }
+
+ /**
+ * 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.
+ */
+ peek(roomId) {
+ 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 = (0, _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(_client.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(_client.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.
+ */
+ stopPeeking() {
+ this._peekRoom = null;
+ }
+
+ /**
+ * Do a peek room poll.
+ * @param token - from= token
+ */
+ peekPoll(peekRoom, token) {
+ if (this._peekRoom !== peekRoom) {
+ debuglog("Stopped peeking in room %s", peekRoom.roomId);
+ return;
+ }
+
+ // FIXME: gut wrenching; hard-coded timeout values
+ this.client.http.authedRequest(_httpApi.Method.Get, "/events", {
+ room_id: peekRoom.roomId,
+ timeout: String(30 * 1000),
+ from: token
+ }, undefined, {
+ localTimeoutMs: 50 * 1000,
+ abortSignal: this.abortController?.signal
+ }).then(async 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(_client.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());
+ await peekRoom.addLiveEvents(events);
+ this.peekPoll(peekRoom, res.end);
+ }, err => {
+ _logger.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"
+ */
+ getSyncState() {
+ 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.
+ */
+ getSyncStateData() {
+ return this.syncStateData ?? null;
+ }
+ async recoverFromSyncStartupError(savedSyncPromise, error) {
+ // 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 */
+ async wasLazyLoadingToggled(lazyLoadMembers = false) {
+ // 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;
+ }
+ shouldAbortSync(error) {
+ if (error.errcode === "M_UNKNOWN_TOKEN") {
+ // The logout already happened, we just need to stop.
+ _logger.logger.warn("Token no longer valid - assuming logout");
+ this.stop();
+ this.updateSyncState(SyncState.Error, {
+ error
+ });
+ return true;
+ }
+ return false;
+ }
+ /**
+ * Main entry point
+ */
+ async sync() {
+ 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.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.
+ */
+ stop() {
+ 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.
+ */
+ retryImmediately() {
+ 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().
+ */
+ async syncFromCache(savedSync) {
+ 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 = {
+ nextSyncToken,
+ catchingUp: false,
+ fromCache: true
+ };
+ const data = {
+ next_batch: nextSyncToken,
+ rooms: savedSync.roomsData,
+ account_data: {
+ events: savedSync.accountData
+ }
+ };
+ try {
+ await this.processSyncResponse(syncEventData, data);
+ } catch (e) {
+ _logger.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
+ */
+ async doSync(syncOptions) {
+ while (this.running) {
+ const syncToken = this.client.store.getSyncToken();
+ let data;
+ try {
+ if (!this.currentSyncRequest) {
+ this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
+ }
+ data = await this.currentSyncRequest;
+ } catch (e) {
+ const abort = await this.onSyncError(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;
+ 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.logger.error("Caught /sync error", e);
+
+ // Emit the exception for client handling
+ this.client.emit(_client.ClientEvent.SyncUnexpectedError, e);
+ }
+
+ // Persist after processing as `unsigned` may get mutated
+ // with an `org.matrix.msc4023.thread_id`
+ await this.client.store.setSyncData(data);
+
+ // 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.
+ await 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);
+ }
+ }
+ doSyncRequest(syncOptions, syncToken) {
+ const qps = this.getSyncParams(syncOptions, syncToken);
+ return this.client.http.authedRequest(_httpApi.Method.Get, "/sync", qps, undefined, {
+ localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS,
+ abortSignal: this.abortController?.signal
+ });
+ }
+ getSyncParams(syncOptions, syncToken) {
+ 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 = {
+ 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;
+ }
+ async onSyncError(err) {
+ 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.logger.error("/sync error %s", err);
+ if (this.shouldAbortSync(err)) {
+ return true; // abort
+ }
+
+ this.failedSyncCount++;
+ _logger.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
+ */
+ async processSyncResponse(syncEventData, data) {
+ 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(_utils.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(_client.ClientEvent.Event, presenceEvent);
+ });
+ }
+
+ // handle non-room account_data
+ if (Array.isArray(data.account_data?.events)) {
+ const events = data.account_data.events.filter(_utils.noUnsafeEventProps).map(client.getEventMapper());
+ const prevEventsMap = events.reduce((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() === _event.EventType.PushRules) {
+ const rules = accountDataEvent.getContent();
+ client.setPushRules(rules);
+ }
+ const prevEvent = prevEventsMap[accountDataEvent.getType()];
+ client.emit(_client.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 = data.to_device.events.filter(_utils.noUnsafeEventProps);
+ if (this.syncOpts.cryptoCallbacks) {
+ toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
+ }
+ const cancelledKeyVerificationTxns = [];
+ 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 = 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.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(_client.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 = [];
+ let joinRooms = [];
+ let leaveRooms = [];
+ 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 (0, _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(_event.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(_client.ClientEvent.Room, room);
+ } else {
+ // Update room state for invite->reject->invite cycles
+ room.recalculate();
+ }
+ stateEvents.forEach(function (e) {
+ client.emit(_client.ClientEvent.Event, e);
+ });
+ });
+
+ // Handle joins
+ await (0, _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(_room.NotificationCountType.Total, joinObj.unread_notifications.notification_count ?? 0);
+ }
+ if (!encrypted || room.getUnreadNotificationCount(_room.NotificationCountType.Highlight) <= 0) {
+ // If the locally stored highlight count is zero, use the server provided value.
+ room.setUnreadNotificationCount(_room.NotificationCountType.Highlight, joinObj.unread_notifications.highlight_count ?? 0);
+ }
+ }
+ const unreadThreadNotifications = joinObj[_sync.UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[_sync.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, _room.NotificationCountType.Total, unreadNotification.notification_count ?? 0);
+ }
+ const hasNoNotifications = room.getThreadUnreadNotificationCount(threadId, _room.NotificationCountType.Highlight) <= 0;
+ if (!encrypted || encrypted && hasNoNotifications) {
+ room.setThreadUnreadNotificationCount(threadId, _room.NotificationCountType.Highlight, unreadNotification.highlight_count ?? 0);
+ }
+ }
+ } else {
+ room.resetThreadUnreadNotificationCount();
+ }
+ joinObj.timeline = joinObj.timeline || {};
+ 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.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() === _event.EventType.RoomEncryption && e.getStateKey() === "") {
+ await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
+ }
+ }
+ }
+ try {
+ await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache);
+ } catch (e) {
+ _logger.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(_client.ClientEvent.Room, room);
+ }
+ this.processEventsForNotifs(room, events);
+ const emitEvent = e => client.emit(_client.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 (0, _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(_client.ClientEvent.Room, room);
+ }
+ this.processEventsForNotifs(room, events);
+ stateEvents.forEach(function (e) {
+ client.emit(_client.ClientEvent.Event, e);
+ });
+ events.forEach(function (e) {
+ client.emit(_client.ClientEvent.Event, e);
+ });
+ accountDataEvents.forEach(function (e) {
+ client.emit(_client.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.cryptoCallbacks) {
+ await this.syncOpts.cryptoCallbacks.processDeviceLists(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 and unused fallback keys
+ await this.syncOpts.cryptoCallbacks?.processKeyCounts(data.device_one_time_keys_count, data.device_unused_fallback_key_types ?? data["org.matrix.msc2732.device_unused_fallback_key_types"]);
+ }
+
+ /**
+ * 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
+ */
+ startKeepAlives(delay) {
+ 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 = (0, _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.
+ */
+ pokeKeepAlive(connDidFail = false) {
+ const success = () => {
+ clearTimeout(this.keepAliveTimer);
+ if (this.connectionReturnedDefer) {
+ this.connectionReturnedDefer.resolve(connDidFail);
+ this.connectionReturnedDefer = undefined;
+ }
+ };
+ this.client.http.request(_httpApi.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
+ });
+ }
+ });
+ }
+ mapSyncResponseToRoomArray(obj) {
+ // Maps { roomid: {stuff}, roomid: {stuff} }
+ // to
+ // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}]
+ const client = this.client;
+ return Object.keys(obj).filter(k => !(0, _utils.unsafeProp)(k)).map(roomId => {
+ let room = client.store.getRoom(roomId);
+ let isBrandNewRoom = false;
+ if (!room) {
+ room = this.createRoom(roomId);
+ isBrandNewRoom = true;
+ }
+ return _objectSpread(_objectSpread({}, obj[roomId]), {}, {
+ room,
+ isBrandNewRoom
+ });
+ });
+ }
+ mapSyncEventsFormat(obj, room, decrypt = true) {
+ if (!obj || !Array.isArray(obj.events)) {
+ return [];
+ }
+ const mapper = this.client.getEventMapper({
+ decrypt
+ });
+ return obj.events.filter(_utils.noUnsafeEventProps).map(function (e) {
+ if (room) {
+ e.room_id = room.roomId;
+ }
+ return mapper(e);
+ });
+ }
+
+ /**
+ */
+ resolveInvites(room) {
+ 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
+ */
+ async injectRoomEvents(room, stateEventList, timelineEventList, fromCache = false) {
+ // 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.
+ await 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.
+ */
+ processEventsForNotifs(room, timelineEventList) {
+ // 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);
+ }
+ }
+ }
+ }
+ getGuestFilter() {
+ // 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
+ */
+ updateSyncState(newState, data) {
+ const old = this.syncState;
+ this.syncState = newState;
+ this.syncStateData = data;
+ this.client.emit(_client.ClientEvent.Sync, this.syncState, old, data);
+ }
+}
+exports.SyncApi = SyncApi;
+function createNewUser(client, userId) {
+ const user = new _user.User(userId);
+ client.reEmitter.reEmit(user, [_user.UserEvent.AvatarUrl, _user.UserEvent.DisplayName, _user.UserEvent.Presence, _user.UserEvent.CurrentlyActive, _user.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.
+function _createAndReEmitRoom(client, roomId, opts) {
+ const {
+ timelineSupport
+ } = client;
+ const room = new _room.Room(roomId, client, client.getUserId(), {
+ lazyLoadMembers: opts.lazyLoadMembers,
+ pendingEventOrdering: opts.pendingEventOrdering,
+ timelineSupport
+ });
+ client.reEmitter.reEmit(room, [_room.RoomEvent.Name, _room.RoomEvent.Redaction, _room.RoomEvent.RedactionCancelled, _room.RoomEvent.Receipt, _room.RoomEvent.Tags, _room.RoomEvent.LocalEchoUpdated, _room.RoomEvent.AccountData, _room.RoomEvent.MyMembership, _room.RoomEvent.Timeline, _room.RoomEvent.TimelineReset, _roomState.RoomStateEvent.Events, _roomState.RoomStateEvent.Members, _roomState.RoomStateEvent.NewMember, _roomState.RoomStateEvent.Update, _beacon.BeaconEvent.New, _beacon.BeaconEvent.Update, _beacon.BeaconEvent.Destroy, _beacon.BeaconEvent.LivenessChange]);
+
+ // We need to add a listener for RoomState.members in order to hook them
+ // correctly.
+ room.on(_roomState.RoomStateEvent.NewMember, (event, state, member) => {
+ member.user = client.getUser(member.userId) ?? undefined;
+ client.reEmitter.reEmit(member, [_roomMember.RoomMemberEvent.Name, _roomMember.RoomMemberEvent.Typing, _roomMember.RoomMemberEvent.PowerLevel, _roomMember.RoomMemberEvent.Membership]);
+ });
+ return room;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js b/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js
new file mode 100644
index 0000000000..2c7365bdff
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js
@@ -0,0 +1,462 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TimelineWindow = exports.TimelineIndex = void 0;
+var _eventTimeline = require("./models/event-timeline");
+var _logger = require("./logger");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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
+ */
+const DEBUG = false;
+
+/**
+ * @internal
+ */
+/* istanbul ignore next */
+const debuglog = DEBUG ? _logger.logger.log.bind(_logger.logger) : function () {};
+
+/**
+ * the number of times we ask the server for more events before giving up
+ *
+ * @internal
+ */
+const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
+class TimelineWindow {
+ /**
+ * 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
+ */
+ constructor(client, timelineSet, opts = {}) {
+ this.client = client;
+ this.timelineSet = timelineSet;
+ _defineProperty(this, "windowLimit", void 0);
+ // these will be TimelineIndex objects; they delineate the 'start' and
+ // 'end' of the window.
+ //
+ // start.index is inclusive; end.index is exclusive.
+ _defineProperty(this, "start", void 0);
+ _defineProperty(this, "end", void 0);
+ _defineProperty(this, "eventCount", 0);
+ 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
+ */
+ load(initialEventId, initialWindowSize = 20) {
+ // 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 => {
+ if (!timeline) {
+ throw new Error("No timeline given to initFields");
+ }
+ let eventIndex;
+ 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.
+ */
+ getTimelineIndex(direction) {
+ if (direction == _eventTimeline.EventTimeline.BACKWARDS) {
+ return this.start ?? null;
+ } else if (direction == _eventTimeline.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.
+ */
+ extend(direction, size) {
+ const tl = this.getTimelineIndex(direction);
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+ const count = direction == _eventTimeline.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.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
+ */
+ canPaginate(direction) {
+ const tl = this.getTimelineIndex(direction);
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+ if (direction == _eventTimeline.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.
+ */
+ async paginate(direction, size, makeRequest = true, requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT) {
+ // 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.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.
+ */
+ unpaginate(delta, startOfTimeline) {
+ 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
+ */
+ getEvents() {
+ if (!this.start) {
+ // not yet loaded
+ return [];
+ }
+ const result = [];
+
+ // iterate through each timeline between this.start and this.end
+ // (inclusive).
+ let timeline = this.start.timeline;
+ // eslint-disable-next-line no-constant-condition
+ while (timeline) {
+ 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.EventTimeline.FORWARDS);
+ }
+ }
+ return result;
+ }
+}
+
+/**
+ * A thing which contains a timeline reference, and an index into it.
+ * @internal
+ */
+exports.TimelineWindow = TimelineWindow;
+class TimelineIndex {
+ // index: the indexes are relative to BaseIndex, so could well be negative.
+ constructor(timeline, index) {
+ this.timeline = timeline;
+ this.index = index;
+ _defineProperty(this, "pendingPaginate", void 0);
+ }
+
+ /**
+ * @returns the minimum possible value for the index in the current
+ * timeline
+ */
+ minIndex() {
+ 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).
+ */
+ maxIndex() {
+ 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
+ */
+ advance(delta) {
+ 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.EventTimeline.BACKWARDS : _eventTimeline.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
+ */
+ retreat(delta) {
+ return this.advance(delta * -1) * -1;
+ }
+}
+exports.TimelineIndex = TimelineIndex; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js b/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js
new file mode 100644
index 0000000000..6c7ead2ce7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/utils.js
@@ -0,0 +1,754 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MapWithDefault = exports.DEFAULT_ALPHABET = void 0;
+exports.alphabetPad = alphabetPad;
+exports.averageBetweenStrings = averageBetweenStrings;
+exports.baseToString = baseToString;
+exports.checkObjectHasKeys = checkObjectHasKeys;
+exports.chunkPromises = chunkPromises;
+exports.compare = compare;
+exports.decodeParams = decodeParams;
+exports.deepCompare = deepCompare;
+exports.deepCopy = deepCopy;
+exports.deepSortedObjectEntries = deepSortedObjectEntries;
+exports.defer = defer;
+exports.encodeParams = encodeParams;
+exports.encodeUri = encodeUri;
+exports.ensureNoTrailingSlash = ensureNoTrailingSlash;
+exports.escapeRegExp = escapeRegExp;
+exports.globToRegexp = globToRegexp;
+exports.immediate = immediate;
+exports.internaliseString = internaliseString;
+exports.isFunction = isFunction;
+exports.isNullOrUndefined = isNullOrUndefined;
+exports.isNumber = isNumber;
+exports.isSupportedReceiptType = isSupportedReceiptType;
+exports.lexicographicCompare = lexicographicCompare;
+exports.mapsEqual = mapsEqual;
+exports.nextString = nextString;
+exports.noUnsafeEventProps = noUnsafeEventProps;
+exports.normalize = normalize;
+exports.prevString = prevString;
+exports.promiseMapSeries = promiseMapSeries;
+exports.promiseTry = promiseTry;
+exports.recursiveMapToObject = recursiveMapToObject;
+exports.recursivelyAssign = recursivelyAssign;
+exports.removeDirectionOverrideChars = removeDirectionOverrideChars;
+exports.removeElement = removeElement;
+exports.removeHiddenChars = removeHiddenChars;
+exports.replaceParam = replaceParam;
+exports.safeSet = safeSet;
+exports.simpleRetryOperation = simpleRetryOperation;
+exports.sleep = sleep;
+exports.sortEventsByLatestContentTimestamp = sortEventsByLatestContentTimestamp;
+exports.stringToBase = stringToBase;
+exports.unsafeProp = unsafeProp;
+var _unhomoglyph = _interopRequireDefault(require("unhomoglyph"));
+var _pRetry = _interopRequireDefault(require("p-retry"));
+var _location = require("./@types/location");
+var _read_receipts = require("./@types/read_receipts");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const interns = new Map();
+
+/**
+ * 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.
+ */
+function internaliseString(str) {
+ // Unwrap strings before entering the map, if we somehow got a wrapped
+ // string as our input. This should only happen from tests.
+ if (str 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
+ */
+function encodeParams(params, 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;
+}
+/**
+ * Replace a stable parameter with the unstable naming for params
+ */
+function replaceParam(stable, unstable, dict) {
+ const result = _objectSpread(_objectSpread({}, 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
+ */
+function decodeParams(query) {
+ const o = {};
+ 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'.
+ */
+function encodeUri(pathTemplate, variables) {
+ 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.
+ */
+function removeElement(array, fn, reverse) {
+ let i;
+ 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.
+ */
+function isFunction(value) {
+ 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
+function checkObjectHasKeys(obj, keys) {
+ 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.
+ */
+function deepCopy(obj) {
+ 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
+ */
+function deepCompare(x, y) {
+ // 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.
+ */
+function deepSortedObjectEntries(obj) {
+ if (typeof obj !== "object") return obj;
+
+ // Apparently these are object types...
+ if (obj === null || obj === undefined || Array.isArray(obj)) return obj;
+ const pairs = [];
+ 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
+ */
+function isNumber(value) {
+ 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
+ */
+function removeHiddenChars(str) {
+ if (typeof str === "string") {
+ return (0, _unhomoglyph.default)(str.normalize("NFD").replace(removeHiddenCharsRegex, ""));
+ }
+ return "";
+}
+
+/**
+ * Removes the direction override characters from a string
+ * @returns string with chars removed
+ */
+function removeDirectionOverrideChars(str) {
+ if (typeof str === "string") {
+ return str.replace(/[\u202d-\u202e]/g, "");
+ }
+ return "";
+}
+function normalize(str) {
+ // 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;
+function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * Converts Matrix glob-style string to a regular expression
+ * https://spec.matrix.org/v1.7/appendices/#glob-style-matching
+ * @param glob - Matrix glob-style string
+ * @returns regular expression
+ */
+function globToRegexp(glob) {
+ return escapeRegExp(glob).replace(/\\\*/g, ".*").replace(/\?/g, ".");
+}
+function ensureNoTrailingSlash(url) {
+ 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
+ */
+function sleep(ms, value) {
+ return new Promise(resolve => {
+ setTimeout(resolve, ms, value);
+ });
+}
+
+/**
+ * Promise/async version of {@link setImmediate}.
+ */
+function immediate() {
+ return new Promise(setImmediate);
+}
+function isNullOrUndefined(val) {
+ return val === null || val === undefined;
+}
+// Returns a Deferred
+function defer() {
+ let resolve;
+ let reject;
+ const promise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+ return {
+ resolve,
+ reject,
+ promise
+ };
+}
+async function promiseMapSeries(promises, fn // if async we don't care about the type as we only await resolution
+) {
+ for (const o of promises) {
+ await fn(await o);
+ }
+}
+function promiseTry(fn) {
+ return Promise.resolve(fn());
+}
+
+// Creates and awaits all promises, running no more than `chunkSize` at the same time
+async function chunkPromises(fns, chunkSize) {
+ const results = [];
+ 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.
+ */
+function simpleRetryOperation(promiseFn) {
+ return (0, _pRetry.default)(attempt => {
+ 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).
+ */
+const DEFAULT_ALPHABET = (() => {
+ 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.
+ */
+exports.DEFAULT_ALPHABET = DEFAULT_ALPHABET;
+function alphabetPad(s, n, alphabet = DEFAULT_ALPHABET) {
+ 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.
+ */
+function baseToString(n, alphabet = DEFAULT_ALPHABET) {
+ // 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.
+ */
+function stringToBase(s, alphabet = DEFAULT_ALPHABET) {
+ 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.
+ */
+function averageBetweenStrings(a, b, alphabet = DEFAULT_ALPHABET) {
+ 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.
+ */
+function nextString(s, alphabet = DEFAULT_ALPHABET) {
+ 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.
+ */
+function prevString(s, alphabet = DEFAULT_ALPHABET) {
+ 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.
+ */
+function lexicographicCompare(a, b) {
+ // 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
+ */
+function compare(a, b) {
+ 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
+ */
+function recursivelyAssign(target, source, ignoreNullish = false) {
+ 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) {
+ safeSet(target, sourceKey, sourceValue);
+ continue;
+ }
+ }
+ return target;
+}
+function getContentTimestampWithFallback(event) {
+ return _location.M_TIMESTAMP.findIn(event.getContent()) ?? -1;
+}
+
+/**
+ * Sort events by their content m.ts property
+ * Latest timestamp first
+ */
+function sortEventsByLatestContentTimestamp(left, right) {
+ return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
+}
+function isSupportedReceiptType(receiptType) {
+ return [_read_receipts.ReceiptType.Read, _read_receipts.ReceiptType.ReadPrivate].includes(receiptType);
+}
+
+/**
+ * Determines whether two maps are equal.
+ * @param eq - The equivalence relation to compare values by. Defaults to strict equality.
+ */
+function mapsEqual(x, y, eq = (v1, v2) => v1 === v2) {
+ 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) {
+ 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.
+ */
+function recursiveMapToObject(map) {
+ const targetMap = new Map();
+ for (const [key, value] of map) {
+ targetMap.set(key, processMapToObjectValue(value));
+ }
+ return Object.fromEntries(targetMap.entries());
+}
+function unsafeProp(prop) {
+ return prop === "__proto__" || prop === "prototype" || prop === "constructor";
+}
+function safeSet(obj, prop, value) {
+ if (unsafeProp(prop)) {
+ throw new Error("Trying to modify prototype or constructor");
+ }
+ obj[prop] = value;
+}
+function noUnsafeEventProps(event) {
+ return !(unsafeProp(event.room_id) || unsafeProp(event.sender) || unsafeProp(event.user_id) || unsafeProp(event.event_id));
+}
+class MapWithDefault extends Map {
+ constructor(createDefault) {
+ super();
+ this.createDefault = createDefault;
+ }
+
+ /**
+ * 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.
+ */
+ getOrCreate(key) {
+ if (!this.has(key)) {
+ this.set(key, this.createDefault());
+ }
+ return this.get(key);
+ }
+}
+exports.MapWithDefault = MapWithDefault; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js
new file mode 100644
index 0000000000..4cecf68ad3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js
@@ -0,0 +1,52 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.releaseContext = exports.acquireContext = void 0;
+/*
+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 = 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
+ */
+const acquireContext = () => {
+ 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.
+ */
+exports.acquireContext = acquireContext;
+const releaseContext = () => {
+ refCount--;
+ if (refCount === 0) {
+ audioContext?.close();
+ audioContext = null;
+ }
+};
+exports.releaseContext = releaseContext; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js
new file mode 100644
index 0000000000..862d7ce1f8
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js
@@ -0,0 +1,2364 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MatrixCall = exports.CallType = exports.CallState = exports.CallParty = exports.CallEvent = exports.CallErrorCode = exports.CallError = exports.CallDirection = void 0;
+exports.createNewMatrixCall = createNewMatrixCall;
+exports.genCallID = genCallID;
+exports.setTracksEnabled = setTracksEnabled;
+exports.supportsMatrixCall = supportsMatrixCall;
+var _uuid = require("uuid");
+var _sdpTransform = require("sdp-transform");
+var _logger = require("../logger");
+var _utils = require("../utils");
+var _event = require("../@types/event");
+var _randomstring = require("../randomstring");
+var _callEventTypes = require("./callEventTypes");
+var _callFeed = require("./callFeed");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _deviceinfo = require("../crypto/deviceinfo");
+var _groupCall = require("./groupCall");
+var _httpApi = require("../http-api");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+var MediaType = /*#__PURE__*/function (MediaType) {
+ MediaType["AUDIO"] = "audio";
+ MediaType["VIDEO"] = "video";
+ return MediaType;
+}(MediaType || {});
+var CodecName = /*#__PURE__*/function (CodecName) {
+ CodecName["OPUS"] = "opus";
+ return CodecName;
+}(CodecName || {}); // add more as needed
+// Used internally to specify modifications to codec parameters in SDP
+let CallState = /*#__PURE__*/function (CallState) {
+ CallState["Fledgling"] = "fledgling";
+ CallState["InviteSent"] = "invite_sent";
+ CallState["WaitLocalMedia"] = "wait_local_media";
+ CallState["CreateOffer"] = "create_offer";
+ CallState["CreateAnswer"] = "create_answer";
+ CallState["Connecting"] = "connecting";
+ CallState["Connected"] = "connected";
+ CallState["Ringing"] = "ringing";
+ CallState["Ended"] = "ended";
+ return CallState;
+}({});
+exports.CallState = CallState;
+let CallType = /*#__PURE__*/function (CallType) {
+ CallType["Voice"] = "voice";
+ CallType["Video"] = "video";
+ return CallType;
+}({});
+exports.CallType = CallType;
+let CallDirection = /*#__PURE__*/function (CallDirection) {
+ CallDirection["Inbound"] = "inbound";
+ CallDirection["Outbound"] = "outbound";
+ return CallDirection;
+}({});
+exports.CallDirection = CallDirection;
+let CallParty = /*#__PURE__*/function (CallParty) {
+ CallParty["Local"] = "local";
+ CallParty["Remote"] = "remote";
+ return CallParty;
+}({});
+exports.CallParty = CallParty;
+let CallEvent = /*#__PURE__*/function (CallEvent) {
+ CallEvent["Hangup"] = "hangup";
+ CallEvent["State"] = "state";
+ CallEvent["Error"] = "error";
+ CallEvent["Replaced"] = "replaced";
+ CallEvent["LocalHoldUnhold"] = "local_hold_unhold";
+ CallEvent["RemoteHoldUnhold"] = "remote_hold_unhold";
+ CallEvent["HoldUnhold"] = "hold_unhold";
+ CallEvent["FeedsChanged"] = "feeds_changed";
+ CallEvent["AssertedIdentityChanged"] = "asserted_identity_changed";
+ CallEvent["LengthChanged"] = "length_changed";
+ CallEvent["DataChannel"] = "datachannel";
+ CallEvent["SendVoipEvent"] = "send_voip_event";
+ CallEvent["PeerConnectionCreated"] = "peer_connection_created";
+ return CallEvent;
+}({});
+exports.CallEvent = CallEvent;
+let CallErrorCode = /*#__PURE__*/function (CallErrorCode) {
+ CallErrorCode["UserHangup"] = "user_hangup";
+ CallErrorCode["LocalOfferFailed"] = "local_offer_failed";
+ CallErrorCode["NoUserMedia"] = "no_user_media";
+ CallErrorCode["UnknownDevices"] = "unknown_devices";
+ CallErrorCode["SendInvite"] = "send_invite";
+ CallErrorCode["CreateAnswer"] = "create_answer";
+ CallErrorCode["CreateOffer"] = "create_offer";
+ CallErrorCode["SendAnswer"] = "send_answer";
+ CallErrorCode["SetRemoteDescription"] = "set_remote_description";
+ CallErrorCode["SetLocalDescription"] = "set_local_description";
+ CallErrorCode["AnsweredElsewhere"] = "answered_elsewhere";
+ CallErrorCode["IceFailed"] = "ice_failed";
+ CallErrorCode["InviteTimeout"] = "invite_timeout";
+ CallErrorCode["Replaced"] = "replaced";
+ CallErrorCode["SignallingFailed"] = "signalling_timeout";
+ CallErrorCode["UserBusy"] = "user_busy";
+ CallErrorCode["Transferred"] = "transferred";
+ CallErrorCode["NewSession"] = "new_session";
+ return CallErrorCode;
+}({});
+/**
+ * The version field that we set in m.call.* events
+ */
+exports.CallErrorCode = CallErrorCode;
+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
+/** The time after which we try a ICE restart, if ICE got disconnected */
+const ICE_RECONNECTING_TIMEOUT = 2 * 1000; // ms
+class CallError extends Error {
+ constructor(code, msg, err) {
+ // Still don't think there's any way to have proper nested errors
+ super(msg + ": " + err);
+ _defineProperty(this, "code", void 0);
+ this.code = code;
+ }
+}
+exports.CallError = CallError;
+function genCallID() {
+ return Date.now().toString() + (0, _randomstring.randomString)(16);
+}
+function getCodecParamMods(isPtt) {
+ const mods = [{
+ mediaType: "audio",
+ codec: "opus",
+ enableDtx: true,
+ maxAverageBitrate: isPtt ? 12000 : undefined
+ }];
+ return mods;
+}
+
+/**
+ * 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.
+ */
+
+// The key of the transceiver map (purpose + media type, separated by ':')
+
+// 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, kind) {
+ return purpose + ":" + kind;
+}
+class MatrixCall extends _typedEventEmitter.TypedEventEmitter {
+ /**
+ * Construct a new Matrix Call.
+ * @param opts - Config options.
+ */
+ constructor(opts) {
+ super();
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "callId", void 0);
+ _defineProperty(this, "invitee", void 0);
+ _defineProperty(this, "hangupParty", void 0);
+ _defineProperty(this, "hangupReason", void 0);
+ _defineProperty(this, "direction", void 0);
+ _defineProperty(this, "ourPartyId", void 0);
+ _defineProperty(this, "peerConn", void 0);
+ _defineProperty(this, "toDeviceSeq", 0);
+ // whether this call should have push-to-talk semantics
+ // This should be set by the consumer on incoming & outgoing calls.
+ _defineProperty(this, "isPtt", false);
+ _defineProperty(this, "_state", CallState.Fledgling);
+ _defineProperty(this, "client", void 0);
+ _defineProperty(this, "forceTURN", void 0);
+ _defineProperty(this, "turnServers", void 0);
+ // A queue for candidates waiting to go out.
+ // We try to amalgamate candidates into a single candidate message where
+ // possible
+ _defineProperty(this, "candidateSendQueue", []);
+ _defineProperty(this, "candidateSendTries", 0);
+ _defineProperty(this, "candidatesEnded", false);
+ _defineProperty(this, "feeds", []);
+ // our transceivers for each purpose and type of media
+ _defineProperty(this, "transceivers", new Map());
+ _defineProperty(this, "inviteOrAnswerSent", false);
+ _defineProperty(this, "waitForLocalAVStream", false);
+ _defineProperty(this, "successor", void 0);
+ _defineProperty(this, "opponentMember", void 0);
+ _defineProperty(this, "opponentVersion", void 0);
+ // 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.
+ _defineProperty(this, "opponentPartyId", void 0);
+ _defineProperty(this, "opponentCaps", void 0);
+ _defineProperty(this, "iceDisconnectedTimeout", void 0);
+ _defineProperty(this, "iceReconnectionTimeOut", void 0);
+ _defineProperty(this, "inviteTimeout", void 0);
+ _defineProperty(this, "removeTrackListeners", new Map());
+ // 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
+ _defineProperty(this, "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' :(
+ _defineProperty(this, "callStatsAtEnd", void 0);
+ // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
+ _defineProperty(this, "makingOffer", false);
+ _defineProperty(this, "ignoreOffer", false);
+ _defineProperty(this, "isSettingRemoteAnswerPending", false);
+ _defineProperty(this, "responsePromiseChain", void 0);
+ // 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
+ _defineProperty(this, "remoteCandidateBuffer", new Map());
+ _defineProperty(this, "remoteAssertedIdentity", void 0);
+ _defineProperty(this, "remoteSDPStreamMetadata", void 0);
+ _defineProperty(this, "callLengthInterval", void 0);
+ _defineProperty(this, "callStartTime", void 0);
+ _defineProperty(this, "opponentDeviceId", void 0);
+ _defineProperty(this, "opponentDeviceInfo", void 0);
+ _defineProperty(this, "opponentSessionId", void 0);
+ _defineProperty(this, "groupCallId", void 0);
+ // Used to keep the timer for the delay before actually stopping our
+ // video track after muting (see setLocalVideoMuted)
+ _defineProperty(this, "stopVideoTrackTimer", void 0);
+ // 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
+ _defineProperty(this, "isOnlyDataChannelAllowed", void 0);
+ _defineProperty(this, "stats", void 0);
+ /**
+ * Internal
+ */
+ _defineProperty(this, "gotLocalIceCandidate", event => {
+ if (event.candidate) {
+ if (this.candidatesEnded) {
+ _logger.logger.warn(`Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended!`);
+ }
+ _logger.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);
+ }
+ }
+ });
+ _defineProperty(this, "onIceGatheringStateChange", event => {
+ _logger.logger.debug(`Call ${this.callId} onIceGatheringStateChange() ice gathering state changed to ${this.peerConn.iceGatheringState}`);
+ if (this.peerConn?.iceGatheringState === "complete") {
+ this.queueCandidate(null); // We should leave it to WebRTC to announce the end
+ _logger.logger.debug(`Call ${this.callId} onIceGatheringStateChange() ice gathering state complete, set candidates have ended`);
+ }
+ });
+ _defineProperty(this, "getLocalOfferFailed", err => {
+ _logger.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);
+ });
+ _defineProperty(this, "getUserMediaFailed", err => {
+ if (this.successor) {
+ this.successor.getUserMediaFailed(err);
+ return;
+ }
+ _logger.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);
+ });
+ _defineProperty(this, "onIceConnectionStateChanged", () => {
+ if (this.callHasEnded()) {
+ return; // because ICE can still complete as we're ending the call
+ }
+
+ _logger.logger.debug(`Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`);
+
+ // 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;
+ if (this.iceReconnectionTimeOut) {
+ clearTimeout(this.iceReconnectionTimeOut);
+ }
+ 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") {
+ this.candidatesEnded = false;
+ // 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) {
+ this.candidatesEnded = false;
+ _logger.logger.debug(`Call ${this.callId} onIceConnectionStateChanged() ice restart (state=${this.peerConn?.iceConnectionState})`);
+ this.peerConn.restartIce();
+ } else {
+ _logger.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.candidatesEnded = false;
+ this.iceReconnectionTimeOut = setTimeout(() => {
+ _logger.logger.info(`Call ${this.callId} onIceConnectionStateChanged() ICE restarting because of ICE disconnected, (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`);
+ if (this.peerConn?.restartIce) {
+ this.candidatesEnded = false;
+ this.peerConn.restartIce();
+ }
+ this.iceReconnectionTimeOut = undefined;
+ }, ICE_RECONNECTING_TIMEOUT);
+ this.iceDisconnectedTimeout = setTimeout(() => {
+ _logger.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);
+ }
+ }
+ });
+ _defineProperty(this, "onSignallingStateChanged", () => {
+ _logger.logger.debug(`Call ${this.callId} onSignallingStateChanged() running (state=${this.peerConn?.signalingState})`);
+ });
+ _defineProperty(this, "onTrack", ev => {
+ if (ev.streams.length === 0) {
+ _logger.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 = () => {
+ if (stream.getTracks().length === 0) {
+ _logger.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);
+ }
+ });
+ _defineProperty(this, "onDataChannel", ev => {
+ this.emit(CallEvent.DataChannel, ev.channel, this);
+ });
+ _defineProperty(this, "onNegotiationNeeded", async () => {
+ _logger.logger.info(`Call ${this.callId} onNegotiationNeeded() negotiation is needed!`);
+ if (this.state !== CallState.CreateOffer && this.opponentVersion === 0) {
+ _logger.logger.info(`Call ${this.callId} onNegotiationNeeded() opponent does not support renegotiation: ignoring negotiationneeded event`);
+ return;
+ }
+ this.queueGotLocalOffer();
+ });
+ _defineProperty(this, "onHangupReceived", msg => {
+ _logger.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.logger.info(`Call ${this.callId} onHangupReceived() ignoring message from party ID ${msg.party_id}: our partner is ${this.opponentPartyId}`);
+ }
+ });
+ _defineProperty(this, "onRejectReceived", msg => {
+ _logger.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.logger.debug(`Call ${this.callId} onRejectReceived() called in wrong state (state=${this.state})`);
+ }
+ });
+ _defineProperty(this, "onAnsweredElsewhere", msg => {
+ _logger.logger.debug(`Call ${this.callId} onAnsweredElsewhere() running`);
+ this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true);
+ });
+ 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) {
+ (0, _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.
+ */
+ async placeVoiceCall() {
+ await this.placeCall(true, false);
+ }
+
+ /**
+ * Place a video call to this room.
+ * @throws If you have not specified a listener for 'error' events.
+ */
+ async placeVideoCall() {
+ 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.
+ */
+ createDataChannel(label, options) {
+ const dataChannel = this.peerConn.createDataChannel(label, options);
+ this.emit(CallEvent.DataChannel, dataChannel, this);
+ return dataChannel;
+ }
+ getOpponentMember() {
+ return this.opponentMember;
+ }
+ getOpponentDeviceId() {
+ return this.opponentDeviceId;
+ }
+ getOpponentSessionId() {
+ return this.opponentSessionId;
+ }
+ opponentCanBeTransferred() {
+ return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]);
+ }
+ opponentSupportsDTMF() {
+ return Boolean(this.opponentCaps && this.opponentCaps["m.call.dtmf"]);
+ }
+ getRemoteAssertedIdentity() {
+ return this.remoteAssertedIdentity;
+ }
+ get state() {
+ return this._state;
+ }
+ set state(state) {
+ const oldState = this._state;
+ this._state = state;
+ this.emit(CallEvent.State, state, oldState, this);
+ }
+ get type() {
+ // 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;
+ }
+ get hasLocalUserMediaVideoTrack() {
+ return !!this.localUsermediaStream?.getVideoTracks().length;
+ }
+ get hasRemoteUserMediaVideoTrack() {
+ return this.getRemoteFeeds().some(feed => {
+ return feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia && feed.stream?.getVideoTracks().length;
+ });
+ }
+ get hasLocalUserMediaAudioTrack() {
+ return !!this.localUsermediaStream?.getAudioTracks().length;
+ }
+ get hasRemoteUserMediaAudioTrack() {
+ return this.getRemoteFeeds().some(feed => {
+ return feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia && !!feed.stream?.getAudioTracks().length;
+ });
+ }
+ get hasUserMediaAudioSender() {
+ return Boolean(this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "audio"))?.sender);
+ }
+ get hasUserMediaVideoSender() {
+ return Boolean(this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "video"))?.sender);
+ }
+ get localUsermediaFeed() {
+ return this.getLocalFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia);
+ }
+ get localScreensharingFeed() {
+ return this.getLocalFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare);
+ }
+ get localUsermediaStream() {
+ return this.localUsermediaFeed?.stream;
+ }
+ get localScreensharingStream() {
+ return this.localScreensharingFeed?.stream;
+ }
+ get remoteUsermediaFeed() {
+ return this.getRemoteFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Usermedia);
+ }
+ get remoteScreensharingFeed() {
+ return this.getRemoteFeeds().find(feed => feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare);
+ }
+ get remoteUsermediaStream() {
+ return this.remoteUsermediaFeed?.stream;
+ }
+ get remoteScreensharingStream() {
+ return this.remoteScreensharingFeed?.stream;
+ }
+ getFeedByStreamId(streamId) {
+ return this.getFeeds().find(feed => feed.stream.id === streamId);
+ }
+
+ /**
+ * Returns an array of all CallFeeds
+ * @returns CallFeeds
+ */
+ getFeeds() {
+ return this.feeds;
+ }
+
+ /**
+ * Returns an array of all local CallFeeds
+ * @returns local CallFeeds
+ */
+ getLocalFeeds() {
+ return this.feeds.filter(feed => feed.isLocal());
+ }
+
+ /**
+ * Returns an array of all remote CallFeeds
+ * @returns remote CallFeeds
+ */
+ getRemoteFeeds() {
+ return this.feeds.filter(feed => !feed.isLocal());
+ }
+ async initOpponentCrypto() {
+ 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.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 _groupCall.GroupCallUnknownDeviceError(userId);
+ }
+ }
+
+ /**
+ * Generates and returns localSDPStreamMetadata
+ * @returns localSDPStreamMetadata
+ */
+ getLocalSDPStreamMetadata(updateStreamIds = false) {
+ const metadata = {};
+ 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
+ */
+ noIncomingFeeds() {
+ return !this.feeds.some(feed => !feed.isLocal());
+ }
+ pushRemoteFeed(stream) {
+ // 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.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.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.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.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
+ */
+ pushRemoteFeedWithoutMetadata(stream) {
+ const userId = this.getOpponentMember().userId;
+ // We can guess the purpose here since the other client can only send one stream
+ const purpose = _callEventTypes.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.logger.warn(`Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring new stream because we already have stream (streamId=${stream.id})`);
+ return;
+ }
+ if (this.getFeedByStreamId(stream.id)) {
+ _logger.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.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.logger.info(`Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`);
+ }
+ pushNewLocalFeed(stream, purpose, addToPeerConnection = true) {
+ 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.logger.warn(`Call ${this.callId} pushNewLocalFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`);
+ return;
+ }
+ this.pushLocalFeed(new _callFeed.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
+ */
+ pushLocalFeed(callFeed, addToPeerConnection = true) {
+ if (this.feeds.some(feed => callFeed.stream.id === feed.stream.id)) {
+ _logger.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.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.logger.warn(`Call ${this.callId} pushLocalFeed() didn't find a matching transceiver after adding track!`);
+ }
+ }
+ }
+ }
+ _logger.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
+ */
+ removeLocalFeed(callFeed) {
+ 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 === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) {
+ this.client.getMediaHandler().stopScreensharingStream(callFeed.stream);
+ }
+ this.deleteFeed(callFeed);
+ }
+ deleteAllFeeds() {
+ for (const feed of this.feeds) {
+ if (!feed.isLocal() || !this.groupCallId) {
+ feed.dispose();
+ }
+ }
+ this.feeds = [];
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+ }
+ deleteFeedByStream(stream) {
+ const feed = this.getFeedByStreamId(stream.id);
+ if (!feed) {
+ _logger.logger.warn(`Call ${this.callId} deleteFeedByStream() didn't find the feed to delete (streamId=${stream.id})`);
+ return;
+ }
+ this.deleteFeed(feed);
+ }
+ deleteFeed(feed) {
+ 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' :(
+ async getCurrentCallStats() {
+ if (this.callHasEnded()) {
+ return this.callStatsAtEnd;
+ }
+ return this.collectCallStats();
+ }
+ async collectCallStats() {
+ // 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 = [];
+ 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
+ */
+ async initWithInvite(event) {
+ const invite = event.getContent();
+ 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.logger.warn(`Call ${this.callId} initWithInvite() failed to get TURN credentials! Proceeding with call anyway...`);
+ }
+ const sdpStreamMetadata = invite[_callEventTypes.SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ _logger.logger.debug(`Call ${this.callId} initWithInvite() did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
+ }
+ this.peerConn = this.createPeerConnection();
+ this.emit(CallEvent.PeerConnectionCreated, this.peerConn, this);
+ // 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);
+ _logger.logger.debug(`Call ${this.callId} initWithInvite() set remote description: ${invite.offer.type}`);
+ await this.addBufferedIceCandidates();
+ } catch (e) {
+ _logger.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.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.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 => {
+ 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
+ */
+ initWithHangup(event) {
+ // 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;
+ }
+ shouldAnswerWithMediaType(wantedValue, valueOfTheOtherSide, type) {
+ if (wantedValue && !valueOfTheOtherSide) {
+ // TODO: Figure out how to do this
+ _logger.logger.warn(`Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type} because the other side isn't sending it either.`);
+ return false;
+ } else if (!(0, _utils.isNullOrUndefined)(wantedValue) && wantedValue !== valueOfTheOtherSide && !this.opponentSupportsSDPStreamMetadata()) {
+ _logger.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.
+ */
+ async answer(audio, video) {
+ 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.CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ userId: this.client.getUserId(),
+ deviceId: this.client.getDeviceId() ?? undefined,
+ stream,
+ purpose: _callEventTypes.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.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(e);
+ return;
+ }
+ }
+ } else if (this.waitForLocalAVStream) {
+ this.state = CallState.WaitLocalMedia;
+ }
+ }
+ answerWithCallFeeds(callFeeds) {
+ 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.
+ */
+ replacedBy(newCall) {
+ _logger.logger.debug(`Call ${this.callId} replacedBy() running (newCallId=${newCall.callId})`);
+ if (this.state === CallState.WaitLocalMedia) {
+ _logger.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.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.
+ */
+ hangup(reason, suppressEvent) {
+ if (this.callHasEnded()) return;
+ _logger.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 = {};
+ // Don't send UserHangup reason to older clients
+ if (this.opponentVersion && this.opponentVersion !== 0 || reason !== CallErrorCode.UserHangup) {
+ content["reason"] = reason;
+ }
+ this.sendVoipEvent(_event.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.
+ */
+ reject() {
+ if (this.state !== CallState.Ringing) {
+ throw Error("Call must be in 'ringing' state to reject!");
+ }
+ if (this.opponentVersion === 0) {
+ _logger.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.logger.debug("Rejecting call: " + this.callId);
+ this.terminate(CallParty.Local, CallErrorCode.UserHangup, true);
+ this.sendVoipEvent(_event.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
+ */
+ async upgradeCall(audio, video) {
+ // We don't do call downgrades
+ if (!audio && !video) return;
+ if (!this.opponentSupportsSDPStreamMetadata()) return;
+ try {
+ _logger.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.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), this);
+ }
+ }
+
+ /**
+ * Returns true if this.remoteSDPStreamMetadata is defined, otherwise returns false
+ * @returns can screenshare
+ */
+ opponentSupportsSDPStreamMetadata() {
+ return Boolean(this.remoteSDPStreamMetadata);
+ }
+
+ /**
+ * If there is a screensharing stream returns true, otherwise returns false
+ * @returns is screensharing
+ */
+ isScreensharing() {
+ 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
+ */
+ async setScreensharingEnabled(enabled, opts) {
+ // Skip if there is nothing to do
+ if (enabled && this.isScreensharing()) {
+ _logger.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.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.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, _callEventTypes.SDPStreamMetadataPurpose.Screenshare);
+ return true;
+ } catch (err) {
+ _logger.logger.error(`Call ${this.callId} setScreensharingEnabled() failed to get screen-sharing stream:`, err);
+ return false;
+ }
+ } else {
+ const audioTransceiver = this.transceivers.get(getTransceiverKey(_callEventTypes.SDPStreamMetadataPurpose.Screenshare, "audio"));
+ const videoTransceiver = this.transceivers.get(getTransceiverKey(_callEventTypes.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
+ */
+ async setScreensharingEnabledWithoutMetadataSupport(enabled, opts) {
+ _logger.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(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, "video"))?.sender;
+ sender?.replaceTrack(track ?? null);
+ this.pushNewLocalFeed(stream, _callEventTypes.SDPStreamMetadataPurpose.Screenshare, false);
+ return true;
+ } catch (err) {
+ _logger.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(_callEventTypes.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
+ */
+ async updateLocalUsermediaStream(stream, forceAudio = false, forceVideo = false) {
+ const callFeed = this.localUsermediaFeed;
+ const audioEnabled = forceAudio || !callFeed.isAudioMuted() && !this.remoteOnHold;
+ const videoEnabled = forceVideo || !callFeed.isVideoMuted() && !this.remoteOnHold;
+ _logger.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(_callEventTypes.SDPStreamMetadataPurpose.Usermedia, track.kind);
+ const transceiver = this.transceivers.get(tKey);
+ const oldSender = transceiver?.sender;
+ let added = false;
+ if (oldSender) {
+ try {
+ _logger.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.logger.warn(`Call ${this.callId} updateLocalUsermediaStream() replaceTrack failed: adding new transceiver instead`, error);
+ }
+ }
+ if (!added) {
+ _logger.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.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
+ */
+ async setLocalVideoMuted(muted) {
+ _logger.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).
+ */
+ isLocalVideoMuted() {
+ 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
+ */
+ async setMicrophoneMuted(muted) {
+ _logger.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).
+ */
+ isMicrophoneMuted() {
+ 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)
+ */
+ isRemoteOnHold() {
+ return this.remoteOnHold;
+ }
+ setRemoteOnHold(onHold) {
+ 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
+ */
+ isLocalOnHold() {
+ 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)
+ */
+ sendDtmfDigit(digit) {
+ 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");
+ }
+ updateMuteStatus() {
+ const micShouldBeMuted = this.isMicrophoneMuted() || this.remoteOnHold;
+ const vidShouldBeMuted = this.isLocalVideoMuted() || this.remoteOnHold;
+ _logger.logger.log(`Call ${this.callId} updateMuteStatus stream ${this.localUsermediaStream.id} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`);
+ setTracksEnabled(this.localUsermediaStream.getAudioTracks(), !micShouldBeMuted);
+ setTracksEnabled(this.localUsermediaStream.getVideoTracks(), !vidShouldBeMuted);
+ }
+ async sendMetadataUpdate() {
+ await this.sendVoipEvent(_event.EventType.CallSDPStreamMetadataChangedPrefix, {
+ [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata()
+ });
+ }
+ gotCallFeedsForInvite(callFeeds, requestScreenshareFeed = false) {
+ 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.logger.debug(`Call ${this.callId} gotUserMediaForInvite() run`);
+ // Now we wait for the negotiationneeded event
+ }
+
+ async sendAnswer() {
+ 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
+ },
+ [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true)
+ };
+ 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.logger.info(`Call ${this.callId} sendAnswer() discarding ${discardCount} candidates that will be sent in answer`);
+ try {
+ await this.sendVoipEvent(_event.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 _httpApi.MatrixError && error.event) this.client.cancelPendingEvent(error.event);
+ let code = CallErrorCode.SendAnswer;
+ let message = "Failed to send answer";
+ if (error.name == "UnknownDeviceError") {
+ code = CallErrorCode.UnknownDevices;
+ message = "Unknown devices present in the room";
+ }
+ this.emit(CallEvent.Error, new CallError(code, message, 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();
+ }
+ queueGotCallFeedsForAnswer(callFeeds) {
+ // 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
+ mungeSdp(description, mods) {
+ // The only way to enable DTX at this time is through SDP munging
+ const sdp = (0, _sdpTransform.parse)(description.sdp);
+ sdp.media.forEach(media => {
+ const payloadTypeToCodecMap = new Map();
+ const codecToPayloadTypeMap = new Map();
+ 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.logger.info(`Call ${this.callId} mungeSdp() ignoring SDP modifications for ${mod.codec} as it's not present.`);
+ continue;
+ }
+ const extraConfig = [];
+ 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 = (0, _sdpTransform.write)(sdp);
+ }
+ async createOffer() {
+ const offer = await this.peerConn.createOffer();
+ this.mungeSdp(offer, getCodecParamMods(this.isPtt));
+ return offer;
+ }
+ async createAnswer() {
+ const answer = await this.peerConn.createAnswer();
+ this.mungeSdp(answer, getCodecParamMods(this.isPtt));
+ return answer;
+ }
+ async gotCallFeedsForAnswer(callFeeds) {
+ if (this.callHasEnded()) return;
+ this.waitForLocalAVStream = false;
+ for (const feed of callFeeds) {
+ this.pushLocalFeed(feed);
+ }
+ this.state = CallState.CreateAnswer;
+ let answer;
+ try {
+ this.getRidOfRTXCodecs();
+ answer = await this.createAnswer();
+ } catch (err) {
+ _logger.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.logger.debug(`Call ${this.callId} gotCallFeedsForAnswer() error setting local description!`, err);
+ this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
+ return;
+ }
+ }
+ async onRemoteIceCandidatesReceived(ev) {
+ if (this.callHasEnded()) {
+ //debuglog("Ignoring remote ICE candidate because call has ended");
+ return;
+ }
+ const content = ev.getContent();
+ const candidates = content.candidates;
+ if (!candidates) {
+ _logger.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.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.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.
+ */
+ async onAnswerReceived(event) {
+ const content = event.getContent();
+ _logger.logger.debug(`Call ${this.callId} onAnswerReceived() running (hangupParty=${content.party_id})`);
+ if (this.callHasEnded()) {
+ _logger.logger.debug(`Call ${this.callId} onAnswerReceived() ignoring answer because call has ended`);
+ return;
+ }
+ if (this.opponentPartyId !== undefined) {
+ _logger.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[_callEventTypes.SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ _logger.logger.warn(`Call ${this.callId} onAnswerReceived() did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
+ }
+ try {
+ this.isSettingRemoteAnswerPending = true;
+ await this.peerConn.setRemoteDescription(content.answer);
+ this.isSettingRemoteAnswerPending = false;
+ _logger.logger.debug(`Call ${this.callId} onAnswerReceived() set remote description: ${content.answer.type}`);
+ } catch (e) {
+ this.isSettingRemoteAnswerPending = false;
+ _logger.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(_event.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.logger.warn(`Call ${this.callId} onAnswerReceived() failed to send select_answer event`, err);
+ }
+ }
+ }
+ async onSelectAnswerReceived(event) {
+ if (this.direction !== CallDirection.Inbound) {
+ _logger.logger.warn(`Call ${this.callId} onSelectAnswerReceived() got select_answer for an outbound call: ignoring`);
+ return;
+ }
+ const selectedPartyId = event.getContent().selected_party_id;
+ if (selectedPartyId === undefined || selectedPartyId === null) {
+ _logger.logger.warn(`Call ${this.callId} onSelectAnswerReceived() got nonsensical select_answer with null/undefined selected_party_id: ignoring`);
+ return;
+ }
+ if (selectedPartyId !== this.ourPartyId) {
+ _logger.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);
+ }
+ }
+ async onNegotiateReceived(event) {
+ const content = event.getContent();
+ const description = content.description;
+ if (!description || !description.sdp || !description.type) {
+ _logger.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://w3c.github.io/webrtc-pc/#perfect-negotiation-example
+ const readyForOffer = !this.makingOffer && (this.peerConn.signalingState === "stable" || this.isSettingRemoteAnswerPending);
+ const offerCollision = description.type === "offer" && !readyForOffer;
+ this.ignoreOffer = !polite && offerCollision;
+ if (this.ignoreOffer) {
+ _logger.logger.info(`Call ${this.callId} onNegotiateReceived() ignoring colliding negotiate event because we're impolite`);
+ return;
+ }
+ const prevLocalOnHold = this.isLocalOnHold();
+ const sdpStreamMetadata = content[_callEventTypes.SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ _logger.logger.warn(`Call ${this.callId} onNegotiateReceived() received negotiation event without SDPStreamMetadata!`);
+ }
+ try {
+ this.isSettingRemoteAnswerPending = description.type == "answer";
+ await this.peerConn.setRemoteDescription(description); // SRD rolls back as needed
+ this.isSettingRemoteAnswerPending = false;
+ _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() set remote description: ${description.type}`);
+ if (description.type === "offer") {
+ let answer;
+ try {
+ this.getRidOfRTXCodecs();
+ answer = await this.createAnswer();
+ } catch (err) {
+ _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() failed to create answer: `, err);
+ this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true);
+ return;
+ }
+ await this.peerConn.setLocalDescription(answer);
+ _logger.logger.debug(`Call ${this.callId} onNegotiateReceived() create an answer`);
+ this.sendVoipEvent(_event.EventType.CallNegotiate, {
+ description: this.peerConn.localDescription?.toJSON(),
+ [_callEventTypes.SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true)
+ });
+ }
+ } catch (err) {
+ this.isSettingRemoteAnswerPending = false;
+ _logger.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);
+ }
+ }
+ updateRemoteSDPStreamMetadata(metadata) {
+ this.remoteSDPStreamMetadata = (0, _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;
+ }
+ }
+ onSDPStreamMetadataChangedReceived(event) {
+ const content = event.getContent();
+ const metadata = content[_callEventTypes.SDPStreamMetadataKey];
+ this.updateRemoteSDPStreamMetadata(metadata);
+ }
+ async onAssertedIdentityReceived(event) {
+ const content = event.getContent();
+ if (!content.asserted_identity) return;
+ this.remoteAssertedIdentity = {
+ id: content.asserted_identity.id,
+ displayName: content.asserted_identity.display_name
+ };
+ this.emit(CallEvent.AssertedIdentityChanged, this);
+ }
+ callHasEnded() {
+ // 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;
+ }
+ queueGotLocalOffer() {
+ // 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();
+ }
+ }
+ async wrappedGotLocalOffer() {
+ 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);
+ return;
+ } finally {
+ this.makingOffer = false;
+ }
+ }
+ async gotLocalOffer() {
+ _logger.logger.debug(`Call ${this.callId} gotLocalOffer() running`);
+ if (this.callHasEnded()) {
+ _logger.logger.debug(`Call ${this.callId} gotLocalOffer() ignoring newly created offer because the call has ended"`);
+ return;
+ }
+ let offer;
+ try {
+ this.getRidOfRTXCodecs();
+ offer = await this.createOffer();
+ } catch (err) {
+ _logger.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.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 ? _event.EventType.CallInvite : _event.EventType.CallNegotiate;
+ const content = {
+ lifetime: CALL_TIMEOUT_MS
+ };
+ if (eventType === _event.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[_callEventTypes.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.logger.info(`Call ${this.callId} gotLocalOffer() discarding ${discardCount} candidates that will be sent in offer`);
+ try {
+ await this.sendVoipEvent(eventType, content);
+ } catch (error) {
+ _logger.logger.error(`Call ${this.callId} gotLocalOffer() failed to send invite`, error);
+ if (error instanceof _httpApi.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.name == "UnknownDeviceError") {
+ code = CallErrorCode.UnknownDevices;
+ message = "Unknown devices present in the room";
+ }
+ this.emit(CallEvent.Error, new CallError(code, message, 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);
+ }
+ }
+ /**
+ * 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)
+ */
+ getRidOfRTXCodecs() {
+ // RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF before v113
+ 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(_callEventTypes.SDPStreamMetadataPurpose.Screenshare, "video"));
+ // setCodecPreferences isn't supported on FF (as of v113)
+ screenshareVideoTransceiver?.setCodecPreferences?.(codecs);
+ }
+ /**
+ * @internal
+ */
+ async sendVoipEvent(eventType, content) {
+ 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 = _objectSpread(_objectSpread({}, realContent), {}, {
+ device_id: this.client.deviceId,
+ sender_session_id: this.client.getSessionId(),
+ dest_session_id: this.opponentSessionId,
+ seq: toDeviceSeq,
+ [_event.ToDeviceMessageId]: (0, _uuid.v4)()
+ });
+ 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.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([[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
+ */
+ queueCandidate(content) {
+ // 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
+ discardDuplicateCandidates() {
+ let discardCount = 0;
+ const newQueue = [];
+ 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
+ */
+ async transfer(targetUserId) {
+ // 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
+ };
+ await this.sendVoipEvent(_event.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).
+ */
+ async transferToCall(transferTargetCall) {
+ 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
+ };
+ await transferTargetCall.sendVoipEvent(_event.EventType.CallReplaces, bodyToTransferTarget);
+ const bodyToTransferee = {
+ replacement_id: genCallID(),
+ target_user: {
+ id: targetUserId,
+ display_name: targetProfileInfo?.displayname,
+ avatar_url: targetProfileInfo?.avatar_url
+ },
+ create_call: newCallId
+ };
+ await this.sendVoipEvent(_event.EventType.CallReplaces, bodyToTransferee);
+ await this.terminate(CallParty.Local, CallErrorCode.Transferred, true);
+ await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transferred, true);
+ }
+ async terminate(hangupParty, hangupReason, shouldEmit) {
+ 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);
+ }
+ stopAllMedia() {
+ _logger.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 === _callEventTypes.SDPStreamMetadataPurpose.Usermedia) {
+ this.client.getMediaHandler().stopUserMediaStream(feed.stream);
+ } else if (feed.isLocal() && feed.purpose === _callEventTypes.SDPStreamMetadataPurpose.Screenshare) {
+ this.client.getMediaHandler().stopScreensharingStream(feed.stream);
+ } else if (!feed.isLocal()) {
+ _logger.logger.debug(`Call ${this.callId} stopAllMedia() stopping stream (streamId=${feed.stream.id})`);
+ for (const track of feed.stream.getTracks()) {
+ track.stop();
+ }
+ }
+ }
+ }
+ checkForErrorListener() {
+ if (this.listeners(_typedEventEmitter.EventEmitterEvents.Error).length === 0) {
+ throw new Error("You MUST attach an error listener using call.on('error', function() {})");
+ }
+ }
+ async sendCandidateQueue() {
+ 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.logger.debug(`Call ${this.callId} sendCandidateQueue() attempting to send ${candidates.length} candidates`);
+ try {
+ await this.sendVoipEvent(_event.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 _httpApi.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.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), this);
+ this.hangup(code, false);
+ return;
+ }
+ const delayMs = 500 * Math.pow(2, this.candidateSendTries);
+ ++this.candidateSendTries;
+ _logger.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.
+ */
+ async placeCall(audio, video) {
+ 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.CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ userId: this.client.getUserId(),
+ deviceId: this.client.getDeviceId() ?? undefined,
+ stream,
+ purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: false,
+ videoMuted: false
+ });
+ await this.placeCallWithCallFeeds([callFeed]);
+ } catch (e) {
+ this.getUserMediaFailed(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.
+ */
+ async placeCallWithCallFeeds(callFeeds, requestScreenshareFeed = false) {
+ 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.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.emit(CallEvent.PeerConnectionCreated, this.peerConn, this);
+ this.gotCallFeedsForInvite(callFeeds, requestScreenshareFeed);
+ }
+ createPeerConnection() {
+ 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);
+ const opponentMember = this.getOpponentMember();
+ const opponentMemberId = opponentMember ? opponentMember.userId : "unknown";
+ this.stats?.addStatsReportGatherer(this.callId, opponentMemberId, pc);
+ return pc;
+ }
+ partyIdMatches(msg) {
+ // 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
+ chooseOpponent(ev) {
+ // I choo-choo-choose you
+ const msg = ev.getContent();
+ _logger.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 || {};
+ this.opponentMember = this.client.getRoom(this.roomId).getMember(ev.getSender()) ?? undefined;
+ if (this.opponentMember) {
+ this.stats?.updateOpponentMember(this.callId, this.opponentMember.userId);
+ }
+ }
+ async addBufferedIceCandidates() {
+ const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId);
+ if (bufferedCandidates) {
+ _logger.logger.info(`Call ${this.callId} addBufferedIceCandidates() adding ${bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`);
+ await this.addIceCandidates(bufferedCandidates);
+ }
+ this.remoteCandidateBuffer.clear();
+ }
+ async addIceCandidates(candidates) {
+ for (const candidate of candidates) {
+ if ((candidate.sdpMid === null || candidate.sdpMid === undefined) && (candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined)) {
+ _logger.logger.debug(`Call ${this.callId} addIceCandidates() got remote ICE end-of-candidates`);
+ } else {
+ _logger.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.logger.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err);
+ } else {
+ _logger.logger.debug(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate because ignoring offer`, err);
+ }
+ }
+ }
+ }
+ get hasPeerConnection() {
+ return Boolean(this.peerConn);
+ }
+ initStats(stats, peerId = "unknown") {
+ this.stats = stats;
+ this.stats.start();
+ }
+}
+exports.MatrixCall = MatrixCall;
+function setTracksEnabled(tracks, enabled) {
+ for (const track of tracks) {
+ track.enabled = enabled;
+ }
+}
+function supportsMatrixCall() {
+ // 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.logger.error("WebRTC is not supported in this browser / environment");
+ }
+ return false;
+ }
+ } catch (e) {
+ _logger.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.
+ */
+function createNewMatrixCall(client, roomId, options) {
+ if (!supportsMatrixCall()) return null;
+ const optionsForceTURN = options ? options.forceTURN : false;
+ const opts = {
+ 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;
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js
new file mode 100644
index 0000000000..caf1cc9d2b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js
@@ -0,0 +1,339 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.CallEventHandlerEvent = exports.CallEventHandler = void 0;
+var _logger = require("../logger");
+var _call = require("./call");
+var _event = require("../@types/event");
+var _client = require("../client");
+var _groupCall = require("./groupCall");
+var _room = require("../models/room");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+// 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;
+let CallEventHandlerEvent = /*#__PURE__*/function (CallEventHandlerEvent) {
+ CallEventHandlerEvent["Incoming"] = "Call.incoming";
+ return CallEventHandlerEvent;
+}({});
+exports.CallEventHandlerEvent = CallEventHandlerEvent;
+class CallEventHandler {
+ constructor(client) {
+ // XXX: Most of these are only public because of the tests
+ _defineProperty(this, "calls", void 0);
+ _defineProperty(this, "callEventBuffer", void 0);
+ _defineProperty(this, "nextSeqByCall", new Map());
+ _defineProperty(this, "toDeviceEventBuffers", new Map());
+ _defineProperty(this, "client", void 0);
+ _defineProperty(this, "candidateEventsByCall", void 0);
+ _defineProperty(this, "eventBufferPromiseChain", void 0);
+ _defineProperty(this, "onSync", () => {
+ // 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);
+ }
+ });
+ _defineProperty(this, "onRoomTimeline", event => {
+ this.callEventBuffer.push(event);
+ });
+ _defineProperty(this, "onToDeviceEvent", event => {
+ 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();
+ }
+ }
+ });
+ this.client = client;
+ this.calls = new Map();
+ // 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();
+ }
+ start() {
+ this.client.on(_client.ClientEvent.Sync, this.onSync);
+ this.client.on(_room.RoomEvent.Timeline, this.onRoomTimeline);
+ this.client.on(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ }
+ stop() {
+ this.client.removeListener(_client.ClientEvent.Sync, this.onSync);
+ this.client.removeListener(_room.RoomEvent.Timeline, this.onRoomTimeline);
+ this.client.removeListener(_client.ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ }
+ async evaluateEventBuffer(eventBuffer) {
+ 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();
+
+ // 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 === _event.EventType.CallAnswer || eventType === _event.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 === _event.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.logger.error("CallEventHandler evaluateEventBuffer() caught exception handling call event", e);
+ }
+ }
+ }
+ async handleCallEvent(event) {
+ this.client.emit(_client.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();
+ const senderId = event.getSender();
+ let call = content.call_id ? this.calls.get(content.call_id) : undefined;
+ let opponentDeviceId;
+ let groupCall;
+ if (groupCallId) {
+ groupCall = this.client.groupCallEventHandler.getGroupCallById(groupCallId);
+ if (!groupCall) {
+ _logger.logger.warn(`CallEventHandler handleCallEvent() could not find a group call - ignoring event (groupCallId=${groupCallId}, type=${type})`);
+ return;
+ }
+ opponentDeviceId = content.device_id;
+ if (!opponentDeviceId) {
+ _logger.logger.warn(`CallEventHandler handleCallEvent() could not find a device id - ignoring event (senderId=${senderId})`);
+ groupCall.emit(_groupCall.GroupCallEvent.Error, new _groupCall.GroupCallUnknownDeviceError(senderId));
+ return;
+ }
+ if (content.dest_session_id !== this.client.getSessionId()) {
+ _logger.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 === _event.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 === _call.CallState.Ended) return;
+ if (call) {
+ _logger.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.logger.info("CallEventHandler handleCallEvent() current turn creds expire in " + timeUntilTurnCresExpire + " ms");
+ call = (0, _call.createNewMatrixCall)(this.client, callRoomId, {
+ forceTURN: this.client.forceTURN,
+ opponentDeviceId,
+ groupCallId,
+ opponentSessionId: content.sender_session_id
+ }) ?? undefined;
+ if (!call) {
+ _logger.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 _call.CallError) {
+ if (e.code === _groupCall.GroupCallErrorCode.UnknownDevice) {
+ groupCall?.emit(_groupCall.GroupCallEvent.Error, e);
+ } else {
+ _logger.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;
+ for (const thisCall of this.calls.values()) {
+ const isCalling = [_call.CallState.WaitLocalMedia, _call.CallState.CreateOffer, _call.CallState.InviteSent].includes(thisCall.state);
+ if (call.roomId === thisCall.roomId && thisCall.direction === _call.CallDirection.Outbound && call.getOpponentMember()?.userId === thisCall.invitee && isCalling) {
+ existingCall = thisCall;
+ break;
+ }
+ }
+ if (existingCall) {
+ if (existingCall.callId > call.callId) {
+ _logger.logger.log(`CallEventHandler handleCallEvent() detected glare - answering incoming call and canceling outgoing call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`);
+ existingCall.replacedBy(call);
+ } else {
+ _logger.logger.log(`CallEventHandler handleCallEvent() detected glare - hanging up incoming call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`);
+ call.hangup(_call.CallErrorCode.Replaced, true);
+ }
+ } else {
+ this.client.emit(CallEventHandlerEvent.Incoming, call);
+ }
+ return;
+ } else if (type === _event.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 ([_event.EventType.CallHangup, _event.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 = (0, _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 !== _call.CallState.Ended) {
+ if (type === _event.EventType.CallHangup) {
+ call.onHangupReceived(content);
+ } else {
+ call.onRejectReceived(content);
+ }
+
+ // @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 === _call.CallState.Ended) this.calls.delete(content.call_id);
+ }
+ }
+ return;
+ }
+
+ // The following events need a call and a peer connection
+ if (!call || !call.hasPeerConnection) {
+ _logger.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 _event.EventType.CallAnswer:
+ if (weSentTheEvent) {
+ if (call.state === _call.CallState.Ringing) {
+ call.onAnsweredElsewhere(content);
+ }
+ } else {
+ call.onAnswerReceived(event);
+ }
+ break;
+ case _event.EventType.CallSelectAnswer:
+ call.onSelectAnswerReceived(event);
+ break;
+ case _event.EventType.CallNegotiate:
+ call.onNegotiateReceived(event);
+ break;
+ case _event.EventType.CallAssertedIdentity:
+ case _event.EventType.CallAssertedIdentityPrefix:
+ call.onAssertedIdentityReceived(event);
+ break;
+ case _event.EventType.CallSDPStreamMetadataChanged:
+ case _event.EventType.CallSDPStreamMetadataChangedPrefix:
+ call.onSDPStreamMetadataChangedReceived(event);
+ break;
+ }
+ }
+}
+exports.CallEventHandler = CallEventHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js
new file mode 100644
index 0000000000..fae0c8f1e9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js
@@ -0,0 +1,19 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SDPStreamMetadataPurpose = exports.SDPStreamMetadataKey = void 0;
+// allow non-camelcase as these are events type that go onto the wire
+/* eslint-disable camelcase */
+
+// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
+const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
+exports.SDPStreamMetadataKey = SDPStreamMetadataKey;
+let SDPStreamMetadataPurpose = /*#__PURE__*/function (SDPStreamMetadataPurpose) {
+ SDPStreamMetadataPurpose["Usermedia"] = "m.usermedia";
+ SDPStreamMetadataPurpose["Screenshare"] = "m.screenshare";
+ return SDPStreamMetadataPurpose;
+}({});
+/* eslint-enable camelcase */
+exports.SDPStreamMetadataPurpose = SDPStreamMetadataPurpose; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js
new file mode 100644
index 0000000000..25af7aa5e8
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js
@@ -0,0 +1,294 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SPEAKING_THRESHOLD = exports.CallFeedEvent = exports.CallFeed = void 0;
+var _callEventTypes = require("./callEventTypes");
+var _audioContext = require("./audioContext");
+var _logger = require("../logger");
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _call = require("./call");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+const POLLING_INTERVAL = 200; // ms
+const SPEAKING_THRESHOLD = -60; // dB
+exports.SPEAKING_THRESHOLD = SPEAKING_THRESHOLD;
+const SPEAKING_SAMPLE_COUNT = 8; // samples
+let CallFeedEvent = /*#__PURE__*/function (CallFeedEvent) {
+ CallFeedEvent["NewStream"] = "new_stream";
+ CallFeedEvent["MuteStateChanged"] = "mute_state_changed";
+ CallFeedEvent["LocalVolumeChanged"] = "local_volume_changed";
+ CallFeedEvent["VolumeChanged"] = "volume_changed";
+ CallFeedEvent["ConnectedChanged"] = "connected_changed";
+ CallFeedEvent["Speaking"] = "speaking";
+ CallFeedEvent["Disposed"] = "disposed";
+ return CallFeedEvent;
+}({});
+exports.CallFeedEvent = CallFeedEvent;
+class CallFeed extends _typedEventEmitter.TypedEventEmitter {
+ constructor(opts) {
+ super();
+ _defineProperty(this, "stream", void 0);
+ _defineProperty(this, "sdpMetadataStreamId", void 0);
+ _defineProperty(this, "userId", void 0);
+ _defineProperty(this, "deviceId", void 0);
+ _defineProperty(this, "purpose", void 0);
+ _defineProperty(this, "speakingVolumeSamples", void 0);
+ _defineProperty(this, "client", void 0);
+ _defineProperty(this, "call", void 0);
+ _defineProperty(this, "roomId", void 0);
+ _defineProperty(this, "audioMuted", void 0);
+ _defineProperty(this, "videoMuted", void 0);
+ _defineProperty(this, "localVolume", 1);
+ _defineProperty(this, "measuringVolumeActivity", false);
+ _defineProperty(this, "audioContext", void 0);
+ _defineProperty(this, "analyser", void 0);
+ _defineProperty(this, "frequencyBinCount", void 0);
+ _defineProperty(this, "speakingThreshold", SPEAKING_THRESHOLD);
+ _defineProperty(this, "speaking", false);
+ _defineProperty(this, "volumeLooperTimeout", void 0);
+ _defineProperty(this, "_disposed", false);
+ _defineProperty(this, "_connected", false);
+ _defineProperty(this, "onAddTrack", () => {
+ this.emit(CallFeedEvent.NewStream, this.stream);
+ });
+ _defineProperty(this, "onCallState", state => {
+ if (state === _call.CallState.Connected) {
+ this.connected = true;
+ } else if (state === _call.CallState.Connecting) {
+ this.connected = false;
+ }
+ });
+ _defineProperty(this, "volumeLooper", () => {
+ 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);
+ });
+ 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(_call.CallEvent.State, this.onCallState);
+ this.onCallState(opts.call.state);
+ }
+ }
+ get connected() {
+ // Local feeds are always considered connected
+ return this.isLocal() || this._connected;
+ }
+ set connected(connected) {
+ this._connected = connected;
+ this.emit(CallFeedEvent.ConnectedChanged, this.connected);
+ }
+ get hasAudioTrack() {
+ return this.stream.getAudioTracks().length > 0;
+ }
+ updateStream(oldStream, newStream) {
+ if (newStream === oldStream) return;
+ const wasMeasuringVolumeActivity = this.measuringVolumeActivity;
+ if (oldStream) {
+ oldStream.removeEventListener("addtrack", this.onAddTrack);
+ this.measureVolumeActivity(false);
+ }
+ this.stream = newStream;
+ newStream.addEventListener("addtrack", this.onAddTrack);
+ if (this.hasAudioTrack) {
+ this.initVolumeMeasuring();
+ if (wasMeasuringVolumeActivity) this.measureVolumeActivity(true);
+ } else {
+ this.measureVolumeActivity(false);
+ }
+ this.emit(CallFeedEvent.NewStream, this.stream);
+ }
+ initVolumeMeasuring() {
+ if (!this.hasAudioTrack) return;
+ if (!this.audioContext) this.audioContext = (0, _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);
+ }
+ /**
+ * Returns callRoom member
+ * @returns member of the callRoom
+ */
+ getMember() {
+ 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?
+ */
+ isLocal() {
+ 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?
+ */
+ isAudioMuted() {
+ 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?
+ */
+ isVideoMuted() {
+ // We assume only one video track
+ return this.stream.getVideoTracks().length === 0 || this.videoMuted;
+ }
+ isSpeaking() {
+ 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
+ */
+ setNewStream(newStream) {
+ 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?
+ */
+ setAudioVideoMuted(audioMuted, videoMuted) {
+ 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
+ */
+ measureVolumeActivity(enabled) {
+ 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);
+ }
+ }
+ setSpeakingThreshold(threshold) {
+ this.speakingThreshold = threshold;
+ }
+ clone() {
+ const mediaHandler = this.client.getMediaHandler();
+ const stream = this.stream.clone();
+ _logger.logger.log(`CallFeed clone() cloning stream (originalStreamId=${this.stream.id}, newStreamId${stream.id})`);
+ if (this.purpose === _callEventTypes.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
+ });
+ }
+ dispose() {
+ clearTimeout(this.volumeLooperTimeout);
+ this.stream?.removeEventListener("addtrack", this.onAddTrack);
+ this.call?.removeListener(_call.CallEvent.State, this.onCallState);
+ if (this.audioContext) {
+ this.audioContext = undefined;
+ this.analyser = undefined;
+ (0, _audioContext.releaseContext)();
+ }
+ this._disposed = true;
+ this.emit(CallFeedEvent.Disposed);
+ }
+ get disposed() {
+ return this._disposed;
+ }
+ set disposed(value) {
+ this._disposed = value;
+ }
+ getLocalVolume() {
+ return this.localVolume;
+ }
+ setLocalVolume(localVolume) {
+ this.localVolume = localVolume;
+ this.emit(CallFeedEvent.LocalVolumeChanged, localVolume);
+ }
+}
+exports.CallFeed = CallFeed; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js
new file mode 100644
index 0000000000..ac6da49d3b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js
@@ -0,0 +1,1213 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.OtherUserSpeakingError = exports.GroupCallUnknownDeviceError = exports.GroupCallType = exports.GroupCallTerminationReason = exports.GroupCallStatsReportEvent = exports.GroupCallState = exports.GroupCallIntent = exports.GroupCallEvent = exports.GroupCallErrorCode = exports.GroupCallError = exports.GroupCall = void 0;
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _callFeed = require("./callFeed");
+var _call = require("./call");
+var _roomState = require("../models/room-state");
+var _logger = require("../logger");
+var _ReEmitter = require("../ReEmitter");
+var _callEventTypes = require("./callEventTypes");
+var _event = require("../@types/event");
+var _callEventHandler = require("./callEventHandler");
+var _groupCallEventHandler = require("./groupCallEventHandler");
+var _utils = require("../utils");
+var _groupCallStats = require("./stats/groupCallStats");
+var _statsReport = require("./stats/statsReport");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+let GroupCallIntent = /*#__PURE__*/function (GroupCallIntent) {
+ GroupCallIntent["Ring"] = "m.ring";
+ GroupCallIntent["Prompt"] = "m.prompt";
+ GroupCallIntent["Room"] = "m.room";
+ return GroupCallIntent;
+}({});
+exports.GroupCallIntent = GroupCallIntent;
+let GroupCallType = /*#__PURE__*/function (GroupCallType) {
+ GroupCallType["Video"] = "m.video";
+ GroupCallType["Voice"] = "m.voice";
+ return GroupCallType;
+}({});
+exports.GroupCallType = GroupCallType;
+let GroupCallTerminationReason = /*#__PURE__*/function (GroupCallTerminationReason) {
+ GroupCallTerminationReason["CallEnded"] = "call_ended";
+ return GroupCallTerminationReason;
+}({});
+exports.GroupCallTerminationReason = GroupCallTerminationReason;
+/**
+ * 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.
+ */
+let GroupCallEvent = /*#__PURE__*/function (GroupCallEvent) {
+ GroupCallEvent["GroupCallStateChanged"] = "group_call_state_changed";
+ GroupCallEvent["ActiveSpeakerChanged"] = "active_speaker_changed";
+ GroupCallEvent["CallsChanged"] = "calls_changed";
+ GroupCallEvent["UserMediaFeedsChanged"] = "user_media_feeds_changed";
+ GroupCallEvent["ScreenshareFeedsChanged"] = "screenshare_feeds_changed";
+ GroupCallEvent["LocalScreenshareStateChanged"] = "local_screenshare_state_changed";
+ GroupCallEvent["LocalMuteStateChanged"] = "local_mute_state_changed";
+ GroupCallEvent["ParticipantsChanged"] = "participants_changed";
+ GroupCallEvent["Error"] = "group_call_error";
+ return GroupCallEvent;
+}({});
+exports.GroupCallEvent = GroupCallEvent;
+let GroupCallStatsReportEvent = /*#__PURE__*/function (GroupCallStatsReportEvent) {
+ GroupCallStatsReportEvent["ConnectionStats"] = "GroupCall.connection_stats";
+ GroupCallStatsReportEvent["ByteSentStats"] = "GroupCall.byte_sent_stats";
+ GroupCallStatsReportEvent["SummaryStats"] = "GroupCall.summary_stats";
+ return GroupCallStatsReportEvent;
+}({});
+exports.GroupCallStatsReportEvent = GroupCallStatsReportEvent;
+let GroupCallErrorCode = /*#__PURE__*/function (GroupCallErrorCode) {
+ GroupCallErrorCode["NoUserMedia"] = "no_user_media";
+ GroupCallErrorCode["UnknownDevice"] = "unknown_device";
+ GroupCallErrorCode["PlaceCallFailed"] = "place_call_failed";
+ return GroupCallErrorCode;
+}({});
+exports.GroupCallErrorCode = GroupCallErrorCode;
+class GroupCallError extends Error {
+ constructor(code, msg, err) {
+ // Still don't think there's any way to have proper nested errors
+ if (err) {
+ super(msg + ": " + err);
+ _defineProperty(this, "code", void 0);
+ } else {
+ super(msg);
+ _defineProperty(this, "code", void 0);
+ }
+ this.code = code;
+ }
+}
+exports.GroupCallError = GroupCallError;
+class GroupCallUnknownDeviceError extends GroupCallError {
+ constructor(userId) {
+ super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId);
+ this.userId = userId;
+ }
+}
+exports.GroupCallUnknownDeviceError = GroupCallUnknownDeviceError;
+class OtherUserSpeakingError extends Error {
+ constructor() {
+ super("Cannot unmute: another user is speaking");
+ }
+}
+exports.OtherUserSpeakingError = OtherUserSpeakingError;
+let GroupCallState = /*#__PURE__*/function (GroupCallState) {
+ GroupCallState["LocalCallFeedUninitialized"] = "local_call_feed_uninitialized";
+ GroupCallState["InitializingLocalCallFeed"] = "initializing_local_call_feed";
+ GroupCallState["LocalCallFeedInitialized"] = "local_call_feed_initialized";
+ GroupCallState["Entered"] = "entered";
+ GroupCallState["Ended"] = "ended";
+ return GroupCallState;
+}({});
+exports.GroupCallState = GroupCallState;
+const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour
+
+function getCallUserId(call) {
+ return call.getOpponentMember()?.userId || call.invitee || null;
+}
+class GroupCall extends _typedEventEmitter.TypedEventEmitter {
+ constructor(client, room, type, isPtt, intent, groupCallId, dataChannelsEnabled, dataChannelOptions, isCallWithoutVideoAndAudio) {
+ super();
+ this.client = client;
+ this.room = room;
+ this.type = type;
+ this.isPtt = isPtt;
+ this.intent = intent;
+ this.dataChannelsEnabled = dataChannelsEnabled;
+ this.dataChannelOptions = dataChannelOptions;
+ // Config
+ _defineProperty(this, "activeSpeakerInterval", 1000);
+ _defineProperty(this, "retryCallInterval", 5000);
+ _defineProperty(this, "participantTimeout", 1000 * 15);
+ _defineProperty(this, "pttMaxTransmitTime", 1000 * 20);
+ _defineProperty(this, "activeSpeaker", void 0);
+ _defineProperty(this, "localCallFeed", void 0);
+ _defineProperty(this, "localScreenshareFeed", void 0);
+ _defineProperty(this, "localDesktopCapturerSourceId", void 0);
+ _defineProperty(this, "userMediaFeeds", []);
+ _defineProperty(this, "screenshareFeeds", []);
+ _defineProperty(this, "groupCallId", void 0);
+ _defineProperty(this, "allowCallWithoutVideoAndAudio", void 0);
+ _defineProperty(this, "calls", new Map());
+ // user_id -> device_id -> MatrixCall
+ _defineProperty(this, "callHandlers", new Map());
+ // user_id -> device_id -> ICallHandlers
+ _defineProperty(this, "activeSpeakerLoopInterval", void 0);
+ _defineProperty(this, "retryCallLoopInterval", void 0);
+ _defineProperty(this, "retryCallCounts", new Map());
+ // user_id -> device_id -> count
+ _defineProperty(this, "reEmitter", void 0);
+ _defineProperty(this, "transmitTimer", null);
+ _defineProperty(this, "participantsExpirationTimer", null);
+ _defineProperty(this, "resendMemberStateTimer", null);
+ _defineProperty(this, "initWithAudioMuted", false);
+ _defineProperty(this, "initWithVideoMuted", false);
+ _defineProperty(this, "initCallFeedPromise", void 0);
+ _defineProperty(this, "stats", void 0);
+ /**
+ * Configure default webrtc stats collection interval in ms
+ * Disable collecting webrtc stats by setting interval to 0
+ */
+ _defineProperty(this, "statsCollectIntervalTime", 0);
+ _defineProperty(this, "onConnectionStats", report => {
+ this.emit(GroupCallStatsReportEvent.ConnectionStats, {
+ report
+ });
+ });
+ _defineProperty(this, "onByteSentStats", report => {
+ this.emit(GroupCallStatsReportEvent.ByteSentStats, {
+ report
+ });
+ });
+ _defineProperty(this, "onSummaryStats", report => {
+ this.emit(GroupCallStatsReportEvent.SummaryStats, {
+ report
+ });
+ });
+ _defineProperty(this, "_state", GroupCallState.LocalCallFeedUninitialized);
+ _defineProperty(this, "_participants", new Map());
+ _defineProperty(this, "_creationTs", null);
+ _defineProperty(this, "_enteredViaAnotherSession", false);
+ /*
+ * 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.
+ */
+ _defineProperty(this, "onIncomingCall", newCall => {
+ // The incoming calls may be for another room, which we will ignore.
+ if (newCall.roomId !== this.room.roomId) {
+ return;
+ }
+ if (newCall.state !== _call.CallState.Ringing) {
+ _logger.logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`);
+ return;
+ }
+ if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) {
+ _logger.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.logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`);
+ return;
+ }
+ const deviceMap = this.calls.get(opponentUserId) ?? new Map();
+ const prevCall = deviceMap.get(newCall.getOpponentDeviceId());
+ if (prevCall?.callId === newCall.callId) return;
+ _logger.logger.log(`GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`);
+ if (prevCall) prevCall.hangup(_call.CallErrorCode.Replaced, false);
+ // We must do this before we start initialising / answering the call as we
+ // need to know it is the active call for this user+deviceId and to not ignore
+ // events from it.
+ deviceMap.set(newCall.getOpponentDeviceId(), newCall);
+ this.calls.set(opponentUserId, deviceMap);
+ 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) {
+ (0, _call.setTracksEnabled)(feed.stream.getAudioTracks(), false);
+ (0, _call.setTracksEnabled)(feed.stream.getVideoTracks(), false);
+ }
+ }
+ newCall.answerWithCallFeeds(feeds);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ });
+ _defineProperty(this, "onRetryCallLoop", () => {
+ 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();
+ });
+ _defineProperty(this, "onCallFeedsChanged", call => {
+ 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;
+ const deviceMap = this.calls.get(opponentMemberId);
+ const currentCallForUserDevice = deviceMap?.get(opponentDeviceId);
+ if (currentCallForUserDevice?.callId !== call.callId) {
+ // the call in question is not the current call for this user/deviceId
+ // so ignore feed events from it otherwise we'll remove our real feeds
+ return;
+ }
+ 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);
+ }
+ }
+ });
+ _defineProperty(this, "onCallStateChanged", (call, state, _oldState) => {
+ if (state === _call.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 === _call.CallState.Connected && opponentUserId) {
+ const retriesMap = this.retryCallCounts.get(opponentUserId);
+ retriesMap?.delete(call.getOpponentDeviceId());
+ if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
+ }
+ });
+ _defineProperty(this, "onCallHangup", call => {
+ if (call.hangupReason === _call.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);
+ deviceMap.delete(call.getOpponentDeviceId());
+ if (deviceMap.size === 0) this.calls.delete(opponentUserId);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ }
+ });
+ _defineProperty(this, "onCallReplaced", (prevCall, newCall) => {
+ const opponentUserId = prevCall.getOpponentMember().userId;
+ let deviceMap = this.calls.get(opponentUserId);
+ if (deviceMap === undefined) {
+ deviceMap = new Map();
+ this.calls.set(opponentUserId, deviceMap);
+ }
+ prevCall.hangup(_call.CallErrorCode.Replaced, false);
+ this.initCall(newCall);
+ deviceMap.set(prevCall.getOpponentDeviceId(), newCall);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ });
+ _defineProperty(this, "onActiveSpeakerLoop", () => {
+ let topAvg = undefined;
+ let nextActiveSpeaker = 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, _callFeed.SPEAKING_THRESHOLD));
+ const avg = total / callFeed.speakingVolumeSamples.length;
+ if (!topAvg || avg > topAvg) {
+ topAvg = avg;
+ nextActiveSpeaker = callFeed;
+ }
+ }
+ if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > _callFeed.SPEAKING_THRESHOLD) {
+ this.activeSpeaker = nextActiveSpeaker;
+ this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
+ }
+ });
+ _defineProperty(this, "onRoomState", () => this.updateParticipants());
+ _defineProperty(this, "onParticipantsChanged", () => {
+ // 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()) {
+ (0, _call.setTracksEnabled)(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected);
+ (0, _call.setTracksEnabled)(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected);
+ }
+ });
+ if (this.state === GroupCallState.Entered) this.placeOutgoingCalls();
+ });
+ _defineProperty(this, "onStateChanged", (newState, oldState) => {
+ 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.logger.error(`GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`, e));
+ }
+ });
+ _defineProperty(this, "onLocalFeedsChanged", () => {
+ if (this.state === GroupCallState.Entered) {
+ this.updateMemberState().catch(e => _logger.logger.error(`GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`, e));
+ }
+ });
+ this.reEmitter = new _ReEmitter.ReEmitter(this);
+ this.groupCallId = groupCallId ?? (0, _call.genCallID)();
+ this.creationTs = room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null;
+ this.updateParticipants();
+ room.on(_roomState.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;
+ }
+ async create() {
+ this.creationTs = Date.now();
+ this.client.groupCallEventHandler.groupCalls.set(this.room.roomId, this);
+ this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Outgoing, this);
+ const groupCallState = {
+ "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, _event.EventType.GroupCallPrefix, groupCallState, this.groupCallId);
+ return this;
+ }
+ /**
+ * The group call's state.
+ */
+ get state() {
+ return this._state;
+ }
+ set state(value) {
+ const prevValue = this._state;
+ if (value !== prevValue) {
+ this._state = value;
+ this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue);
+ }
+ }
+ /**
+ * The current participants in the call, as a map from members to device IDs
+ * to participant info.
+ */
+ get participants() {
+ return this._participants;
+ }
+ set participants(value) {
+ const prevValue = this._participants;
+ const participantStateEqual = (x, y) => x.sessionId === y.sessionId && x.screensharing === y.screensharing;
+ const deviceMapsEqual = (x, y) => (0, _utils.mapsEqual)(x, y, participantStateEqual);
+
+ // Only update if the map actually changed
+ if (!(0, _utils.mapsEqual)(value, prevValue, deviceMapsEqual)) {
+ this._participants = value;
+ this.emit(GroupCallEvent.ParticipantsChanged, value);
+ }
+ }
+ /**
+ * The timestamp at which the call was created, or null if it has not yet
+ * been created.
+ */
+ get creationTs() {
+ return this._creationTs;
+ }
+ set creationTs(value) {
+ this._creationTs = value;
+ }
+ /**
+ * Whether the local device has entered this call via another session, such
+ * as a widget.
+ */
+ get enteredViaAnotherSession() {
+ return this._enteredViaAnotherSession;
+ }
+ set enteredViaAnotherSession(value) {
+ this._enteredViaAnotherSession = value;
+ this.updateParticipants();
+ }
+
+ /**
+ * Executes the given callback on all calls in this group call.
+ * @param f - The callback.
+ */
+ forEachCall(f) {
+ for (const deviceMap of this.calls.values()) {
+ for (const call of deviceMap.values()) f(call);
+ }
+ }
+ getLocalFeeds() {
+ const feeds = [];
+ if (this.localCallFeed) feeds.push(this.localCallFeed);
+ if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed);
+ return feeds;
+ }
+ hasLocalParticipant() {
+ 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.
+ */
+ callExpected(call) {
+ 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;
+ }
+ async initLocalCallFeed() {
+ 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;
+ }
+ }
+ async initLocalCallFeedInternal() {
+ _logger.logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`);
+ let stream;
+ 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.CallFeed({
+ client: this.client,
+ roomId: this.room.roomId,
+ userId: this.client.getUserId(),
+ deviceId: this.client.getDeviceId(),
+ stream,
+ purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt,
+ videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0
+ });
+ (0, _call.setTracksEnabled)(stream.getAudioTracks(), !callFeed.isAudioMuted());
+ (0, _call.setTracksEnabled)(stream.getVideoTracks(), !callFeed.isVideoMuted());
+ this.localCallFeed = callFeed;
+ this.addUserMediaFeed(callFeed);
+ this.state = GroupCallState.LocalCallFeedInitialized;
+ }
+ async updateLocalUsermediaStream(stream) {
+ if (this.localCallFeed) {
+ const oldStream = this.localCallFeed.stream;
+ this.localCallFeed.setNewStream(stream);
+ const micShouldBeMuted = this.localCallFeed.isAudioMuted();
+ const vidShouldBeMuted = this.localCallFeed.isVideoMuted();
+ _logger.logger.log(`GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`);
+ (0, _call.setTracksEnabled)(stream.getAudioTracks(), !micShouldBeMuted);
+ (0, _call.setTracksEnabled)(stream.getVideoTracks(), !vidShouldBeMuted);
+ this.client.getMediaHandler().stopUserMediaStream(oldStream);
+ }
+ }
+ async enter() {
+ 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.logger.log(`GroupCall ${this.groupCallId} enter() running`);
+ this.state = GroupCallState.Entered;
+ this.client.on(_callEventHandler.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);
+ }
+ dispose() {
+ 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(_call.CallErrorCode.UserHangup, false));
+ this.activeSpeaker = undefined;
+ clearInterval(this.activeSpeakerLoopInterval);
+ this.retryCallCounts.clear();
+ clearInterval(this.retryCallLoopInterval);
+ this.client.removeListener(_callEventHandler.CallEventHandlerEvent.Incoming, this.onIncomingCall);
+ this.stats?.stop();
+ }
+ leave() {
+ this.dispose();
+ this.state = GroupCallState.LocalCallFeedUninitialized;
+ }
+ async terminate(emitStateEvent = true) {
+ this.dispose();
+ this.room.off(_roomState.RoomStateEvent.Update, this.onRoomState);
+ this.client.groupCallEventHandler.groupCalls.delete(this.room.roomId);
+ this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Ended, this);
+ this.state = GroupCallState.Ended;
+ if (emitStateEvent) {
+ const existingStateEvent = this.room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId);
+ await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallPrefix, _objectSpread(_objectSpread({}, existingStateEvent.getContent()), {}, {
+ "m.terminated": GroupCallTerminationReason.CallEnded
+ }), this.groupCallId);
+ }
+ }
+
+ /*
+ * Local Usermedia
+ */
+
+ isLocalVideoMuted() {
+ if (this.localCallFeed) {
+ return this.localCallFeed.isVideoMuted();
+ }
+ return true;
+ }
+ isMicrophoneMuted() {
+ 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
+ */
+ async setMicrophoneMuted(muted) {
+ // 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 () => {
+ const updates = [];
+ this.forEachCall(call => updates.push(call.sendMetadataUpdate()));
+ await Promise.all(updates).catch(e => _logger.logger.info(`GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`, e));
+ };
+ if (sendUpdatesBefore) await sendUpdates();
+ if (this.localCallFeed) {
+ _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`);
+ const hasPermission = await this.checkAudioPermissionIfNecessary(muted);
+ if (!hasPermission) {
+ 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.
+ (0, _call.setTracksEnabled)(this.localCallFeed.stream.getAudioTracks(), !muted);
+ } else {
+ _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`);
+ this.initWithAudioMuted = muted;
+ }
+ this.forEachCall(call => (0, _call.setTracksEnabled)(call.localUsermediaFeed.stream.getAudioTracks(), !muted && this.callExpected(call)));
+ this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted());
+ if (!sendUpdatesBefore) await sendUpdates();
+ return true;
+ }
+
+ /**
+ * If we allow entering a call without a camera and without video, it can happen that the access rights to the
+ * devices have not yet been queried. If a stream does not yet have an audio track, we assume that the rights have
+ * not yet been checked.
+ *
+ * `this.client.getMediaHandler().getUserMediaStream` clones the current stream, so it only wanted to be called when
+ * not Audio Track exists.
+ * As such, this is a compromise, because, the access rights should always be queried before the call.
+ */
+ async checkAudioPermissionIfNecessary(muted) {
+ // We needed this here to avoid an error in case user join a call without a device.
+ try {
+ if (!muted && this.localCallFeed && !this.localCallFeed.hasAudioTrack) {
+ const stream = await this.client.getMediaHandler().getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
+ if (stream?.getTracks().length === 0) {
+ // if case permission denied to get a stream stop this here
+ /* istanbul ignore next */
+ _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`);
+ return false;
+ }
+ }
+ } catch (e) {
+ /* istanbul ignore next */
+ _logger.logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`);
+ return false;
+ }
+ 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
+ */
+ async setLocalVideoMuted(muted) {
+ // 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.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);
+ (0, _call.setTracksEnabled)(this.localCallFeed.stream.getVideoTracks(), !muted);
+ } catch (_) {
+ // No permission to video device
+ /* istanbul ignore next */
+ _logger.logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`);
+ return false;
+ }
+ } else {
+ _logger.logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
+ this.initWithVideoMuted = muted;
+ }
+ const updates = [];
+ 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 => (0, _call.setTracksEnabled)(call.localUsermediaFeed.stream.getVideoTracks(), !muted && this.callExpected(call)));
+ this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted);
+ return true;
+ }
+ async setScreensharingEnabled(enabled, opts = {}) {
+ if (enabled === this.isScreensharing()) {
+ return enabled;
+ }
+ if (enabled) {
+ try {
+ _logger.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 = () => {
+ this.setScreensharingEnabled(false);
+ track.removeEventListener("ended", onTrackEnded);
+ };
+ track.addEventListener("ended", onTrackEnded);
+ }
+ _logger.logger.log(`GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`);
+ this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId;
+ this.localScreenshareFeed = new _callFeed.CallFeed({
+ client: this.client,
+ roomId: this.room.roomId,
+ userId: this.client.getUserId(),
+ deviceId: this.client.getDeviceId(),
+ stream,
+ purpose: _callEventTypes.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.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));
+ 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;
+ }
+ }
+ isScreensharing() {
+ return !!this.localScreenshareFeed;
+ }
+ /**
+ * 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.
+ */
+ wantsOutgoingCall(userId, deviceId) {
+ 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.
+ */
+ placeOutgoingCalls() {
+ let callsChanged = false;
+ for (const [{
+ userId
+ }, participantMap] of this.participants) {
+ const callMap = this.calls.get(userId) ?? new Map();
+ 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.logger.debug(`GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`);
+ prevCall.hangup(_call.CallErrorCode.NewSession, false);
+ }
+ const newCall = (0, _call.createNewMatrixCall)(this.client, this.room.roomId, {
+ invitee: userId,
+ opponentDeviceId: deviceId,
+ opponentSessionId: participant.sessionId,
+ groupCallId: this.groupCallId
+ });
+ if (newCall === null) {
+ _logger.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.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.logger.warn(`GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`, e);
+ if (e instanceof _call.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(_call.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
+ */
+
+ getMemberStateEvents(userId) {
+ return userId === undefined ? this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix) : this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix, userId);
+ }
+ initCall(call) {
+ const opponentMemberId = getCallUserId(call);
+ if (!opponentMemberId) {
+ throw new Error("Cannot init call without user id");
+ }
+ const onCallFeedsChanged = () => this.onCallFeedsChanged(call);
+ const onCallStateChanged = (state, oldState) => this.onCallStateChanged(call, state, oldState);
+ const onCallHangup = this.onCallHangup;
+ const onCallReplaced = newCall => 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(_call.CallEvent.FeedsChanged, onCallFeedsChanged);
+ call.on(_call.CallEvent.State, onCallStateChanged);
+ call.on(_call.CallEvent.Hangup, onCallHangup);
+ call.on(_call.CallEvent.Replaced, onCallReplaced);
+ call.isPtt = this.isPtt;
+ this.reEmitter.reEmit(call, Object.values(_call.CallEvent));
+ call.initStats(this.getGroupCallStats());
+ onCallFeedsChanged();
+ }
+ disposeCall(call, hangupReason) {
+ 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(_call.CallEvent.FeedsChanged, onCallFeedsChanged);
+ call.removeListener(_call.CallEvent.State, onCallStateChanged);
+ call.removeListener(_call.CallEvent.Hangup, onCallHangup);
+ call.removeListener(_call.CallEvent.Replaced, onCallReplaced);
+ deviceMap.delete(opponentMemberId);
+ if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId);
+ if (call.hangupReason === _call.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);
+ }
+ }
+ /*
+ * UserMedia CallFeed Event Handlers
+ */
+
+ getUserMediaFeed(userId, deviceId) {
+ return this.userMediaFeeds.find(f => f.userId === userId && f.deviceId === deviceId);
+ }
+ addUserMediaFeed(callFeed) {
+ this.userMediaFeeds.push(callFeed);
+ callFeed.measureVolumeActivity(true);
+ this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
+ }
+ replaceUserMediaFeed(existingFeed, replacementFeed) {
+ 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);
+ }
+ removeUserMediaFeed(callFeed) {
+ 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);
+ }
+ }
+ /*
+ * Screenshare Call Feed Event Handlers
+ */
+
+ getScreenshareFeed(userId, deviceId) {
+ return this.screenshareFeeds.find(f => f.userId === userId && f.deviceId === deviceId);
+ }
+ addScreenshareFeed(callFeed) {
+ this.screenshareFeeds.push(callFeed);
+ this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
+ }
+ replaceScreenshareFeed(existingFeed, replacementFeed) {
+ 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);
+ }
+ removeScreenshareFeed(callFeed) {
+ 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.
+ */
+ updateParticipants() {
+ 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.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();
+ 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();
+ const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
+ const call = calls.find(call => call["m.call_id"] === this.groupCallId);
+ const devices = 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));
+
+ // 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();
+ 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 === _callEventTypes.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 === _callEventTypes.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.
+ */
+ async updateDevices(fn, keepAlive = false) {
+ const now = Date.now();
+ const localUserId = this.client.getUserId();
+ const event = this.getMemberStateEvents(localUserId);
+ const content = event?.getContent() ?? {};
+ const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
+ let call = null;
+ const otherCalls = [];
+ for (const c of calls) {
+ if (c["m.call_id"] === this.groupCallId) {
+ call = c;
+ } else {
+ otherCalls.push(c);
+ }
+ }
+ if (call === null) call = {};
+ const devices = 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));
+ const newDevices = fn(validDevices);
+ if (newDevices === null) return;
+ const newCalls = [...otherCalls];
+ if (newDevices.length > 0) {
+ newCalls.push(_objectSpread(_objectSpread({}, call), {}, {
+ "m.call_id": this.groupCallId,
+ "m.devices": newDevices
+ }));
+ }
+ const newContent = {
+ "m.calls": newCalls
+ };
+ await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallMemberPrefix, newContent, localUserId, {
+ keepAlive
+ });
+ }
+ async addDeviceToMemberState() {
+ 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
+ }]);
+ }
+
+ async updateMemberState() {
+ // 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.logger.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`);
+ try {
+ await this.addDeviceToMemberState();
+ } catch (e) {
+ _logger.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).
+ */
+ async cleanMemberState() {
+ const {
+ devices: myDevices
+ } = await this.client.getDevices();
+ const deviceMap = new Map(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;
+ });
+ }
+ getGroupCallStats() {
+ if (this.stats === undefined) {
+ const userID = this.client.getUserId() || "unknown";
+ this.stats = new _groupCallStats.GroupCallStats(this.groupCallId, userID, this.statsCollectIntervalTime);
+ this.stats.reports.on(_statsReport.StatsReport.CONNECTION_STATS, this.onConnectionStats);
+ this.stats.reports.on(_statsReport.StatsReport.BYTE_SENT_STATS, this.onByteSentStats);
+ this.stats.reports.on(_statsReport.StatsReport.SUMMARY_STATS, this.onSummaryStats);
+ }
+ return this.stats;
+ }
+ setGroupCallStatsInterval(interval) {
+ this.statsCollectIntervalTime = interval;
+ if (this.stats !== undefined) {
+ this.stats.stop();
+ this.stats.setInterval(interval);
+ if (interval > 0) {
+ this.stats.start();
+ }
+ }
+ }
+}
+exports.GroupCall = GroupCall; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js
new file mode 100644
index 0000000000..6c80c5b4da
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js
@@ -0,0 +1,181 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.GroupCallEventHandlerEvent = exports.GroupCallEventHandler = void 0;
+var _client = require("../client");
+var _groupCall = require("./groupCall");
+var _roomState = require("../models/room-state");
+var _logger = require("../logger");
+var _event = require("../@types/event");
+var _sync = require("../sync");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let GroupCallEventHandlerEvent = /*#__PURE__*/function (GroupCallEventHandlerEvent) {
+ GroupCallEventHandlerEvent["Incoming"] = "GroupCall.incoming";
+ GroupCallEventHandlerEvent["Outgoing"] = "GroupCall.outgoing";
+ GroupCallEventHandlerEvent["Ended"] = "GroupCall.ended";
+ GroupCallEventHandlerEvent["Participants"] = "GroupCall.participants";
+ return GroupCallEventHandlerEvent;
+}({});
+exports.GroupCallEventHandlerEvent = GroupCallEventHandlerEvent;
+class GroupCallEventHandler {
+ constructor(client) {
+ this.client = client;
+ _defineProperty(this, "groupCalls", new Map());
+ // 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
+ _defineProperty(this, "roomDeferreds", new Map());
+ _defineProperty(this, "onRoomsChanged", room => {
+ this.createGroupCallForRoom(room);
+ });
+ _defineProperty(this, "onRoomStateChanged", (event, state) => {
+ const eventType = event.getType();
+ if (eventType === _event.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.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.logger.warn(`GroupCallEventHandler onRoomStateChanged() currently does not support multiple calls (roomId=${state.roomId})`);
+ }
+ }
+ });
+ }
+ async start() {
+ // 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() !== _sync.SyncState.Syncing) {
+ _logger.logger.debug("GroupCallEventHandler start() waiting for client to start syncing");
+ await new Promise(resolve => {
+ const onSync = () => {
+ if (this.client.getSyncState() === _sync.SyncState.Syncing) {
+ this.client.off(_client.ClientEvent.Sync, onSync);
+ return resolve();
+ }
+ };
+ this.client.on(_client.ClientEvent.Sync, onSync);
+ });
+ }
+ const rooms = this.client.getRooms();
+ for (const room of rooms) {
+ this.createGroupCallForRoom(room);
+ }
+ this.client.on(_client.ClientEvent.Room, this.onRoomsChanged);
+ this.client.on(_roomState.RoomStateEvent.Events, this.onRoomStateChanged);
+ }
+ stop() {
+ this.client.removeListener(_roomState.RoomStateEvent.Events, this.onRoomStateChanged);
+ }
+ getRoomDeferred(roomId) {
+ let deferred = this.roomDeferreds.get(roomId);
+ if (deferred === undefined) {
+ let resolveFunc;
+ deferred = {
+ prom: new Promise(resolve => {
+ resolveFunc = resolve;
+ })
+ };
+ deferred.resolve = resolveFunc;
+ this.roomDeferreds.set(roomId, deferred);
+ }
+ return deferred;
+ }
+ waitUntilRoomReadyForGroupCalls(roomId) {
+ return this.getRoomDeferred(roomId).prom;
+ }
+ getGroupCallById(groupCallId) {
+ return [...this.groupCalls.values()].find(groupCall => groupCall.groupCallId === groupCallId);
+ }
+ createGroupCallForRoom(room) {
+ const callEvents = room.currentState.getStateEvents(_event.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.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.logger.info(`GroupCallEventHandler createGroupCallForRoom() processed room (roomId=${room.roomId})`);
+ this.getRoomDeferred(room.roomId).resolve();
+ }
+ createGroupCallFromRoomStateEvent(event) {
+ const roomId = event.getRoomId();
+ const content = event.getContent();
+ const room = this.client.getRoom(roomId);
+ if (!room) {
+ _logger.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(_groupCall.GroupCallType).includes(callType)) {
+ _logger.logger.warn(`GroupCallEventHandler createGroupCallFromRoomStateEvent() received invalid call type (type=${callType}, roomId=${roomId})`);
+ return;
+ }
+ const callIntent = content["m.intent"];
+ if (!Object.values(_groupCall.GroupCallIntent).includes(callIntent)) {
+ _logger.logger.warn(`Received invalid group call intent (type=${callType}, roomId=${roomId})`);
+ return;
+ }
+ const isPtt = Boolean(content["io.element.ptt"]);
+ let dataChannelOptions;
+ 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.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;
+ }
+}
+exports.GroupCallEventHandler = GroupCallEventHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js
new file mode 100644
index 0000000000..2077d05a27
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js
@@ -0,0 +1,395 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaHandlerEvent = exports.MediaHandler = void 0;
+var _typedEventEmitter = require("../models/typed-event-emitter");
+var _groupCall = require("../webrtc/groupCall");
+var _logger = require("../logger");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+let MediaHandlerEvent = /*#__PURE__*/function (MediaHandlerEvent) {
+ MediaHandlerEvent["LocalStreamsChanged"] = "local_streams_changed";
+ return MediaHandlerEvent;
+}({});
+exports.MediaHandlerEvent = MediaHandlerEvent;
+class MediaHandler extends _typedEventEmitter.TypedEventEmitter {
+ constructor(client) {
+ super();
+ this.client = client;
+ _defineProperty(this, "audioInput", void 0);
+ _defineProperty(this, "audioSettings", void 0);
+ _defineProperty(this, "videoInput", void 0);
+ _defineProperty(this, "localUserMediaStream", void 0);
+ _defineProperty(this, "userMediaStreams", []);
+ _defineProperty(this, "screensharingStreams", []);
+ // Promise chain to serialise calls to getMediaStream
+ _defineProperty(this, "getMediaStreamPromise", void 0);
+ }
+ restoreMediaSettings(audioInput, videoInput) {
+ 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
+ */
+ async setAudioInput(deviceId) {
+ _logger.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
+ */
+ async setAudioSettings(opts) {
+ _logger.logger.info(`MediaHandler setAudioSettings() running (opts=${JSON.stringify(opts)})`);
+ this.audioSettings = Object.assign({}, opts);
+ await this.updateLocalUsermediaStreams();
+ }
+
+ /**
+ * Set a video input device to use for MatrixCalls
+ * @param deviceId - the identifier for the device
+ * undefined treated as unset
+ */
+ async setVideoInput(deviceId) {
+ _logger.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
+ */
+ async setMediaInputs(audioInput, videoInput) {
+ _logger.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
+ */
+ async updateLocalUsermediaStreams() {
+ if (this.userMediaStreams.length === 0) return;
+ const callMediaStreamParams = 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.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.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.logger.log(`MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (groupCallId=${groupCall.groupCallId})`);
+ const stream = await this.getUserMediaStream(true, groupCall.type === _groupCall.GroupCallType.Video);
+ if (groupCall.state === _groupCall.GroupCallState.Ended) {
+ continue;
+ }
+ await groupCall.updateLocalUsermediaStream(stream);
+ }
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+ }
+ async hasAudioDevice() {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return devices.filter(device => device.kind === "audioinput").length > 0;
+ } catch (err) {
+ _logger.logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
+ return false;
+ }
+ }
+ async hasVideoDevice() {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return devices.filter(device => device.kind === "videoinput").length > 0;
+ } catch (err) {
+ _logger.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
+ */
+ async getUserMediaStream(audio, video, reusable = true) {
+ // 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;
+ }
+ async getUserMediaStreamInternal(audio, video, reusable) {
+ const shouldRequestAudio = audio && (await this.hasAudioDevice());
+ const shouldRequestVideo = video && (await this.hasVideoDevice());
+ let stream;
+ 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.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.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
+ */
+ stopUserMediaStream(mediaStream) {
+ _logger.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.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
+ */
+ async getScreensharingStream(opts = {}, reusable = true) {
+ let stream;
+ if (this.screensharingStreams.length === 0) {
+ const screenshareConstraints = this.getScreenshareContraints(opts);
+ if (opts.desktopCapturerSourceId) {
+ // We are using Electron
+ _logger.logger.debug(`MediaHandler getScreensharingStream() calling getUserMedia() (opts=${JSON.stringify(opts)})`);
+ stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
+ } else {
+ // We are not using Electron
+ _logger.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.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
+ */
+ stopScreensharingStream(mediaStream) {
+ _logger.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.logger.debug(`MediaHandler stopScreensharingStream() splicing stream out (streamId=${mediaStream.id})`);
+ this.screensharingStreams.splice(index, 1);
+ }
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+ }
+
+ /**
+ * Stops all local media tracks
+ */
+ stopAllStreams() {
+ for (const stream of this.userMediaStreams) {
+ _logger.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);
+ }
+ getUserMediaContraints(audio, video) {
+ 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
+ };
+ }
+ getScreenshareContraints(opts) {
+ const {
+ desktopCapturerSourceId,
+ audio
+ } = opts;
+ if (desktopCapturerSourceId) {
+ return {
+ audio: audio ?? false,
+ video: {
+ mandatory: {
+ chromeMediaSource: "desktop",
+ chromeMediaSourceId: desktopCapturerSourceId
+ }
+ }
+ };
+ } else {
+ return {
+ audio: audio ?? false,
+ video: true
+ };
+ }
+ }
+}
+exports.MediaHandler = MediaHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js
new file mode 100644
index 0000000000..b830e469a1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js
@@ -0,0 +1,194 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.CallStatsReportGatherer = void 0;
+var _connectionStats = require("./connectionStats");
+var _connectionStatsBuilder = require("./connectionStatsBuilder");
+var _transportStatsBuilder = require("./transportStatsBuilder");
+var _mediaSsrcHandler = require("./media/mediaSsrcHandler");
+var _mediaTrackHandler = require("./media/mediaTrackHandler");
+var _mediaTrackStatsHandler = require("./media/mediaTrackStatsHandler");
+var _trackStatsBuilder = require("./trackStatsBuilder");
+var _connectionStatsReportBuilder = require("./connectionStatsReportBuilder");
+var _valueFormatter = require("./valueFormatter");
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class CallStatsReportGatherer {
+ constructor(callId, opponentMemberId, pc, emitter, isFocus = true) {
+ this.callId = callId;
+ this.opponentMemberId = opponentMemberId;
+ this.pc = pc;
+ this.emitter = emitter;
+ this.isFocus = isFocus;
+ _defineProperty(this, "isActive", true);
+ _defineProperty(this, "previousStatsReport", void 0);
+ _defineProperty(this, "currentStatsReport", void 0);
+ _defineProperty(this, "connectionStats", new _connectionStats.ConnectionStats());
+ _defineProperty(this, "trackStats", void 0);
+ pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this));
+ this.trackStats = new _mediaTrackStatsHandler.MediaTrackStatsHandler(new _mediaSsrcHandler.MediaSsrcHandler(), new _mediaTrackHandler.MediaTrackHandler(pc));
+ }
+ async processStats(groupCallId, localUserId) {
+ const summary = {
+ isFirstCollection: this.previousStatsReport === undefined,
+ receivedMedia: 0,
+ receivedAudioMedia: 0,
+ receivedVideoMedia: 0,
+ audioTrackSummary: {
+ count: 0,
+ muted: 0,
+ maxPacketLoss: 0,
+ maxJitter: 0,
+ concealedAudio: 0,
+ totalAudio: 0
+ },
+ videoTrackSummary: {
+ count: 0,
+ muted: 0,
+ maxPacketLoss: 0,
+ maxJitter: 0,
+ concealedAudio: 0,
+ totalAudio: 0
+ }
+ };
+ 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 summary;
+ }
+ this.previousStatsReport = this.currentStatsReport;
+ summary.receivedMedia = this.connectionStats.bitrate.download;
+ summary.receivedAudioMedia = this.connectionStats.bitrate.audio?.download || 0;
+ summary.receivedVideoMedia = this.connectionStats.bitrate.video?.download || 0;
+ const trackSummary = _trackStatsBuilder.TrackStatsBuilder.buildTrackSummary(Array.from(this.trackStats.getTrack2stats().values()));
+ return _objectSpread(_objectSpread({}, summary), {}, {
+ audioTrackSummary: trackSummary.audioTrackSummary,
+ videoTrackSummary: trackSummary.videoTrackSummary
+ });
+ }).catch(error => {
+ this.handleError(error);
+ return summary;
+ });
+ }
+ this.isActive = false;
+ }
+ return Promise.resolve(summary);
+ }
+ processStatsReport(groupCallId, localUserId) {
+ const byteSentStatsReport = new Map();
+ byteSentStatsReport.callId = this.callId;
+ byteSentStatsReport.opponentMemberId = this.opponentMemberId;
+ 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 = _connectionStatsBuilder.ConnectionStatsBuilder.buildBandwidthReport(now);
+ this.connectionStats.transport = _transportStatsBuilder.TransportStatsBuilder.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) {
+ _trackStatsBuilder.TrackStatsBuilder.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") {
+ _trackStatsBuilder.TrackStatsBuilder.buildFramerateResolution(trackStats, now);
+ if (before) {
+ _trackStatsBuilder.TrackStatsBuilder.buildBitrateReceived(trackStats, now, before);
+ }
+ const ts = this.trackStats.findTransceiverByTrackId(trackStats.trackId);
+ _trackStatsBuilder.TrackStatsBuilder.setTrackStatsState(trackStats, ts);
+ _trackStatsBuilder.TrackStatsBuilder.buildJitter(trackStats, now);
+ _trackStatsBuilder.TrackStatsBuilder.buildAudioConcealment(trackStats, now);
+ } else if (before) {
+ byteSentStatsReport.set(trackStats.trackId, _valueFormatter.ValueFormatter.getNonNegativeValue(now.bytesSent));
+ _trackStatsBuilder.TrackStatsBuilder.buildBitrateSend(trackStats, now, before);
+ }
+ _trackStatsBuilder.TrackStatsBuilder.buildCodec(this.currentStatsReport, trackStats, now);
+ } else if (now.type === "track" && now.kind === "video" && !now.remoteSource) {
+ const trackStats = this.trackStats.findLocalVideoTrackStats(now);
+ if (!trackStats) {
+ return;
+ }
+ _trackStatsBuilder.TrackStatsBuilder.buildFramerateResolution(trackStats, now);
+ _trackStatsBuilder.TrackStatsBuilder.calculateSimulcastFramerate(trackStats, now, before, this.trackStats.mediaTrackHandler.getActiveSimulcastStreams());
+ }
+ });
+ this.emitter.emitByteSendReport(byteSentStatsReport);
+ this.processAndEmitConnectionStatsReport();
+ }
+ setActive(isActive) {
+ this.isActive = isActive;
+ }
+ getActive() {
+ return this.isActive;
+ }
+ handleError(_) {
+ this.isActive = false;
+ }
+ processAndEmitConnectionStatsReport() {
+ const report = _connectionStatsReportBuilder.ConnectionStatsReportBuilder.build(this.trackStats.getTrack2stats());
+ report.callId = this.callId;
+ report.opponentMemberId = this.opponentMemberId;
+ this.connectionStats.bandwidth = report.bandwidth;
+ this.connectionStats.bitrate = report.bitrate;
+ this.connectionStats.packetLoss = report.packetLoss;
+ this.emitter.emitConnectionStatsReport(_objectSpread(_objectSpread({}, report), {}, {
+ transport: this.connectionStats.transport
+ }));
+ this.connectionStats.transport = [];
+ }
+ stopProcessingStats() {}
+ onSignalStateChange() {
+ 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");
+ }
+ }
+ }
+ setOpponentMemberId(id) {
+ this.opponentMemberId = id;
+ }
+}
+exports.CallStatsReportGatherer = CallStatsReportGatherer; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js
new file mode 100644
index 0000000000..16374812d0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js
@@ -0,0 +1,34 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ConnectionStats = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+class ConnectionStats {
+ constructor() {
+ _defineProperty(this, "bandwidth", {});
+ _defineProperty(this, "bitrate", {});
+ _defineProperty(this, "packetLoss", {});
+ _defineProperty(this, "transport", []);
+ }
+}
+exports.ConnectionStats = ConnectionStats; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js
new file mode 100644
index 0000000000..64bf4082ff
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js
@@ -0,0 +1,33 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ConnectionStatsBuilder = void 0;
+/*
+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.
+*/
+
+class ConnectionStatsBuilder {
+ static buildBandwidthReport(now) {
+ const availableIncomingBitrate = now.availableIncomingBitrate;
+ const availableOutgoingBitrate = now.availableOutgoingBitrate;
+ return {
+ download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0,
+ upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0
+ };
+ }
+}
+exports.ConnectionStatsBuilder = ConnectionStatsBuilder; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js
new file mode 100644
index 0000000000..7178b5411e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js
@@ -0,0 +1,127 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ConnectionStatsReportBuilder = void 0;
+/*
+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.
+*/
+
+class ConnectionStatsReportBuilder {
+ static build(stats) {
+ const report = {};
+
+ // process stats
+ const totalPackets = {
+ download: 0,
+ upload: 0
+ };
+ const lostPackets = {
+ download: 0,
+ upload: 0
+ };
+ let bitrateDownload = 0;
+ let bitrateUpload = 0;
+ const resolutions = {
+ local: new Map(),
+ remote: new Map()
+ };
+ const framerates = {
+ local: new Map(),
+ remote: new Map()
+ };
+ const codecs = {
+ local: new Map(),
+ remote: new Map()
+ };
+ const jitter = new Map();
+ const audioConcealment = new Map();
+ let audioBitrateDownload = 0;
+ let audioBitrateUpload = 0;
+ let videoBitrateDownload = 0;
+ let videoBitrateUpload = 0;
+ let totalConcealedAudio = 0;
+ let totalAudioDuration = 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") {
+ // process audio quality stats
+ const audioConcealmentForTrack = trackStats.getAudioConcealment();
+ totalConcealedAudio += audioConcealmentForTrack.concealedAudio;
+ totalAudioDuration += audioConcealmentForTrack.totalAudioDuration;
+ 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());
+ if (trackStats.getType() === "remote") {
+ jitter.set(trackId, trackStats.getJitter());
+ if (trackStats.kind === "audio") {
+ audioConcealment.set(trackId, trackStats.getAudioConcealment());
+ }
+ }
+ 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: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.download + lostPackets.upload, totalPackets.download + totalPackets.upload),
+ download: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download),
+ upload: ConnectionStatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload)
+ };
+ report.audioConcealment = audioConcealment;
+ report.totalAudioConcealment = {
+ concealedAudio: totalConcealedAudio,
+ totalAudioDuration
+ };
+ report.framerate = framerates;
+ report.resolution = resolutions;
+ report.codec = codecs;
+ report.jitter = jitter;
+ return report;
+ }
+ static calculatePacketLoss(lostPackets, totalPackets) {
+ if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) {
+ return 0;
+ }
+ return Math.round(lostPackets / totalPackets * 100);
+ }
+}
+exports.ConnectionStatsReportBuilder = ConnectionStatsReportBuilder; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js
new file mode 100644
index 0000000000..4ed8a1062f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js
@@ -0,0 +1,80 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.GroupCallStats = void 0;
+var _callStatsReportGatherer = require("./callStatsReportGatherer");
+var _statsReportEmitter = require("./statsReportEmitter");
+var _summaryStatsReportGatherer = require("./summaryStatsReportGatherer");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class GroupCallStats {
+ constructor(groupCallId, userId, interval = 10000) {
+ this.groupCallId = groupCallId;
+ this.userId = userId;
+ this.interval = interval;
+ _defineProperty(this, "timer", void 0);
+ _defineProperty(this, "gatherers", new Map());
+ _defineProperty(this, "reports", new _statsReportEmitter.StatsReportEmitter());
+ _defineProperty(this, "summaryStatsReportGatherer", new _summaryStatsReportGatherer.SummaryStatsReportGatherer(this.reports));
+ }
+ start() {
+ if (this.timer === undefined && this.interval > 0) {
+ this.timer = setInterval(() => {
+ this.processStats();
+ }, this.interval);
+ }
+ }
+ stop() {
+ if (this.timer !== undefined) {
+ clearInterval(this.timer);
+ this.gatherers.forEach(c => c.stopProcessingStats());
+ }
+ }
+ hasStatsReportGatherer(callId) {
+ return this.gatherers.has(callId);
+ }
+ addStatsReportGatherer(callId, opponentMemberId, peerConnection) {
+ if (this.hasStatsReportGatherer(callId)) {
+ return false;
+ }
+ this.gatherers.set(callId, new _callStatsReportGatherer.CallStatsReportGatherer(callId, opponentMemberId, peerConnection, this.reports));
+ return true;
+ }
+ removeStatsReportGatherer(callId) {
+ return this.gatherers.delete(callId);
+ }
+ getStatsReportGatherer(callId) {
+ return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined;
+ }
+ updateOpponentMember(callId, opponentMember) {
+ this.getStatsReportGatherer(callId)?.setOpponentMemberId(opponentMember);
+ }
+ processStats() {
+ const summary = [];
+ this.gatherers.forEach(c => {
+ summary.push(c.processStats(this.groupCallId, this.userId));
+ });
+ Promise.all(summary).then(s => this.summaryStatsReportGatherer.build(s));
+ }
+ setInterval(interval) {
+ this.interval = interval;
+ }
+}
+exports.GroupCallStats = GroupCallStats; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js
new file mode 100644
index 0000000000..5e43415558
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js
@@ -0,0 +1,62 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaSsrcHandler = void 0;
+var _sdpTransform = require("sdp-transform");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class MediaSsrcHandler {
+ constructor() {
+ _defineProperty(this, "ssrcToMid", {
+ local: new Map(),
+ remote: new Map()
+ });
+ }
+ findMidBySsrc(ssrc, type) {
+ let mid;
+ this.ssrcToMid[type].forEach((ssrcs, m) => {
+ if (ssrcs.find(s => s == ssrc)) {
+ mid = m;
+ return;
+ }
+ });
+ return mid;
+ }
+ parse(description, type) {
+ const sdp = (0, _sdpTransform.parse)(description);
+ const ssrcToMid = new Map();
+ sdp.media.forEach(m => {
+ if (!!m.mid && m.type === "video" || m.type === "audio") {
+ const ssrcs = [];
+ m.ssrcs?.forEach(ssrc => {
+ if (ssrc.attribute === "cname") {
+ ssrcs.push(`${ssrc.id}`);
+ }
+ });
+ ssrcToMid.set(`${m.mid}`, ssrcs);
+ }
+ });
+ this.ssrcToMid[type] = ssrcToMid;
+ }
+ getSsrcToMidMap(type) {
+ return this.ssrcToMid[type];
+ }
+}
+exports.MediaSsrcHandler = MediaSsrcHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js
new file mode 100644
index 0000000000..c4252a9cbd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js
@@ -0,0 +1,69 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaTrackHandler = void 0;
+/*
+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.
+*/
+
+class MediaTrackHandler {
+ constructor(pc) {
+ this.pc = pc;
+ }
+ getLocalTracks(kind) {
+ const isNotNullAndKind = track => {
+ 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);
+ }
+ getTackById(trackId) {
+ 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);
+ }
+ getLocalTrackIdByMid(mid) {
+ 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;
+ }
+ getRemoteTrackIdByMid(mid) {
+ 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;
+ }
+ getActiveSimulcastStreams() {
+ //@TODO implement this right.. Check how many layer configured
+ return 3;
+ }
+ getTransceiverByTrackId(trackId) {
+ return this.pc.getTransceivers().find(t => {
+ return t.receiver.track.id === trackId || t.sender.track !== null && t.sender.track.id === trackId;
+ });
+ }
+}
+exports.MediaTrackHandler = MediaTrackHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js
new file mode 100644
index 0000000000..d5a7963c23
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js
@@ -0,0 +1,150 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaTrackStats = void 0;
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+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.
+*/
+
+class MediaTrackStats {
+ constructor(trackId, type, kind) {
+ this.trackId = trackId;
+ this.type = type;
+ this.kind = kind;
+ _defineProperty(this, "loss", {
+ packetsTotal: 0,
+ packetsLost: 0,
+ isDownloadStream: false
+ });
+ _defineProperty(this, "bitrate", {
+ download: 0,
+ upload: 0
+ });
+ _defineProperty(this, "resolution", {
+ width: -1,
+ height: -1
+ });
+ _defineProperty(this, "audioConcealment", {
+ concealedAudio: 0,
+ totalAudioDuration: 0
+ });
+ _defineProperty(this, "framerate", 0);
+ _defineProperty(this, "jitter", 0);
+ _defineProperty(this, "codec", "");
+ _defineProperty(this, "isAlive", true);
+ _defineProperty(this, "isMuted", false);
+ _defineProperty(this, "isEnabled", true);
+ }
+ getType() {
+ return this.type;
+ }
+ setLoss(loss) {
+ this.loss = loss;
+ }
+ getLoss() {
+ return this.loss;
+ }
+ setResolution(resolution) {
+ this.resolution = resolution;
+ }
+ getResolution() {
+ return this.resolution;
+ }
+ setFramerate(framerate) {
+ this.framerate = framerate;
+ }
+ getFramerate() {
+ return this.framerate;
+ }
+ setBitrate(bitrate) {
+ this.bitrate = bitrate;
+ }
+ getBitrate() {
+ return this.bitrate;
+ }
+ setCodec(codecShortType) {
+ this.codec = codecShortType;
+ return true;
+ }
+ getCodec() {
+ return this.codec;
+ }
+ resetBitrate() {
+ this.bitrate = {
+ download: 0,
+ upload: 0
+ };
+ }
+ set alive(isAlive) {
+ this.isAlive = isAlive;
+ }
+
+ /**
+ * A MediaTrackState is alive if the corresponding MediaStreamTrack track bound to a transceiver and the
+ * MediaStreamTrack is in state MediaStreamTrack.readyState === live
+ */
+ get alive() {
+ return this.isAlive;
+ }
+ set muted(isMuted) {
+ this.isMuted = isMuted;
+ }
+
+ /**
+ * A MediaTrackState.isMuted corresponding to MediaStreamTrack.muted.
+ * But these values only match if MediaTrackState.isAlive.
+ */
+ get muted() {
+ return this.isMuted;
+ }
+ set enabled(isEnabled) {
+ this.isEnabled = isEnabled;
+ }
+
+ /**
+ * A MediaTrackState.isEnabled corresponding to MediaStreamTrack.enabled.
+ * But these values only match if MediaTrackState.isAlive.
+ */
+ get enabled() {
+ return this.isEnabled;
+ }
+ setJitter(jitter) {
+ this.jitter = jitter;
+ }
+
+ /**
+ * Jitter in milliseconds
+ */
+ getJitter() {
+ return this.jitter;
+ }
+
+ /**
+ * Audio concealment ration (conceled duration / total duration)
+ */
+ setAudioConcealment(concealedAudioDuration, totalAudioDuration) {
+ this.audioConcealment.concealedAudio = concealedAudioDuration;
+ this.audioConcealment.totalAudioDuration = totalAudioDuration;
+ }
+ getAudioConcealment() {
+ return this.audioConcealment;
+ }
+}
+exports.MediaTrackStats = MediaTrackStats; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js
new file mode 100644
index 0000000000..f72f644cb3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js
@@ -0,0 +1,82 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MediaTrackStatsHandler = void 0;
+var _mediaTrackStats = require("./mediaTrackStats");
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ 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.
+ */
+class MediaTrackStatsHandler {
+ constructor(mediaSsrcHandler, mediaTrackHandler) {
+ this.mediaSsrcHandler = mediaSsrcHandler;
+ this.mediaTrackHandler = mediaTrackHandler;
+ _defineProperty(this, "track2stats", new Map());
+ }
+
+ /**
+ * 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
+ */
+ findTrack2Stats(report, type) {
+ 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 = track.kind === "audio" ? track.kind : "video";
+ trackStats = new _mediaTrackStats.MediaTrackStats(trackID, type, kind);
+ this.track2stats.set(trackID, trackStats);
+ } else {
+ return undefined;
+ }
+ }
+ return trackStats;
+ }
+ findLocalVideoTrackStats(report) {
+ const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video");
+ if (localVideoTracks.length === 0) {
+ return undefined;
+ }
+ return this.findTrack2Stats(report, "local");
+ }
+ getTrack2stats() {
+ return this.track2stats;
+ }
+ findTransceiverByTrackId(trackID) {
+ return this.mediaTrackHandler.getTransceiverByTrackId(trackID);
+ }
+}
+exports.MediaTrackStatsHandler = MediaTrackStatsHandler; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js
new file mode 100644
index 0000000000..d020a9e7f9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js
@@ -0,0 +1,28 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.StatsReport = void 0;
+/*
+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.
+*/
+let StatsReport = /*#__PURE__*/function (StatsReport) {
+ StatsReport["CONNECTION_STATS"] = "StatsReport.connection_stats";
+ StatsReport["BYTE_SENT_STATS"] = "StatsReport.byte_sent_stats";
+ StatsReport["SUMMARY_STATS"] = "StatsReport.summary_stats";
+ return StatsReport;
+}({});
+exports.StatsReport = StatsReport; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js
new file mode 100644
index 0000000000..c25da81743
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js
@@ -0,0 +1,36 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.StatsReportEmitter = void 0;
+var _typedEventEmitter = require("../../models/typed-event-emitter");
+var _statsReport = require("./statsReport");
+/*
+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.
+*/
+
+class StatsReportEmitter extends _typedEventEmitter.TypedEventEmitter {
+ emitByteSendReport(byteSentStats) {
+ this.emit(_statsReport.StatsReport.BYTE_SENT_STATS, byteSentStats);
+ }
+ emitConnectionStatsReport(report) {
+ this.emit(_statsReport.StatsReport.CONNECTION_STATS, report);
+ }
+ emitSummaryStatsReport(report) {
+ this.emit(_statsReport.StatsReport.SUMMARY_STATS, report);
+ }
+}
+exports.StatsReportEmitter = StatsReportEmitter; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js
new file mode 100644
index 0000000000..fb78690e64
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js
@@ -0,0 +1,103 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SummaryStatsReportGatherer = void 0;
+/*
+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.
+*/
+
+class SummaryStatsReportGatherer {
+ constructor(emitter) {
+ this.emitter = emitter;
+ }
+ build(allSummary) {
+ // Filter all stats which collect the first time webrtc stats.
+ // Because stats based on time interval and the first collection of a summery stats has no previous
+ // webrtcStats as basement all the calculation are 0. We don't want track the 0 stats.
+ const summary = allSummary.filter(s => !s.isFirstCollection);
+ const summaryTotalCount = summary.length;
+ if (summaryTotalCount === 0) {
+ return;
+ }
+ const summaryCounter = {
+ receivedAudio: 0,
+ receivedVideo: 0,
+ receivedMedia: 0,
+ concealedAudio: 0,
+ totalAudio: 0
+ };
+ let maxJitter = 0;
+ let maxPacketLoss = 0;
+ summary.forEach(stats => {
+ this.countTrackListReceivedMedia(summaryCounter, stats);
+ this.countConcealedAudio(summaryCounter, stats);
+ maxJitter = this.buildMaxJitter(maxJitter, stats);
+ maxPacketLoss = this.buildMaxPacketLoss(maxPacketLoss, stats);
+ });
+ const decimalPlaces = 5;
+ const report = {
+ percentageReceivedMedia: Number((summaryCounter.receivedMedia / summaryTotalCount).toFixed(decimalPlaces)),
+ percentageReceivedVideoMedia: Number((summaryCounter.receivedVideo / summaryTotalCount).toFixed(decimalPlaces)),
+ percentageReceivedAudioMedia: Number((summaryCounter.receivedAudio / summaryTotalCount).toFixed(decimalPlaces)),
+ maxJitter,
+ maxPacketLoss,
+ percentageConcealedAudio: Number(summaryCounter.totalAudio > 0 ? (summaryCounter.concealedAudio / summaryCounter.totalAudio).toFixed(decimalPlaces) : 0),
+ peerConnections: summaryTotalCount
+ };
+ this.emitter.emitSummaryStatsReport(report);
+ }
+ countTrackListReceivedMedia(counter, stats) {
+ let hasReceivedAudio = false;
+ let hasReceivedVideo = false;
+ if (stats.receivedAudioMedia > 0 || stats.audioTrackSummary.count === 0) {
+ counter.receivedAudio++;
+ hasReceivedAudio = true;
+ }
+ if (stats.receivedVideoMedia > 0 || stats.videoTrackSummary.count === 0) {
+ counter.receivedVideo++;
+ hasReceivedVideo = true;
+ } else {
+ if (stats.videoTrackSummary.muted > 0 && stats.videoTrackSummary.muted === stats.videoTrackSummary.count) {
+ counter.receivedVideo++;
+ hasReceivedVideo = true;
+ }
+ }
+ if (hasReceivedVideo && hasReceivedAudio) {
+ counter.receivedMedia++;
+ }
+ }
+ buildMaxJitter(maxJitter, stats) {
+ if (maxJitter < stats.videoTrackSummary.maxJitter) {
+ maxJitter = stats.videoTrackSummary.maxJitter;
+ }
+ if (maxJitter < stats.audioTrackSummary.maxJitter) {
+ maxJitter = stats.audioTrackSummary.maxJitter;
+ }
+ return maxJitter;
+ }
+ buildMaxPacketLoss(maxPacketLoss, stats) {
+ if (maxPacketLoss < stats.videoTrackSummary.maxPacketLoss) {
+ maxPacketLoss = stats.videoTrackSummary.maxPacketLoss;
+ }
+ if (maxPacketLoss < stats.audioTrackSummary.maxPacketLoss) {
+ maxPacketLoss = stats.audioTrackSummary.maxPacketLoss;
+ }
+ return maxPacketLoss;
+ }
+ countConcealedAudio(summaryCounter, stats) {
+ summaryCounter.concealedAudio += stats.audioTrackSummary.concealedAudio;
+ summaryCounter.totalAudio += stats.audioTrackSummary.totalAudio;
+ }
+}
+exports.SummaryStatsReportGatherer = SummaryStatsReportGatherer; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js
new file mode 100644
index 0000000000..563a14b784
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js
@@ -0,0 +1,172 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TrackStatsBuilder = void 0;
+var _valueFormatter = require("./valueFormatter");
+class TrackStatsBuilder {
+ static buildFramerateResolution(trackStats, now) {
+ 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));
+ }
+ static calculateSimulcastFramerate(trackStats, now, before, layer) {
+ 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);
+ }
+ static buildCodec(report, trackStats, now) {
+ 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);
+ }
+ }
+ static buildBitrateReceived(trackStats, now, before) {
+ trackStats.setBitrate({
+ download: TrackStatsBuilder.calculateBitrate(now.bytesReceived, before.bytesReceived, now.timestamp, before.timestamp),
+ upload: 0
+ });
+ }
+ static buildBitrateSend(trackStats, now, before) {
+ trackStats.setBitrate({
+ download: 0,
+ upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp)
+ });
+ }
+ static buildPacketsLost(trackStats, now, before) {
+ const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived";
+ let packetsNow = now[key];
+ if (!packetsNow || packetsNow < 0) {
+ packetsNow = 0;
+ }
+ const packetsBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(before[key]);
+ const packetsDiff = Math.max(0, packetsNow - packetsBefore);
+ const packetsLostNow = _valueFormatter.ValueFormatter.getNonNegativeValue(now.packetsLost);
+ const packetsLostBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(before.packetsLost);
+ const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore);
+ trackStats.setLoss({
+ packetsTotal: packetsDiff + packetsLostDiff,
+ packetsLost: packetsLostDiff,
+ isDownloadStream: now.type !== "outbound-rtp"
+ });
+ }
+ static calculateBitrate(bytesNowAny, bytesBeforeAny, nowTimestamp, beforeTimestamp) {
+ const bytesNow = _valueFormatter.ValueFormatter.getNonNegativeValue(bytesNowAny);
+ const bytesBefore = _valueFormatter.ValueFormatter.getNonNegativeValue(bytesBeforeAny);
+ const bytesProcessed = Math.max(0, bytesNow - bytesBefore);
+ const timeMs = nowTimestamp - beforeTimestamp;
+ let bitrateKbps = 0;
+ if (timeMs > 0) {
+ bitrateKbps = Math.round(bytesProcessed * 8 / timeMs);
+ }
+ return bitrateKbps;
+ }
+ static setTrackStatsState(trackStats, transceiver) {
+ if (transceiver === undefined) {
+ trackStats.alive = false;
+ return;
+ }
+ const track = trackStats.getType() === "remote" ? transceiver.receiver.track : transceiver?.sender?.track;
+ if (track === undefined || track === null) {
+ trackStats.alive = false;
+ return;
+ }
+ if (track.readyState === "ended") {
+ trackStats.alive = false;
+ return;
+ }
+ trackStats.muted = track.muted;
+ trackStats.enabled = track.enabled;
+ trackStats.alive = true;
+ }
+ static buildTrackSummary(trackStatsList) {
+ const videoTrackSummary = {
+ count: 0,
+ muted: 0,
+ maxJitter: 0,
+ maxPacketLoss: 0,
+ concealedAudio: 0,
+ totalAudio: 0
+ };
+ const audioTrackSummary = {
+ count: 0,
+ muted: 0,
+ maxJitter: 0,
+ maxPacketLoss: 0,
+ concealedAudio: 0,
+ totalAudio: 0
+ };
+ const remoteTrackList = trackStatsList.filter(t => t.getType() === "remote");
+ const audioTrackList = remoteTrackList.filter(t => t.kind === "audio");
+ remoteTrackList.forEach(stats => {
+ const trackSummary = stats.kind === "video" ? videoTrackSummary : audioTrackSummary;
+ trackSummary.count++;
+ if (stats.alive && stats.muted) {
+ trackSummary.muted++;
+ }
+ if (trackSummary.maxJitter < stats.getJitter()) {
+ trackSummary.maxJitter = stats.getJitter();
+ }
+ if (trackSummary.maxPacketLoss < stats.getLoss().packetsLost) {
+ trackSummary.maxPacketLoss = stats.getLoss().packetsLost;
+ }
+ if (audioTrackList.length > 0) {
+ trackSummary.concealedAudio += stats.getAudioConcealment()?.concealedAudio;
+ trackSummary.totalAudio += stats.getAudioConcealment()?.totalAudioDuration;
+ }
+ });
+ return {
+ audioTrackSummary,
+ videoTrackSummary
+ };
+ }
+ static buildJitter(trackStats, statsReport) {
+ if (statsReport.type !== "inbound-rtp") {
+ return;
+ }
+ const jitterStr = statsReport?.jitter;
+ if (jitterStr !== undefined) {
+ const jitter = _valueFormatter.ValueFormatter.getNonNegativeValue(jitterStr);
+ trackStats.setJitter(Math.round(jitter * 1000));
+ } else {
+ trackStats.setJitter(-1);
+ }
+ }
+ static buildAudioConcealment(trackStats, statsReport) {
+ if (statsReport.type !== "inbound-rtp") {
+ return;
+ }
+ const msPerSample = 1000 * statsReport?.totalSamplesDuration / statsReport?.totalSamplesReceived;
+ const concealedAudioDuration = msPerSample * statsReport?.concealedSamples;
+ const totalAudioDuration = 1000 * statsReport?.totalSamplesDuration;
+ trackStats.setAudioConcealment(concealedAudioDuration, totalAudioDuration);
+ }
+}
+exports.TrackStatsBuilder = TrackStatsBuilder; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js
new file mode 100644
index 0000000000..430afc16cd
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js
@@ -0,0 +1,5 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+}); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js
new file mode 100644
index 0000000000..d65aa28dba
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js
@@ -0,0 +1,40 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.TransportStatsBuilder = void 0;
+class TransportStatsBuilder {
+ static buildReport(report, now, conferenceStatsTransport, isFocus) {
+ 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 => 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
+ });
+ }
+ }
+ return conferenceStatsTransport;
+ }
+}
+exports.TransportStatsBuilder = TransportStatsBuilder; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js
new file mode 100644
index 0000000000..17050d260e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ValueFormatter = void 0;
+/*
+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.
+*/
+class ValueFormatter {
+ static getNonNegativeValue(imput) {
+ let value = imput;
+ if (typeof value !== "number") {
+ value = Number(value);
+ }
+ if (isNaN(value)) {
+ return 0;
+ }
+ return Math.max(0, value);
+ }
+}
+exports.ValueFormatter = ValueFormatter; \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/ClientWidgetApi.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/ClientWidgetApi.js
new file mode 100644
index 0000000000..4bd84b410f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/ClientWidgetApi.js
@@ -0,0 +1,1126 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ClientWidgetApi = void 0;
+var _events = require("events");
+var _PostmessageTransport = require("./transport/PostmessageTransport");
+var _WidgetApiDirection = require("./interfaces/WidgetApiDirection");
+var _WidgetApiAction = require("./interfaces/WidgetApiAction");
+var _Capabilities = require("./interfaces/Capabilities");
+var _ApiVersion = require("./interfaces/ApiVersion");
+var _WidgetEventCapability = require("./models/WidgetEventCapability");
+var _GetOpenIDAction = require("./interfaces/GetOpenIDAction");
+var _SimpleObservable = require("./util/SimpleObservable");
+var _Symbols = require("./Symbols");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _regeneratorRuntime() { "use strict"; /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ _regeneratorRuntime = function _regeneratorRuntime() { return exports; }; var exports = {}, Op = Object.prototype, hasOwn = Op.hasOwnProperty, defineProperty = Object.defineProperty || function (obj, key, desc) { obj[key] = desc.value; }, $Symbol = "function" == typeof Symbol ? Symbol : {}, iteratorSymbol = $Symbol.iterator || "@@iterator", asyncIteratorSymbol = $Symbol.asyncIterator || "@@asyncIterator", toStringTagSymbol = $Symbol.toStringTag || "@@toStringTag"; function define(obj, key, value) { return Object.defineProperty(obj, key, { value: value, enumerable: !0, configurable: !0, writable: !0 }), obj[key]; } try { define({}, ""); } catch (err) { define = function define(obj, key, value) { return obj[key] = value; }; } function wrap(innerFn, outerFn, self, tryLocsList) { var protoGenerator = outerFn && outerFn.prototype instanceof Generator ? outerFn : Generator, generator = Object.create(protoGenerator.prototype), context = new Context(tryLocsList || []); return defineProperty(generator, "_invoke", { value: makeInvokeMethod(innerFn, self, context) }), generator; } function tryCatch(fn, obj, arg) { try { return { type: "normal", arg: fn.call(obj, arg) }; } catch (err) { return { type: "throw", arg: err }; } } exports.wrap = wrap; var ContinueSentinel = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} var IteratorPrototype = {}; define(IteratorPrototype, iteratorSymbol, function () { return this; }); var getProto = Object.getPrototypeOf, NativeIteratorPrototype = getProto && getProto(getProto(values([]))); NativeIteratorPrototype && NativeIteratorPrototype !== Op && hasOwn.call(NativeIteratorPrototype, iteratorSymbol) && (IteratorPrototype = NativeIteratorPrototype); var Gp = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(IteratorPrototype); function defineIteratorMethods(prototype) { ["next", "throw", "return"].forEach(function (method) { define(prototype, method, function (arg) { return this._invoke(method, arg); }); }); } function AsyncIterator(generator, PromiseImpl) { function invoke(method, arg, resolve, reject) { var record = tryCatch(generator[method], generator, arg); if ("throw" !== record.type) { var result = record.arg, value = result.value; return value && "object" == _typeof(value) && hasOwn.call(value, "__await") ? PromiseImpl.resolve(value.__await).then(function (value) { invoke("next", value, resolve, reject); }, function (err) { invoke("throw", err, resolve, reject); }) : PromiseImpl.resolve(value).then(function (unwrapped) { result.value = unwrapped, resolve(result); }, function (error) { return invoke("throw", error, resolve, reject); }); } reject(record.arg); } var previousPromise; defineProperty(this, "_invoke", { value: function value(method, arg) { function callInvokeWithMethodAndArg() { return new PromiseImpl(function (resolve, reject) { invoke(method, arg, resolve, reject); }); } return previousPromise = previousPromise ? previousPromise.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); } }); } function makeInvokeMethod(innerFn, self, context) { var state = "suspendedStart"; return function (method, arg) { if ("executing" === state) throw new Error("Generator is already running"); if ("completed" === state) { if ("throw" === method) throw arg; return doneResult(); } for (context.method = method, context.arg = arg;;) { var delegate = context.delegate; if (delegate) { var delegateResult = maybeInvokeDelegate(delegate, context); if (delegateResult) { if (delegateResult === ContinueSentinel) continue; return delegateResult; } } if ("next" === context.method) context.sent = context._sent = context.arg;else if ("throw" === context.method) { if ("suspendedStart" === state) throw state = "completed", context.arg; context.dispatchException(context.arg); } else "return" === context.method && context.abrupt("return", context.arg); state = "executing"; var record = tryCatch(innerFn, self, context); if ("normal" === record.type) { if (state = context.done ? "completed" : "suspendedYield", record.arg === ContinueSentinel) continue; return { value: record.arg, done: context.done }; } "throw" === record.type && (state = "completed", context.method = "throw", context.arg = record.arg); } }; } function maybeInvokeDelegate(delegate, context) { var methodName = context.method, method = delegate.iterator[methodName]; if (undefined === method) return context.delegate = null, "throw" === methodName && delegate.iterator["return"] && (context.method = "return", context.arg = undefined, maybeInvokeDelegate(delegate, context), "throw" === context.method) || "return" !== methodName && (context.method = "throw", context.arg = new TypeError("The iterator does not provide a '" + methodName + "' method")), ContinueSentinel; var record = tryCatch(method, delegate.iterator, context.arg); if ("throw" === record.type) return context.method = "throw", context.arg = record.arg, context.delegate = null, ContinueSentinel; var info = record.arg; return info ? info.done ? (context[delegate.resultName] = info.value, context.next = delegate.nextLoc, "return" !== context.method && (context.method = "next", context.arg = undefined), context.delegate = null, ContinueSentinel) : info : (context.method = "throw", context.arg = new TypeError("iterator result is not an object"), context.delegate = null, ContinueSentinel); } function pushTryEntry(locs) { var entry = { tryLoc: locs[0] }; 1 in locs && (entry.catchLoc = locs[1]), 2 in locs && (entry.finallyLoc = locs[2], entry.afterLoc = locs[3]), this.tryEntries.push(entry); } function resetTryEntry(entry) { var record = entry.completion || {}; record.type = "normal", delete record.arg, entry.completion = record; } function Context(tryLocsList) { this.tryEntries = [{ tryLoc: "root" }], tryLocsList.forEach(pushTryEntry, this), this.reset(!0); } function values(iterable) { if (iterable) { var iteratorMethod = iterable[iteratorSymbol]; if (iteratorMethod) return iteratorMethod.call(iterable); if ("function" == typeof iterable.next) return iterable; if (!isNaN(iterable.length)) { var i = -1, next = function next() { for (; ++i < iterable.length;) if (hasOwn.call(iterable, i)) return next.value = iterable[i], next.done = !1, next; return next.value = undefined, next.done = !0, next; }; return next.next = next; } } return { next: doneResult }; } function doneResult() { return { value: undefined, done: !0 }; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, defineProperty(Gp, "constructor", { value: GeneratorFunctionPrototype, configurable: !0 }), defineProperty(GeneratorFunctionPrototype, "constructor", { value: GeneratorFunction, configurable: !0 }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, toStringTagSymbol, "GeneratorFunction"), exports.isGeneratorFunction = function (genFun) { var ctor = "function" == typeof genFun && genFun.constructor; return !!ctor && (ctor === GeneratorFunction || "GeneratorFunction" === (ctor.displayName || ctor.name)); }, exports.mark = function (genFun) { return Object.setPrototypeOf ? Object.setPrototypeOf(genFun, GeneratorFunctionPrototype) : (genFun.__proto__ = GeneratorFunctionPrototype, define(genFun, toStringTagSymbol, "GeneratorFunction")), genFun.prototype = Object.create(Gp), genFun; }, exports.awrap = function (arg) { return { __await: arg }; }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, asyncIteratorSymbol, function () { return this; }), exports.AsyncIterator = AsyncIterator, exports.async = function (innerFn, outerFn, self, tryLocsList, PromiseImpl) { void 0 === PromiseImpl && (PromiseImpl = Promise); var iter = new AsyncIterator(wrap(innerFn, outerFn, self, tryLocsList), PromiseImpl); return exports.isGeneratorFunction(outerFn) ? iter : iter.next().then(function (result) { return result.done ? result.value : iter.next(); }); }, defineIteratorMethods(Gp), define(Gp, toStringTagSymbol, "Generator"), define(Gp, iteratorSymbol, function () { return this; }), define(Gp, "toString", function () { return "[object Generator]"; }), exports.keys = function (val) { var object = Object(val), keys = []; for (var key in object) keys.push(key); return keys.reverse(), function next() { for (; keys.length;) { var key = keys.pop(); if (key in object) return next.value = key, next.done = !1, next; } return next.done = !0, next; }; }, exports.values = values, Context.prototype = { constructor: Context, reset: function reset(skipTempReset) { if (this.prev = 0, this.next = 0, this.sent = this._sent = undefined, this.done = !1, this.delegate = null, this.method = "next", this.arg = undefined, this.tryEntries.forEach(resetTryEntry), !skipTempReset) for (var name in this) "t" === name.charAt(0) && hasOwn.call(this, name) && !isNaN(+name.slice(1)) && (this[name] = undefined); }, stop: function stop() { this.done = !0; var rootRecord = this.tryEntries[0].completion; if ("throw" === rootRecord.type) throw rootRecord.arg; return this.rval; }, dispatchException: function dispatchException(exception) { if (this.done) throw exception; var context = this; function handle(loc, caught) { return record.type = "throw", record.arg = exception, context.next = loc, caught && (context.method = "next", context.arg = undefined), !!caught; } for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i], record = entry.completion; if ("root" === entry.tryLoc) return handle("end"); if (entry.tryLoc <= this.prev) { var hasCatch = hasOwn.call(entry, "catchLoc"), hasFinally = hasOwn.call(entry, "finallyLoc"); if (hasCatch && hasFinally) { if (this.prev < entry.catchLoc) return handle(entry.catchLoc, !0); if (this.prev < entry.finallyLoc) return handle(entry.finallyLoc); } else if (hasCatch) { if (this.prev < entry.catchLoc) return handle(entry.catchLoc, !0); } else { if (!hasFinally) throw new Error("try statement without catch or finally"); if (this.prev < entry.finallyLoc) return handle(entry.finallyLoc); } } } }, abrupt: function abrupt(type, arg) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc <= this.prev && hasOwn.call(entry, "finallyLoc") && this.prev < entry.finallyLoc) { var finallyEntry = entry; break; } } finallyEntry && ("break" === type || "continue" === type) && finallyEntry.tryLoc <= arg && arg <= finallyEntry.finallyLoc && (finallyEntry = null); var record = finallyEntry ? finallyEntry.completion : {}; return record.type = type, record.arg = arg, finallyEntry ? (this.method = "next", this.next = finallyEntry.finallyLoc, ContinueSentinel) : this.complete(record); }, complete: function complete(record, afterLoc) { if ("throw" === record.type) throw record.arg; return "break" === record.type || "continue" === record.type ? this.next = record.arg : "return" === record.type ? (this.rval = this.arg = record.arg, this.method = "return", this.next = "end") : "normal" === record.type && afterLoc && (this.next = afterLoc), ContinueSentinel; }, finish: function finish(finallyLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.finallyLoc === finallyLoc) return this.complete(entry.completion, entry.afterLoc), resetTryEntry(entry), ContinueSentinel; } }, "catch": function _catch(tryLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc === tryLoc) { var record = entry.completion; if ("throw" === record.type) { var thrown = record.arg; resetTryEntry(entry); } return thrown; } } throw new Error("illegal catch attempt"); }, delegateYield: function delegateYield(iterable, resultName, nextLoc) { return this.delegate = { iterator: values(iterable), resultName: resultName, nextLoc: nextLoc }, "next" === this.method && (this.arg = undefined), ContinueSentinel; } }, exports; }
+function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
+function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+function _asyncIterator(iterable) { var method, async, sync, retry = 2; for ("undefined" != typeof Symbol && (async = Symbol.asyncIterator, sync = Symbol.iterator); retry--;) { if (async && null != (method = iterable[async])) return method.call(iterable); if (sync && null != (method = iterable[sync])) return new AsyncFromSyncIterator(method.call(iterable)); async = "@@asyncIterator", sync = "@@iterator"; } throw new TypeError("Object is not async iterable"); }
+function AsyncFromSyncIterator(s) { function AsyncFromSyncIteratorContinuation(r) { if (Object(r) !== r) return Promise.reject(new TypeError(r + " is not an object.")); var done = r.done; return Promise.resolve(r.value).then(function (value) { return { value: value, done: done }; }); } return AsyncFromSyncIterator = function AsyncFromSyncIterator(s) { this.s = s, this.n = s.next; }, AsyncFromSyncIterator.prototype = { s: null, n: null, next: function next() { return AsyncFromSyncIteratorContinuation(this.n.apply(this.s, arguments)); }, "return": function _return(value) { var ret = this.s["return"]; return void 0 === ret ? Promise.resolve({ value: value, done: !0 }) : AsyncFromSyncIteratorContinuation(ret.apply(this.s, arguments)); }, "throw": function _throw(value) { var thr = this.s["return"]; return void 0 === thr ? Promise.reject(value) : AsyncFromSyncIteratorContinuation(thr.apply(this.s, arguments)); } }, new AsyncFromSyncIterator(s); } /*
+ * 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.
+ */
+/**
+ * API handler for the client side of widgets. This raises events
+ * for each action received as `action:${action}` (eg: "action:screenshot").
+ * Default handling can be prevented by using preventDefault() on the
+ * raised event. The default handling varies for each action: ones
+ * which the SDK can handle safely are acknowledged appropriately and
+ * ones which are unhandled (custom or require the client to do something)
+ * are rejected with an error.
+ *
+ * Events which are preventDefault()ed must reply using the transport.
+ * The events raised will have a default of an IWidgetApiRequest
+ * interface.
+ *
+ * When the ClientWidgetApi is ready to start sending requests, it will
+ * raise a "ready" CustomEvent. After the ready event fires, actions can
+ * be sent and the transport will be ready.
+ *
+ * When the widget has indicated it has loaded, this class raises a
+ * "preparing" CustomEvent. The preparing event does not indicate that
+ * the widget is ready to receive communications - that is signified by
+ * the ready event exclusively.
+ *
+ * This class only handles one widget at a time.
+ */
+var ClientWidgetApi = /*#__PURE__*/function (_EventEmitter) {
+ _inherits(ClientWidgetApi, _EventEmitter);
+ var _super = _createSuper(ClientWidgetApi);
+ /**
+ * Creates a new client widget API. This will instantiate the transport
+ * and start everything. When the iframe is loaded under the widget's
+ * conditions, a "ready" event will be raised.
+ * @param {Widget} widget The widget to communicate with.
+ * @param {HTMLIFrameElement} iframe The iframe the widget is in.
+ * @param {WidgetDriver} driver The driver for this widget/client.
+ */
+ function ClientWidgetApi(widget, iframe, driver) {
+ var _this;
+ _classCallCheck(this, ClientWidgetApi);
+ _this = _super.call(this);
+ _this.widget = widget;
+ _this.iframe = iframe;
+ _this.driver = driver;
+ _defineProperty(_assertThisInitialized(_this), "transport", void 0);
+ // contentLoadedActionSent is used to check that only one ContentLoaded request is send.
+ _defineProperty(_assertThisInitialized(_this), "contentLoadedActionSent", false);
+ _defineProperty(_assertThisInitialized(_this), "allowedCapabilities", new Set());
+ _defineProperty(_assertThisInitialized(_this), "allowedEvents", []);
+ _defineProperty(_assertThisInitialized(_this), "isStopped", false);
+ _defineProperty(_assertThisInitialized(_this), "turnServers", null);
+ if (!(iframe !== null && iframe !== void 0 && iframe.contentWindow)) {
+ throw new Error("No iframe supplied");
+ }
+ if (!widget) {
+ throw new Error("Invalid widget");
+ }
+ if (!driver) {
+ throw new Error("Invalid driver");
+ }
+ _this.transport = new _PostmessageTransport.PostmessageTransport(_WidgetApiDirection.WidgetApiDirection.ToWidget, widget.id, iframe.contentWindow, window);
+ _this.transport.targetOrigin = widget.origin;
+ _this.transport.on("message", _this.handleMessage.bind(_assertThisInitialized(_this)));
+ iframe.addEventListener("load", _this.onIframeLoad.bind(_assertThisInitialized(_this)));
+ _this.transport.start();
+ return _this;
+ }
+ _createClass(ClientWidgetApi, [{
+ key: "hasCapability",
+ value: function hasCapability(capability) {
+ return this.allowedCapabilities.has(capability);
+ }
+ }, {
+ key: "canUseRoomTimeline",
+ value: function canUseRoomTimeline(roomId) {
+ return this.hasCapability("org.matrix.msc2762.timeline:".concat(_Symbols.Symbols.AnyRoom)) || this.hasCapability("org.matrix.msc2762.timeline:".concat(roomId));
+ }
+ }, {
+ key: "canSendRoomEvent",
+ value: function canSendRoomEvent(eventType) {
+ var msgtype = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsRoomEvent(_WidgetEventCapability.EventDirection.Send, eventType, msgtype);
+ });
+ }
+ }, {
+ key: "canSendStateEvent",
+ value: function canSendStateEvent(eventType, stateKey) {
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsStateEvent(_WidgetEventCapability.EventDirection.Send, eventType, stateKey);
+ });
+ }
+ }, {
+ key: "canSendToDeviceEvent",
+ value: function canSendToDeviceEvent(eventType) {
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsToDeviceEvent(_WidgetEventCapability.EventDirection.Send, eventType);
+ });
+ }
+ }, {
+ key: "canReceiveRoomEvent",
+ value: function canReceiveRoomEvent(eventType) {
+ var msgtype = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsRoomEvent(_WidgetEventCapability.EventDirection.Receive, eventType, msgtype);
+ });
+ }
+ }, {
+ key: "canReceiveStateEvent",
+ value: function canReceiveStateEvent(eventType, stateKey) {
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsStateEvent(_WidgetEventCapability.EventDirection.Receive, eventType, stateKey);
+ });
+ }
+ }, {
+ key: "canReceiveToDeviceEvent",
+ value: function canReceiveToDeviceEvent(eventType) {
+ return this.allowedEvents.some(function (e) {
+ return e.matchesAsToDeviceEvent(_WidgetEventCapability.EventDirection.Receive, eventType);
+ });
+ }
+ }, {
+ key: "stop",
+ value: function stop() {
+ this.isStopped = true;
+ this.transport.stop();
+ }
+ }, {
+ key: "beginCapabilities",
+ value: function beginCapabilities() {
+ var _this2 = this;
+ // widget has loaded - tell all the listeners that
+ this.emit("preparing");
+ var requestedCaps;
+ this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.Capabilities, {}).then(function (caps) {
+ requestedCaps = caps.capabilities;
+ return _this2.driver.validateCapabilities(new Set(caps.capabilities));
+ }).then(function (allowedCaps) {
+ console.log("Widget ".concat(_this2.widget.id, " is allowed capabilities:"), Array.from(allowedCaps));
+ _this2.allowedCapabilities = allowedCaps;
+ _this2.allowedEvents = _WidgetEventCapability.WidgetEventCapability.findEventCapabilities(allowedCaps);
+ _this2.notifyCapabilities(requestedCaps);
+ _this2.emit("ready");
+ });
+ }
+ }, {
+ key: "notifyCapabilities",
+ value: function notifyCapabilities(requested) {
+ var _this3 = this;
+ this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.NotifyCapabilities, {
+ requested: requested,
+ approved: Array.from(this.allowedCapabilities)
+ })["catch"](function (e) {
+ console.warn("non-fatal error notifying widget of approved capabilities:", e);
+ }).then(function () {
+ _this3.emit("capabilitiesNotified");
+ });
+ }
+ }, {
+ key: "onIframeLoad",
+ value: function onIframeLoad(ev) {
+ if (this.widget.waitForIframeLoad) {
+ // If the widget is set to waitForIframeLoad the capabilities immediatly get setup after load.
+ // The client does not wait for the ContentLoaded action.
+ this.beginCapabilities();
+ } else {
+ // Reaching this means, that the Iframe got reloaded/loaded and
+ // the clientApi is awaiting the FIRST ContentLoaded action.
+ this.contentLoadedActionSent = false;
+ }
+ }
+ }, {
+ key: "handleContentLoadedAction",
+ value: function handleContentLoadedAction(action) {
+ if (this.contentLoadedActionSent) {
+ throw new Error("Improper sequence: ContentLoaded Action can only be send once after the widget loaded " + "and should only be used if waitForIframeLoad is false (default=true)");
+ }
+ if (this.widget.waitForIframeLoad) {
+ this.transport.reply(action, {
+ error: {
+ message: "Improper sequence: not expecting ContentLoaded event if " + "waitForIframLoad is true (default=true)"
+ }
+ });
+ } else {
+ this.transport.reply(action, {});
+ this.beginCapabilities();
+ }
+ this.contentLoadedActionSent = true;
+ }
+ }, {
+ key: "replyVersions",
+ value: function replyVersions(request) {
+ this.transport.reply(request, {
+ supported_versions: _ApiVersion.CurrentApiVersions
+ });
+ }
+ }, {
+ key: "handleCapabilitiesRenegotiate",
+ value: function handleCapabilitiesRenegotiate(request) {
+ var _request$data,
+ _this4 = this;
+ // acknowledge first
+ this.transport.reply(request, {});
+ var requested = ((_request$data = request.data) === null || _request$data === void 0 ? void 0 : _request$data.capabilities) || [];
+ var newlyRequested = new Set(requested.filter(function (r) {
+ return !_this4.hasCapability(r);
+ }));
+ if (newlyRequested.size === 0) {
+ // Nothing to do - notify capabilities
+ return this.notifyCapabilities([]);
+ }
+ this.driver.validateCapabilities(newlyRequested).then(function (allowed) {
+ allowed.forEach(function (c) {
+ return _this4.allowedCapabilities.add(c);
+ });
+ var allowedEvents = _WidgetEventCapability.WidgetEventCapability.findEventCapabilities(allowed);
+ allowedEvents.forEach(function (c) {
+ return _this4.allowedEvents.push(c);
+ });
+ return _this4.notifyCapabilities(Array.from(newlyRequested));
+ });
+ }
+ }, {
+ key: "handleNavigate",
+ value: function handleNavigate(request) {
+ var _request$data2,
+ _request$data3,
+ _this5 = this;
+ if (!this.hasCapability(_Capabilities.MatrixCapabilities.MSC2931Navigate)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Missing capability"
+ }
+ });
+ }
+ if (!((_request$data2 = request.data) !== null && _request$data2 !== void 0 && _request$data2.uri) || !((_request$data3 = request.data) !== null && _request$data3 !== void 0 && _request$data3.uri.toString().startsWith("https://matrix.to/#"))) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid matrix.to URI"
+ }
+ });
+ }
+ var onErr = function onErr(e) {
+ console.error("[ClientWidgetApi] Failed to handle navigation: ", e);
+ return _this5.transport.reply(request, {
+ error: {
+ message: "Error handling navigation"
+ }
+ });
+ };
+ try {
+ this.driver.navigate(request.data.uri.toString())["catch"](function (e) {
+ return onErr(e);
+ }).then(function () {
+ return _this5.transport.reply(request, {});
+ });
+ } catch (e) {
+ return onErr(e);
+ }
+ }
+ }, {
+ key: "handleOIDC",
+ value: function handleOIDC(request) {
+ var _this6 = this;
+ var phase = 1; // 1 = initial request, 2 = after user manual confirmation
+
+ var replyState = function replyState(state, credential) {
+ credential = credential || {};
+ if (phase > 1) {
+ return _this6.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.OpenIDCredentials, _objectSpread({
+ state: state,
+ original_request_id: request.requestId
+ }, credential));
+ } else {
+ return _this6.transport.reply(request, _objectSpread({
+ state: state
+ }, credential));
+ }
+ };
+ var replyError = function replyError(msg) {
+ console.error("[ClientWidgetApi] Failed to handle OIDC: ", msg);
+ if (phase > 1) {
+ // We don't have a way to indicate that a random error happened in this flow, so
+ // just block the attempt.
+ return replyState(_GetOpenIDAction.OpenIDRequestState.Blocked);
+ } else {
+ return _this6.transport.reply(request, {
+ error: {
+ message: msg
+ }
+ });
+ }
+ };
+ var observer = new _SimpleObservable.SimpleObservable(function (update) {
+ if (update.state === _GetOpenIDAction.OpenIDRequestState.PendingUserConfirmation && phase > 1) {
+ observer.close();
+ return replyError("client provided out-of-phase response to OIDC flow");
+ }
+ if (update.state === _GetOpenIDAction.OpenIDRequestState.PendingUserConfirmation) {
+ replyState(update.state);
+ phase++;
+ return;
+ }
+ if (update.state === _GetOpenIDAction.OpenIDRequestState.Allowed && !update.token) {
+ return replyError("client provided invalid OIDC token for an allowed request");
+ }
+ if (update.state === _GetOpenIDAction.OpenIDRequestState.Blocked) {
+ update.token = undefined; // just in case the client did something weird
+ }
+
+ observer.close();
+ return replyState(update.state, update.token);
+ });
+ this.driver.askOpenID(observer);
+ }
+ }, {
+ key: "handleReadEvents",
+ value: function handleReadEvents(request) {
+ var _this7 = this;
+ if (!request.data.type) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing event type"
+ }
+ });
+ }
+ if (request.data.limit !== undefined && (!request.data.limit || request.data.limit < 0)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - limit out of range"
+ }
+ });
+ }
+ var askRoomIds = null; // null denotes current room only
+ if (request.data.room_ids) {
+ askRoomIds = request.data.room_ids;
+ if (!Array.isArray(askRoomIds)) {
+ askRoomIds = [askRoomIds];
+ }
+ var _iterator2 = _createForOfIteratorHelper(askRoomIds),
+ _step2;
+ try {
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
+ var roomId = _step2.value;
+ if (!this.canUseRoomTimeline(roomId)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Unable to access room timeline: ".concat(roomId)
+ }
+ });
+ }
+ }
+ } catch (err) {
+ _iterator2.e(err);
+ } finally {
+ _iterator2.f();
+ }
+ }
+ var limit = request.data.limit || 0;
+ var events = Promise.resolve([]);
+ if (request.data.state_key !== undefined) {
+ var stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString();
+ if (!this.canReceiveStateEvent(request.data.type, stateKey !== null && stateKey !== void 0 ? stateKey : null)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Cannot read state events of this type"
+ }
+ });
+ }
+ events = this.driver.readStateEvents(request.data.type, stateKey, limit, askRoomIds);
+ } else {
+ if (!this.canReceiveRoomEvent(request.data.type, request.data.msgtype)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Cannot read room events of this type"
+ }
+ });
+ }
+ events = this.driver.readRoomEvents(request.data.type, request.data.msgtype, limit, askRoomIds);
+ }
+ return events.then(function (evs) {
+ return _this7.transport.reply(request, {
+ events: evs
+ });
+ });
+ }
+ }, {
+ key: "handleSendEvent",
+ value: function handleSendEvent(request) {
+ var _this8 = this;
+ if (!request.data.type) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing event type"
+ }
+ });
+ }
+ if (!!request.data.room_id && !this.canUseRoomTimeline(request.data.room_id)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Unable to access room timeline: ".concat(request.data.room_id)
+ }
+ });
+ }
+ var isState = request.data.state_key !== null && request.data.state_key !== undefined;
+ var sendEventPromise;
+ if (isState) {
+ if (!this.canSendStateEvent(request.data.type, request.data.state_key)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Cannot send state events of this type"
+ }
+ });
+ }
+ sendEventPromise = this.driver.sendEvent(request.data.type, request.data.content || {}, request.data.state_key, request.data.room_id);
+ } else {
+ var content = request.data.content || {};
+ var msgtype = content['msgtype'];
+ if (!this.canSendRoomEvent(request.data.type, msgtype)) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Cannot send room events of this type"
+ }
+ });
+ }
+ sendEventPromise = this.driver.sendEvent(request.data.type, content, null,
+ // not sending a state event
+ request.data.room_id);
+ }
+ sendEventPromise.then(function (sentEvent) {
+ return _this8.transport.reply(request, {
+ room_id: sentEvent.roomId,
+ event_id: sentEvent.eventId
+ });
+ })["catch"](function (e) {
+ console.error("error sending event: ", e);
+ return _this8.transport.reply(request, {
+ error: {
+ message: "Error sending event"
+ }
+ });
+ });
+ }
+ }, {
+ key: "handleSendToDevice",
+ value: function () {
+ var _handleSendToDevice = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(request) {
+ return _regeneratorRuntime().wrap(function _callee$(_context) {
+ while (1) switch (_context.prev = _context.next) {
+ case 0:
+ if (request.data.type) {
+ _context.next = 5;
+ break;
+ }
+ _context.next = 3;
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing event type"
+ }
+ });
+ case 3:
+ _context.next = 32;
+ break;
+ case 5:
+ if (request.data.messages) {
+ _context.next = 10;
+ break;
+ }
+ _context.next = 8;
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing event contents"
+ }
+ });
+ case 8:
+ _context.next = 32;
+ break;
+ case 10:
+ if (!(typeof request.data.encrypted !== "boolean")) {
+ _context.next = 15;
+ break;
+ }
+ _context.next = 13;
+ return this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing encryption flag"
+ }
+ });
+ case 13:
+ _context.next = 32;
+ break;
+ case 15:
+ if (this.canSendToDeviceEvent(request.data.type)) {
+ _context.next = 20;
+ break;
+ }
+ _context.next = 18;
+ return this.transport.reply(request, {
+ error: {
+ message: "Cannot send to-device events of this type"
+ }
+ });
+ case 18:
+ _context.next = 32;
+ break;
+ case 20:
+ _context.prev = 20;
+ _context.next = 23;
+ return this.driver.sendToDevice(request.data.type, request.data.encrypted, request.data.messages);
+ case 23:
+ _context.next = 25;
+ return this.transport.reply(request, {});
+ case 25:
+ _context.next = 32;
+ break;
+ case 27:
+ _context.prev = 27;
+ _context.t0 = _context["catch"](20);
+ console.error("error sending to-device event", _context.t0);
+ _context.next = 32;
+ return this.transport.reply(request, {
+ error: {
+ message: "Error sending event"
+ }
+ });
+ case 32:
+ case "end":
+ return _context.stop();
+ }
+ }, _callee, this, [[20, 27]]);
+ }));
+ function handleSendToDevice(_x) {
+ return _handleSendToDevice.apply(this, arguments);
+ }
+ return handleSendToDevice;
+ }()
+ }, {
+ key: "pollTurnServers",
+ value: function () {
+ var _pollTurnServers = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(turnServers, initialServer) {
+ var _iteratorAbruptCompletion, _didIteratorError, _iteratorError, _iterator, _step, server;
+ return _regeneratorRuntime().wrap(function _callee2$(_context2) {
+ while (1) switch (_context2.prev = _context2.next) {
+ case 0:
+ _context2.prev = 0;
+ _context2.next = 3;
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.UpdateTurnServers, initialServer // it's compatible, but missing the index signature
+ );
+ case 3:
+ // Pick the generator up where we left off
+ _iteratorAbruptCompletion = false;
+ _didIteratorError = false;
+ _context2.prev = 5;
+ _iterator = _asyncIterator(turnServers);
+ case 7:
+ _context2.next = 9;
+ return _iterator.next();
+ case 9:
+ if (!(_iteratorAbruptCompletion = !(_step = _context2.sent).done)) {
+ _context2.next = 16;
+ break;
+ }
+ server = _step.value;
+ _context2.next = 13;
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.UpdateTurnServers, server // it's compatible, but missing the index signature
+ );
+ case 13:
+ _iteratorAbruptCompletion = false;
+ _context2.next = 7;
+ break;
+ case 16:
+ _context2.next = 22;
+ break;
+ case 18:
+ _context2.prev = 18;
+ _context2.t0 = _context2["catch"](5);
+ _didIteratorError = true;
+ _iteratorError = _context2.t0;
+ case 22:
+ _context2.prev = 22;
+ _context2.prev = 23;
+ if (!(_iteratorAbruptCompletion && _iterator["return"] != null)) {
+ _context2.next = 27;
+ break;
+ }
+ _context2.next = 27;
+ return _iterator["return"]();
+ case 27:
+ _context2.prev = 27;
+ if (!_didIteratorError) {
+ _context2.next = 30;
+ break;
+ }
+ throw _iteratorError;
+ case 30:
+ return _context2.finish(27);
+ case 31:
+ return _context2.finish(22);
+ case 32:
+ _context2.next = 37;
+ break;
+ case 34:
+ _context2.prev = 34;
+ _context2.t1 = _context2["catch"](0);
+ console.error("error polling for TURN servers", _context2.t1);
+ case 37:
+ case "end":
+ return _context2.stop();
+ }
+ }, _callee2, this, [[0, 34], [5, 18, 22, 32], [23,, 27, 31]]);
+ }));
+ function pollTurnServers(_x2, _x3) {
+ return _pollTurnServers.apply(this, arguments);
+ }
+ return pollTurnServers;
+ }()
+ }, {
+ key: "handleWatchTurnServers",
+ value: function () {
+ var _handleWatchTurnServers = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3(request) {
+ var turnServers, _yield$turnServers$ne, done, value;
+ return _regeneratorRuntime().wrap(function _callee3$(_context3) {
+ while (1) switch (_context3.prev = _context3.next) {
+ case 0:
+ if (this.hasCapability(_Capabilities.MatrixCapabilities.MSC3846TurnServers)) {
+ _context3.next = 5;
+ break;
+ }
+ _context3.next = 3;
+ return this.transport.reply(request, {
+ error: {
+ message: "Missing capability"
+ }
+ });
+ case 3:
+ _context3.next = 30;
+ break;
+ case 5:
+ if (!this.turnServers) {
+ _context3.next = 10;
+ break;
+ }
+ _context3.next = 8;
+ return this.transport.reply(request, {});
+ case 8:
+ _context3.next = 30;
+ break;
+ case 10:
+ _context3.prev = 10;
+ turnServers = this.driver.getTurnServers(); // Peek at the first result, so we can at least verify that the
+ // client isn't banned from getting TURN servers entirely
+ _context3.next = 14;
+ return turnServers.next();
+ case 14:
+ _yield$turnServers$ne = _context3.sent;
+ done = _yield$turnServers$ne.done;
+ value = _yield$turnServers$ne.value;
+ if (!done) {
+ _context3.next = 19;
+ break;
+ }
+ throw new Error("Client refuses to provide any TURN servers");
+ case 19:
+ _context3.next = 21;
+ return this.transport.reply(request, {});
+ case 21:
+ // Start the poll loop, sending the widget the initial result
+ this.pollTurnServers(turnServers, value);
+ this.turnServers = turnServers;
+ _context3.next = 30;
+ break;
+ case 25:
+ _context3.prev = 25;
+ _context3.t0 = _context3["catch"](10);
+ console.error("error getting first TURN server results", _context3.t0);
+ _context3.next = 30;
+ return this.transport.reply(request, {
+ error: {
+ message: "TURN servers not available"
+ }
+ });
+ case 30:
+ case "end":
+ return _context3.stop();
+ }
+ }, _callee3, this, [[10, 25]]);
+ }));
+ function handleWatchTurnServers(_x4) {
+ return _handleWatchTurnServers.apply(this, arguments);
+ }
+ return handleWatchTurnServers;
+ }()
+ }, {
+ key: "handleUnwatchTurnServers",
+ value: function () {
+ var _handleUnwatchTurnServers = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(request) {
+ return _regeneratorRuntime().wrap(function _callee4$(_context4) {
+ while (1) switch (_context4.prev = _context4.next) {
+ case 0:
+ if (this.hasCapability(_Capabilities.MatrixCapabilities.MSC3846TurnServers)) {
+ _context4.next = 5;
+ break;
+ }
+ _context4.next = 3;
+ return this.transport.reply(request, {
+ error: {
+ message: "Missing capability"
+ }
+ });
+ case 3:
+ _context4.next = 15;
+ break;
+ case 5:
+ if (this.turnServers) {
+ _context4.next = 10;
+ break;
+ }
+ _context4.next = 8;
+ return this.transport.reply(request, {});
+ case 8:
+ _context4.next = 15;
+ break;
+ case 10:
+ _context4.next = 12;
+ return this.turnServers["return"](undefined);
+ case 12:
+ this.turnServers = null;
+ _context4.next = 15;
+ return this.transport.reply(request, {});
+ case 15:
+ case "end":
+ return _context4.stop();
+ }
+ }, _callee4, this);
+ }));
+ function handleUnwatchTurnServers(_x5) {
+ return _handleUnwatchTurnServers.apply(this, arguments);
+ }
+ return handleUnwatchTurnServers;
+ }()
+ }, {
+ key: "handleReadRelations",
+ value: function () {
+ var _handleReadRelations = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee5(request) {
+ var _this9 = this;
+ var result, chunk;
+ return _regeneratorRuntime().wrap(function _callee5$(_context5) {
+ while (1) switch (_context5.prev = _context5.next) {
+ case 0:
+ if (request.data.event_id) {
+ _context5.next = 2;
+ break;
+ }
+ return _context5.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing event ID"
+ }
+ }));
+ case 2:
+ if (!(request.data.limit !== undefined && request.data.limit < 0)) {
+ _context5.next = 4;
+ break;
+ }
+ return _context5.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Invalid request - limit out of range"
+ }
+ }));
+ case 4:
+ if (!(request.data.room_id !== undefined && !this.canUseRoomTimeline(request.data.room_id))) {
+ _context5.next = 6;
+ break;
+ }
+ return _context5.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Unable to access room timeline: ".concat(request.data.room_id)
+ }
+ }));
+ case 6:
+ _context5.prev = 6;
+ _context5.next = 9;
+ return this.driver.readEventRelations(request.data.event_id, request.data.room_id, request.data.rel_type, request.data.event_type, request.data.from, request.data.to, request.data.limit, request.data.direction);
+ case 9:
+ result = _context5.sent;
+ // only return events that the user has the permission to receive
+ chunk = result.chunk.filter(function (e) {
+ if (e.state_key !== undefined) {
+ return _this9.canReceiveStateEvent(e.type, e.state_key);
+ } else {
+ return _this9.canReceiveRoomEvent(e.type, e.content['msgtype']);
+ }
+ });
+ return _context5.abrupt("return", this.transport.reply(request, {
+ chunk: chunk,
+ prev_batch: result.prevBatch,
+ next_batch: result.nextBatch
+ }));
+ case 14:
+ _context5.prev = 14;
+ _context5.t0 = _context5["catch"](6);
+ console.error("error getting the relations", _context5.t0);
+ _context5.next = 19;
+ return this.transport.reply(request, {
+ error: {
+ message: "Unexpected error while reading relations"
+ }
+ });
+ case 19:
+ case "end":
+ return _context5.stop();
+ }
+ }, _callee5, this, [[6, 14]]);
+ }));
+ function handleReadRelations(_x6) {
+ return _handleReadRelations.apply(this, arguments);
+ }
+ return handleReadRelations;
+ }()
+ }, {
+ key: "handleUserDirectorySearch",
+ value: function () {
+ var _handleUserDirectorySearch = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee6(request) {
+ var result;
+ return _regeneratorRuntime().wrap(function _callee6$(_context6) {
+ while (1) switch (_context6.prev = _context6.next) {
+ case 0:
+ if (this.hasCapability(_Capabilities.MatrixCapabilities.MSC3973UserDirectorySearch)) {
+ _context6.next = 2;
+ break;
+ }
+ return _context6.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Missing capability"
+ }
+ }));
+ case 2:
+ if (!(typeof request.data.search_term !== 'string')) {
+ _context6.next = 4;
+ break;
+ }
+ return _context6.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Invalid request - missing search term"
+ }
+ }));
+ case 4:
+ if (!(request.data.limit !== undefined && request.data.limit < 0)) {
+ _context6.next = 6;
+ break;
+ }
+ return _context6.abrupt("return", this.transport.reply(request, {
+ error: {
+ message: "Invalid request - limit out of range"
+ }
+ }));
+ case 6:
+ _context6.prev = 6;
+ _context6.next = 9;
+ return this.driver.searchUserDirectory(request.data.search_term, request.data.limit);
+ case 9:
+ result = _context6.sent;
+ return _context6.abrupt("return", this.transport.reply(request, {
+ limited: result.limited,
+ results: result.results.map(function (r) {
+ return {
+ user_id: r.userId,
+ display_name: r.displayName,
+ avatar_url: r.avatarUrl
+ };
+ })
+ }));
+ case 13:
+ _context6.prev = 13;
+ _context6.t0 = _context6["catch"](6);
+ console.error("error searching in the user directory", _context6.t0);
+ _context6.next = 18;
+ return this.transport.reply(request, {
+ error: {
+ message: "Unexpected error while searching in the user directory"
+ }
+ });
+ case 18:
+ case "end":
+ return _context6.stop();
+ }
+ }, _callee6, this, [[6, 13]]);
+ }));
+ function handleUserDirectorySearch(_x7) {
+ return _handleUserDirectorySearch.apply(this, arguments);
+ }
+ return handleUserDirectorySearch;
+ }()
+ }, {
+ key: "handleMessage",
+ value: function handleMessage(ev) {
+ if (this.isStopped) return;
+ var actionEv = new CustomEvent("action:".concat(ev.detail.action), {
+ detail: ev.detail,
+ cancelable: true
+ });
+ this.emit("action:".concat(ev.detail.action), actionEv);
+ if (!actionEv.defaultPrevented) {
+ switch (ev.detail.action) {
+ case _WidgetApiAction.WidgetApiFromWidgetAction.ContentLoaded:
+ return this.handleContentLoadedAction(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.SupportedApiVersions:
+ return this.replyVersions(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.SendEvent:
+ return this.handleSendEvent(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.SendToDevice:
+ return this.handleSendToDevice(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.GetOpenIDCredentials:
+ return this.handleOIDC(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.MSC2931Navigate:
+ return this.handleNavigate(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities:
+ return this.handleCapabilitiesRenegotiate(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.MSC2876ReadEvents:
+ return this.handleReadEvents(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.WatchTurnServers:
+ return this.handleWatchTurnServers(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.UnwatchTurnServers:
+ return this.handleUnwatchTurnServers(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.MSC3869ReadRelations:
+ return this.handleReadRelations(ev.detail);
+ case _WidgetApiAction.WidgetApiFromWidgetAction.MSC3973UserDirectorySearch:
+ return this.handleUserDirectorySearch(ev.detail);
+ default:
+ return this.transport.reply(ev.detail, {
+ error: {
+ message: "Unknown or unsupported action: " + ev.detail.action
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Takes a screenshot of the widget.
+ * @returns Resolves to the widget's screenshot.
+ * @throws Throws if there is a problem.
+ */
+ }, {
+ key: "takeScreenshot",
+ value: function takeScreenshot() {
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.TakeScreenshot, {});
+ }
+
+ /**
+ * Alerts the widget to whether or not it is currently visible.
+ * @param {boolean} isVisible Whether the widget is visible or not.
+ * @returns {Promise<IWidgetApiResponseData>} Resolves when the widget acknowledges the update.
+ */
+ }, {
+ key: "updateVisibility",
+ value: function updateVisibility(isVisible) {
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.UpdateVisibility, {
+ visible: isVisible
+ });
+ }
+ }, {
+ key: "sendWidgetConfig",
+ value: function sendWidgetConfig(data) {
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.WidgetConfig, data).then();
+ }
+ }, {
+ key: "notifyModalWidgetButtonClicked",
+ value: function notifyModalWidgetButtonClicked(id) {
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.ButtonClicked, {
+ id: id
+ }).then();
+ }
+ }, {
+ key: "notifyModalWidgetClose",
+ value: function notifyModalWidgetClose(data) {
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.CloseModalWidget, data).then();
+ }
+
+ /**
+ * Feeds an event to the widget. If the widget is not able to accept the event due to
+ * permissions, this will no-op and return calmly. If the widget failed to handle the
+ * event, this will raise an error.
+ * @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
+ * @param {string} currentViewedRoomId The room ID the user is currently interacting with.
+ * Not the room ID of the event.
+ * @returns {Promise<void>} Resolves when complete, rejects if there was an error sending.
+ */
+ }, {
+ key: "feedEvent",
+ value: function () {
+ var _feedEvent = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee7(rawEvent, currentViewedRoomId) {
+ var _rawEvent$content;
+ return _regeneratorRuntime().wrap(function _callee7$(_context7) {
+ while (1) switch (_context7.prev = _context7.next) {
+ case 0:
+ if (!(rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id))) {
+ _context7.next = 2;
+ break;
+ }
+ return _context7.abrupt("return");
+ case 2:
+ if (!(rawEvent.state_key !== undefined && rawEvent.state_key !== null)) {
+ _context7.next = 7;
+ break;
+ }
+ if (this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) {
+ _context7.next = 5;
+ break;
+ }
+ return _context7.abrupt("return");
+ case 5:
+ _context7.next = 9;
+ break;
+ case 7:
+ if (this.canReceiveRoomEvent(rawEvent.type, (_rawEvent$content = rawEvent.content) === null || _rawEvent$content === void 0 ? void 0 : _rawEvent$content["msgtype"])) {
+ _context7.next = 9;
+ break;
+ }
+ return _context7.abrupt("return");
+ case 9:
+ _context7.next = 11;
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.SendEvent, rawEvent // it's compatible, but missing the index signature
+ );
+ case 11:
+ case "end":
+ return _context7.stop();
+ }
+ }, _callee7, this);
+ }));
+ function feedEvent(_x8, _x9) {
+ return _feedEvent.apply(this, arguments);
+ }
+ return feedEvent;
+ }()
+ /**
+ * Feeds a to-device event to the widget. If the widget is not able to accept the
+ * event due to permissions, this will no-op and return calmly. If the widget failed
+ * to handle the event, this will raise an error.
+ * @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
+ * @param {boolean} encrypted Whether the event contents were encrypted.
+ * @returns {Promise<void>} Resolves when complete, rejects if there was an error sending.
+ */
+ }, {
+ key: "feedToDevice",
+ value: function () {
+ var _feedToDevice = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee8(rawEvent, encrypted) {
+ return _regeneratorRuntime().wrap(function _callee8$(_context8) {
+ while (1) switch (_context8.prev = _context8.next) {
+ case 0:
+ if (!this.canReceiveToDeviceEvent(rawEvent.type)) {
+ _context8.next = 3;
+ break;
+ }
+ _context8.next = 3;
+ return this.transport.send(_WidgetApiAction.WidgetApiToWidgetAction.SendToDevice, // it's compatible, but missing the index signature
+ _objectSpread(_objectSpread({}, rawEvent), {}, {
+ encrypted: encrypted
+ }));
+ case 3:
+ case "end":
+ return _context8.stop();
+ }
+ }, _callee8, this);
+ }));
+ function feedToDevice(_x10, _x11) {
+ return _feedToDevice.apply(this, arguments);
+ }
+ return feedToDevice;
+ }()
+ }]);
+ return ClientWidgetApi;
+}(_events.EventEmitter);
+exports.ClientWidgetApi = ClientWidgetApi;
+//# sourceMappingURL=ClientWidgetApi.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/LICENSE b/comm/chat/protocols/matrix/lib/matrix-widget-api/LICENSE
new file mode 100644
index 0000000000..261eeb9e9f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/Symbols.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/Symbols.js
new file mode 100644
index 0000000000..8b4942046c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/Symbols.js
@@ -0,0 +1,27 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.Symbols = void 0;
+/*
+ * 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.
+ */
+var Symbols = /*#__PURE__*/function (Symbols) {
+ Symbols["AnyRoom"] = "*";
+ return Symbols;
+}({});
+exports.Symbols = Symbols;
+//# sourceMappingURL=Symbols.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/WidgetApi.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/WidgetApi.js
new file mode 100644
index 0000000000..85f96163b8
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/WidgetApi.js
@@ -0,0 +1,808 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetApi = void 0;
+var _events = require("events");
+var _WidgetApiDirection = require("./interfaces/WidgetApiDirection");
+var _ApiVersion = require("./interfaces/ApiVersion");
+var _PostmessageTransport = require("./transport/PostmessageTransport");
+var _WidgetApiAction = require("./interfaces/WidgetApiAction");
+var _GetOpenIDAction = require("./interfaces/GetOpenIDAction");
+var _WidgetType = require("./interfaces/WidgetType");
+var _ModalWidgetActions = require("./interfaces/ModalWidgetActions");
+var _WidgetEventCapability = require("./models/WidgetEventCapability");
+var _Symbols = require("./Symbols");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _regeneratorRuntime() { "use strict"; /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ _regeneratorRuntime = function _regeneratorRuntime() { return exports; }; var exports = {}, Op = Object.prototype, hasOwn = Op.hasOwnProperty, defineProperty = Object.defineProperty || function (obj, key, desc) { obj[key] = desc.value; }, $Symbol = "function" == typeof Symbol ? Symbol : {}, iteratorSymbol = $Symbol.iterator || "@@iterator", asyncIteratorSymbol = $Symbol.asyncIterator || "@@asyncIterator", toStringTagSymbol = $Symbol.toStringTag || "@@toStringTag"; function define(obj, key, value) { return Object.defineProperty(obj, key, { value: value, enumerable: !0, configurable: !0, writable: !0 }), obj[key]; } try { define({}, ""); } catch (err) { define = function define(obj, key, value) { return obj[key] = value; }; } function wrap(innerFn, outerFn, self, tryLocsList) { var protoGenerator = outerFn && outerFn.prototype instanceof Generator ? outerFn : Generator, generator = Object.create(protoGenerator.prototype), context = new Context(tryLocsList || []); return defineProperty(generator, "_invoke", { value: makeInvokeMethod(innerFn, self, context) }), generator; } function tryCatch(fn, obj, arg) { try { return { type: "normal", arg: fn.call(obj, arg) }; } catch (err) { return { type: "throw", arg: err }; } } exports.wrap = wrap; var ContinueSentinel = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} var IteratorPrototype = {}; define(IteratorPrototype, iteratorSymbol, function () { return this; }); var getProto = Object.getPrototypeOf, NativeIteratorPrototype = getProto && getProto(getProto(values([]))); NativeIteratorPrototype && NativeIteratorPrototype !== Op && hasOwn.call(NativeIteratorPrototype, iteratorSymbol) && (IteratorPrototype = NativeIteratorPrototype); var Gp = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(IteratorPrototype); function defineIteratorMethods(prototype) { ["next", "throw", "return"].forEach(function (method) { define(prototype, method, function (arg) { return this._invoke(method, arg); }); }); } function AsyncIterator(generator, PromiseImpl) { function invoke(method, arg, resolve, reject) { var record = tryCatch(generator[method], generator, arg); if ("throw" !== record.type) { var result = record.arg, value = result.value; return value && "object" == _typeof(value) && hasOwn.call(value, "__await") ? PromiseImpl.resolve(value.__await).then(function (value) { invoke("next", value, resolve, reject); }, function (err) { invoke("throw", err, resolve, reject); }) : PromiseImpl.resolve(value).then(function (unwrapped) { result.value = unwrapped, resolve(result); }, function (error) { return invoke("throw", error, resolve, reject); }); } reject(record.arg); } var previousPromise; defineProperty(this, "_invoke", { value: function value(method, arg) { function callInvokeWithMethodAndArg() { return new PromiseImpl(function (resolve, reject) { invoke(method, arg, resolve, reject); }); } return previousPromise = previousPromise ? previousPromise.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); } }); } function makeInvokeMethod(innerFn, self, context) { var state = "suspendedStart"; return function (method, arg) { if ("executing" === state) throw new Error("Generator is already running"); if ("completed" === state) { if ("throw" === method) throw arg; return doneResult(); } for (context.method = method, context.arg = arg;;) { var delegate = context.delegate; if (delegate) { var delegateResult = maybeInvokeDelegate(delegate, context); if (delegateResult) { if (delegateResult === ContinueSentinel) continue; return delegateResult; } } if ("next" === context.method) context.sent = context._sent = context.arg;else if ("throw" === context.method) { if ("suspendedStart" === state) throw state = "completed", context.arg; context.dispatchException(context.arg); } else "return" === context.method && context.abrupt("return", context.arg); state = "executing"; var record = tryCatch(innerFn, self, context); if ("normal" === record.type) { if (state = context.done ? "completed" : "suspendedYield", record.arg === ContinueSentinel) continue; return { value: record.arg, done: context.done }; } "throw" === record.type && (state = "completed", context.method = "throw", context.arg = record.arg); } }; } function maybeInvokeDelegate(delegate, context) { var methodName = context.method, method = delegate.iterator[methodName]; if (undefined === method) return context.delegate = null, "throw" === methodName && delegate.iterator["return"] && (context.method = "return", context.arg = undefined, maybeInvokeDelegate(delegate, context), "throw" === context.method) || "return" !== methodName && (context.method = "throw", context.arg = new TypeError("The iterator does not provide a '" + methodName + "' method")), ContinueSentinel; var record = tryCatch(method, delegate.iterator, context.arg); if ("throw" === record.type) return context.method = "throw", context.arg = record.arg, context.delegate = null, ContinueSentinel; var info = record.arg; return info ? info.done ? (context[delegate.resultName] = info.value, context.next = delegate.nextLoc, "return" !== context.method && (context.method = "next", context.arg = undefined), context.delegate = null, ContinueSentinel) : info : (context.method = "throw", context.arg = new TypeError("iterator result is not an object"), context.delegate = null, ContinueSentinel); } function pushTryEntry(locs) { var entry = { tryLoc: locs[0] }; 1 in locs && (entry.catchLoc = locs[1]), 2 in locs && (entry.finallyLoc = locs[2], entry.afterLoc = locs[3]), this.tryEntries.push(entry); } function resetTryEntry(entry) { var record = entry.completion || {}; record.type = "normal", delete record.arg, entry.completion = record; } function Context(tryLocsList) { this.tryEntries = [{ tryLoc: "root" }], tryLocsList.forEach(pushTryEntry, this), this.reset(!0); } function values(iterable) { if (iterable) { var iteratorMethod = iterable[iteratorSymbol]; if (iteratorMethod) return iteratorMethod.call(iterable); if ("function" == typeof iterable.next) return iterable; if (!isNaN(iterable.length)) { var i = -1, next = function next() { for (; ++i < iterable.length;) if (hasOwn.call(iterable, i)) return next.value = iterable[i], next.done = !1, next; return next.value = undefined, next.done = !0, next; }; return next.next = next; } } return { next: doneResult }; } function doneResult() { return { value: undefined, done: !0 }; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, defineProperty(Gp, "constructor", { value: GeneratorFunctionPrototype, configurable: !0 }), defineProperty(GeneratorFunctionPrototype, "constructor", { value: GeneratorFunction, configurable: !0 }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, toStringTagSymbol, "GeneratorFunction"), exports.isGeneratorFunction = function (genFun) { var ctor = "function" == typeof genFun && genFun.constructor; return !!ctor && (ctor === GeneratorFunction || "GeneratorFunction" === (ctor.displayName || ctor.name)); }, exports.mark = function (genFun) { return Object.setPrototypeOf ? Object.setPrototypeOf(genFun, GeneratorFunctionPrototype) : (genFun.__proto__ = GeneratorFunctionPrototype, define(genFun, toStringTagSymbol, "GeneratorFunction")), genFun.prototype = Object.create(Gp), genFun; }, exports.awrap = function (arg) { return { __await: arg }; }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, asyncIteratorSymbol, function () { return this; }), exports.AsyncIterator = AsyncIterator, exports.async = function (innerFn, outerFn, self, tryLocsList, PromiseImpl) { void 0 === PromiseImpl && (PromiseImpl = Promise); var iter = new AsyncIterator(wrap(innerFn, outerFn, self, tryLocsList), PromiseImpl); return exports.isGeneratorFunction(outerFn) ? iter : iter.next().then(function (result) { return result.done ? result.value : iter.next(); }); }, defineIteratorMethods(Gp), define(Gp, toStringTagSymbol, "Generator"), define(Gp, iteratorSymbol, function () { return this; }), define(Gp, "toString", function () { return "[object Generator]"; }), exports.keys = function (val) { var object = Object(val), keys = []; for (var key in object) keys.push(key); return keys.reverse(), function next() { for (; keys.length;) { var key = keys.pop(); if (key in object) return next.value = key, next.done = !1, next; } return next.done = !0, next; }; }, exports.values = values, Context.prototype = { constructor: Context, reset: function reset(skipTempReset) { if (this.prev = 0, this.next = 0, this.sent = this._sent = undefined, this.done = !1, this.delegate = null, this.method = "next", this.arg = undefined, this.tryEntries.forEach(resetTryEntry), !skipTempReset) for (var name in this) "t" === name.charAt(0) && hasOwn.call(this, name) && !isNaN(+name.slice(1)) && (this[name] = undefined); }, stop: function stop() { this.done = !0; var rootRecord = this.tryEntries[0].completion; if ("throw" === rootRecord.type) throw rootRecord.arg; return this.rval; }, dispatchException: function dispatchException(exception) { if (this.done) throw exception; var context = this; function handle(loc, caught) { return record.type = "throw", record.arg = exception, context.next = loc, caught && (context.method = "next", context.arg = undefined), !!caught; } for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i], record = entry.completion; if ("root" === entry.tryLoc) return handle("end"); if (entry.tryLoc <= this.prev) { var hasCatch = hasOwn.call(entry, "catchLoc"), hasFinally = hasOwn.call(entry, "finallyLoc"); if (hasCatch && hasFinally) { if (this.prev < entry.catchLoc) return handle(entry.catchLoc, !0); if (this.prev < entry.finallyLoc) return handle(entry.finallyLoc); } else if (hasCatch) { if (this.prev < entry.catchLoc) return handle(entry.catchLoc, !0); } else { if (!hasFinally) throw new Error("try statement without catch or finally"); if (this.prev < entry.finallyLoc) return handle(entry.finallyLoc); } } } }, abrupt: function abrupt(type, arg) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc <= this.prev && hasOwn.call(entry, "finallyLoc") && this.prev < entry.finallyLoc) { var finallyEntry = entry; break; } } finallyEntry && ("break" === type || "continue" === type) && finallyEntry.tryLoc <= arg && arg <= finallyEntry.finallyLoc && (finallyEntry = null); var record = finallyEntry ? finallyEntry.completion : {}; return record.type = type, record.arg = arg, finallyEntry ? (this.method = "next", this.next = finallyEntry.finallyLoc, ContinueSentinel) : this.complete(record); }, complete: function complete(record, afterLoc) { if ("throw" === record.type) throw record.arg; return "break" === record.type || "continue" === record.type ? this.next = record.arg : "return" === record.type ? (this.rval = this.arg = record.arg, this.method = "return", this.next = "end") : "normal" === record.type && afterLoc && (this.next = afterLoc), ContinueSentinel; }, finish: function finish(finallyLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.finallyLoc === finallyLoc) return this.complete(entry.completion, entry.afterLoc), resetTryEntry(entry), ContinueSentinel; } }, "catch": function _catch(tryLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc === tryLoc) { var record = entry.completion; if ("throw" === record.type) { var thrown = record.arg; resetTryEntry(entry); } return thrown; } } throw new Error("illegal catch attempt"); }, delegateYield: function delegateYield(iterable, resultName, nextLoc) { return this.delegate = { iterator: values(iterable), resultName: resultName, nextLoc: nextLoc }, "next" === this.method && (this.arg = undefined), ContinueSentinel; } }, exports; }
+function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
+function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+function _awaitAsyncGenerator(value) { return new _OverloadYield(value, 0); }
+function _wrapAsyncGenerator(fn) { return function () { return new _AsyncGenerator(fn.apply(this, arguments)); }; }
+function _AsyncGenerator(gen) { var front, back; function resume(key, arg) { try { var result = gen[key](arg), value = result.value, overloaded = value instanceof _OverloadYield; Promise.resolve(overloaded ? value.v : value).then(function (arg) { if (overloaded) { var nextKey = "return" === key ? "return" : "next"; if (!value.k || arg.done) return resume(nextKey, arg); arg = gen[nextKey](arg).value; } settle(result.done ? "return" : "normal", arg); }, function (err) { resume("throw", err); }); } catch (err) { settle("throw", err); } } function settle(type, value) { switch (type) { case "return": front.resolve({ value: value, done: !0 }); break; case "throw": front.reject(value); break; default: front.resolve({ value: value, done: !1 }); } (front = front.next) ? resume(front.key, front.arg) : back = null; } this._invoke = function (key, arg) { return new Promise(function (resolve, reject) { var request = { key: key, arg: arg, resolve: resolve, reject: reject, next: null }; back ? back = back.next = request : (front = back = request, resume(key, arg)); }); }, "function" != typeof gen["return"] && (this["return"] = void 0); }
+_AsyncGenerator.prototype["function" == typeof Symbol && Symbol.asyncIterator || "@@asyncIterator"] = function () { return this; }, _AsyncGenerator.prototype.next = function (arg) { return this._invoke("next", arg); }, _AsyncGenerator.prototype["throw"] = function (arg) { return this._invoke("throw", arg); }, _AsyncGenerator.prototype["return"] = function (arg) { return this._invoke("return", arg); };
+function _OverloadYield(value, kind) { this.v = value, this.k = kind; } /*
+ * 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.
+ */
+/**
+ * API handler for widgets. This raises events for each action
+ * received as `action:${action}` (eg: "action:screenshot").
+ * Default handling can be prevented by using preventDefault()
+ * on the raised event. The default handling varies for each
+ * action: ones which the SDK can handle safely are acknowledged
+ * appropriately and ones which are unhandled (custom or require
+ * the widget to do something) are rejected with an error.
+ *
+ * Events which are preventDefault()ed must reply using the
+ * transport. The events raised will have a detail of an
+ * IWidgetApiRequest interface.
+ *
+ * When the WidgetApi is ready to start sending requests, it will
+ * raise a "ready" CustomEvent. After the ready event fires, actions
+ * can be sent and the transport will be ready.
+ */
+var WidgetApi = /*#__PURE__*/function (_EventEmitter) {
+ _inherits(WidgetApi, _EventEmitter);
+ var _super = _createSuper(WidgetApi);
+ /**
+ * Creates a new API handler for the given widget.
+ * @param {string} widgetId The widget ID to listen for. If not supplied then
+ * the API will use the widget ID from the first valid request it receives.
+ * @param {string} clientOrigin The origin of the client, or null if not known.
+ */
+ function WidgetApi() {
+ var _this2;
+ var widgetId = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
+ var clientOrigin = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
+ _classCallCheck(this, WidgetApi);
+ _this2 = _super.call(this);
+ _this2.clientOrigin = clientOrigin;
+ _defineProperty(_assertThisInitialized(_this2), "transport", void 0);
+ _defineProperty(_assertThisInitialized(_this2), "capabilitiesFinished", false);
+ _defineProperty(_assertThisInitialized(_this2), "supportsMSC2974Renegotiate", false);
+ _defineProperty(_assertThisInitialized(_this2), "requestedCapabilities", []);
+ _defineProperty(_assertThisInitialized(_this2), "approvedCapabilities", void 0);
+ _defineProperty(_assertThisInitialized(_this2), "cachedClientVersions", void 0);
+ _defineProperty(_assertThisInitialized(_this2), "turnServerWatchers", 0);
+ if (!window.parent) {
+ throw new Error("No parent window. This widget doesn't appear to be embedded properly.");
+ }
+ _this2.transport = new _PostmessageTransport.PostmessageTransport(_WidgetApiDirection.WidgetApiDirection.FromWidget, widgetId, window.parent, window);
+ _this2.transport.targetOrigin = clientOrigin;
+ _this2.transport.on("message", _this2.handleMessage.bind(_assertThisInitialized(_this2)));
+ return _this2;
+ }
+
+ /**
+ * Determines if the widget was granted a particular capability. Note that on
+ * clients where the capabilities are not fed back to the widget this function
+ * will rely on requested capabilities instead.
+ * @param {Capability} capability The capability to check for approval of.
+ * @returns {boolean} True if the widget has approval for the given capability.
+ */
+ _createClass(WidgetApi, [{
+ key: "hasCapability",
+ value: function hasCapability(capability) {
+ if (Array.isArray(this.approvedCapabilities)) {
+ return this.approvedCapabilities.includes(capability);
+ }
+ return this.requestedCapabilities.includes(capability);
+ }
+
+ /**
+ * Request a capability from the client. It is not guaranteed to be allowed,
+ * but will be asked for.
+ * @param {Capability} capability The capability to request.
+ * @throws Throws if the capabilities negotiation has already started and the
+ * widget is unable to request additional capabilities.
+ */
+ }, {
+ key: "requestCapability",
+ value: function requestCapability(capability) {
+ if (this.capabilitiesFinished && !this.supportsMSC2974Renegotiate) {
+ throw new Error("Capabilities have already been negotiated");
+ }
+ this.requestedCapabilities.push(capability);
+ }
+
+ /**
+ * Request capabilities from the client. They are not guaranteed to be allowed,
+ * but will be asked for if the negotiation has not already happened.
+ * @param {Capability[]} capabilities The capabilities to request.
+ * @throws Throws if the capabilities negotiation has already started.
+ */
+ }, {
+ key: "requestCapabilities",
+ value: function requestCapabilities(capabilities) {
+ var _this3 = this;
+ capabilities.forEach(function (cap) {
+ return _this3.requestCapability(cap);
+ });
+ }
+
+ /**
+ * Requests the capability to interact with rooms other than the user's currently
+ * viewed room. Applies to event receiving and sending.
+ * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` to
+ * denote all known rooms.
+ */
+ }, {
+ key: "requestCapabilityForRoomTimeline",
+ value: function requestCapabilityForRoomTimeline(roomId) {
+ this.requestCapability("org.matrix.msc2762.timeline:".concat(roomId));
+ }
+
+ /**
+ * Requests the capability to send a given state event with optional explicit
+ * state key. It is not guaranteed to be allowed, but will be asked for if the
+ * negotiation has not already happened.
+ * @param {string} eventType The state event type to ask for.
+ * @param {string} stateKey If specified, the specific state key to request.
+ * Otherwise all state keys will be requested.
+ */
+ }, {
+ key: "requestCapabilityToSendState",
+ value: function requestCapabilityToSendState(eventType, stateKey) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forStateEvent(_WidgetEventCapability.EventDirection.Send, eventType, stateKey).raw);
+ }
+
+ /**
+ * Requests the capability to receive a given state event with optional explicit
+ * state key. It is not guaranteed to be allowed, but will be asked for if the
+ * negotiation has not already happened.
+ * @param {string} eventType The state event type to ask for.
+ * @param {string} stateKey If specified, the specific state key to request.
+ * Otherwise all state keys will be requested.
+ */
+ }, {
+ key: "requestCapabilityToReceiveState",
+ value: function requestCapabilityToReceiveState(eventType, stateKey) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forStateEvent(_WidgetEventCapability.EventDirection.Receive, eventType, stateKey).raw);
+ }
+
+ /**
+ * Requests the capability to send a given to-device event. It is not
+ * guaranteed to be allowed, but will be asked for if the negotiation has
+ * not already happened.
+ * @param {string} eventType The room event type to ask for.
+ */
+ }, {
+ key: "requestCapabilityToSendToDevice",
+ value: function requestCapabilityToSendToDevice(eventType) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forToDeviceEvent(_WidgetEventCapability.EventDirection.Send, eventType).raw);
+ }
+
+ /**
+ * Requests the capability to receive a given to-device event. It is not
+ * guaranteed to be allowed, but will be asked for if the negotiation has
+ * not already happened.
+ * @param {string} eventType The room event type to ask for.
+ */
+ }, {
+ key: "requestCapabilityToReceiveToDevice",
+ value: function requestCapabilityToReceiveToDevice(eventType) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forToDeviceEvent(_WidgetEventCapability.EventDirection.Receive, eventType).raw);
+ }
+
+ /**
+ * Requests the capability to send a given room event. It is not guaranteed to be
+ * allowed, but will be asked for if the negotiation has not already happened.
+ * @param {string} eventType The room event type to ask for.
+ */
+ }, {
+ key: "requestCapabilityToSendEvent",
+ value: function requestCapabilityToSendEvent(eventType) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forRoomEvent(_WidgetEventCapability.EventDirection.Send, eventType).raw);
+ }
+
+ /**
+ * Requests the capability to receive a given room event. It is not guaranteed to be
+ * allowed, but will be asked for if the negotiation has not already happened.
+ * @param {string} eventType The room event type to ask for.
+ */
+ }, {
+ key: "requestCapabilityToReceiveEvent",
+ value: function requestCapabilityToReceiveEvent(eventType) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forRoomEvent(_WidgetEventCapability.EventDirection.Receive, eventType).raw);
+ }
+
+ /**
+ * Requests the capability to send a given message event with optional explicit
+ * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the
+ * negotiation has not already happened.
+ * @param {string} msgtype If specified, the specific msgtype to request.
+ * Otherwise all message types will be requested.
+ */
+ }, {
+ key: "requestCapabilityToSendMessage",
+ value: function requestCapabilityToSendMessage(msgtype) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forRoomMessageEvent(_WidgetEventCapability.EventDirection.Send, msgtype).raw);
+ }
+
+ /**
+ * Requests the capability to receive a given message event with optional explicit
+ * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the
+ * negotiation has not already happened.
+ * @param {string} msgtype If specified, the specific msgtype to request.
+ * Otherwise all message types will be requested.
+ */
+ }, {
+ key: "requestCapabilityToReceiveMessage",
+ value: function requestCapabilityToReceiveMessage(msgtype) {
+ this.requestCapability(_WidgetEventCapability.WidgetEventCapability.forRoomMessageEvent(_WidgetEventCapability.EventDirection.Receive, msgtype).raw);
+ }
+
+ /**
+ * Requests an OpenID Connect token from the client for the currently logged in
+ * user. This token can be validated server-side with the federation API. Note
+ * that the widget is responsible for validating the token and caching any results
+ * it needs.
+ * @returns {Promise<IOpenIDCredentials>} Resolves to a token for verification.
+ * @throws Throws if the user rejected the request or the request failed.
+ */
+ }, {
+ key: "requestOpenIDConnectToken",
+ value: function requestOpenIDConnectToken() {
+ var _this4 = this;
+ return new Promise(function (resolve, reject) {
+ _this4.transport.sendComplete(_WidgetApiAction.WidgetApiFromWidgetAction.GetOpenIDCredentials, {}).then(function (response) {
+ var rdata = response.response;
+ if (rdata.state === _GetOpenIDAction.OpenIDRequestState.Allowed) {
+ resolve(rdata);
+ } else if (rdata.state === _GetOpenIDAction.OpenIDRequestState.Blocked) {
+ reject(new Error("User declined to verify their identity"));
+ } else if (rdata.state === _GetOpenIDAction.OpenIDRequestState.PendingUserConfirmation) {
+ var handlerFn = function handlerFn(ev) {
+ ev.preventDefault();
+ var request = ev.detail;
+ if (request.data.original_request_id !== response.requestId) return;
+ if (request.data.state === _GetOpenIDAction.OpenIDRequestState.Allowed) {
+ resolve(request.data);
+ _this4.transport.reply(request, {}); // ack
+ } else if (request.data.state === _GetOpenIDAction.OpenIDRequestState.Blocked) {
+ reject(new Error("User declined to verify their identity"));
+ _this4.transport.reply(request, {}); // ack
+ } else {
+ reject(new Error("Invalid state on reply: " + rdata.state));
+ _this4.transport.reply(request, {
+ error: {
+ message: "Invalid state"
+ }
+ });
+ }
+ _this4.off("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.OpenIDCredentials), handlerFn);
+ };
+ _this4.on("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.OpenIDCredentials), handlerFn);
+ } else {
+ reject(new Error("Invalid state: " + rdata.state));
+ }
+ })["catch"](reject);
+ });
+ }
+
+ /**
+ * Asks the client for additional capabilities. Capabilities can be queued for this
+ * request with the requestCapability() functions.
+ * @returns {Promise<void>} Resolves when complete. Note that the promise resolves when
+ * the capabilities request has gone through, not when the capabilities are approved/denied.
+ * Use the WidgetApiToWidgetAction.NotifyCapabilities action to detect changes.
+ */
+ }, {
+ key: "updateRequestedCapabilities",
+ value: function updateRequestedCapabilities() {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities, {
+ capabilities: this.requestedCapabilities
+ }).then();
+ }
+
+ /**
+ * Tell the client that the content has been loaded.
+ * @returns {Promise} Resolves when the client acknowledges the request.
+ */
+ }, {
+ key: "sendContentLoaded",
+ value: function sendContentLoaded() {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.ContentLoaded, {}).then();
+ }
+
+ /**
+ * Sends a sticker to the client.
+ * @param {IStickerActionRequestData} sticker The sticker to send.
+ * @returns {Promise} Resolves when the client acknowledges the request.
+ */
+ }, {
+ key: "sendSticker",
+ value: function sendSticker(sticker) {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SendSticker, sticker).then();
+ }
+
+ /**
+ * Asks the client to set the always-on-screen status for this widget.
+ * @param {boolean} value The new state to request.
+ * @returns {Promise<boolean>} Resolve with true if the client was able to fulfill
+ * the request, resolves to false otherwise. Rejects if an error occurred.
+ */
+ }, {
+ key: "setAlwaysOnScreen",
+ value: function setAlwaysOnScreen(value) {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, {
+ value: value
+ }).then(function (res) {
+ return res.success;
+ });
+ }
+
+ /**
+ * Opens a modal widget.
+ * @param {string} url The URL to the modal widget.
+ * @param {string} name The name of the widget.
+ * @param {IModalWidgetOpenRequestDataButton[]} buttons The buttons to have on the widget.
+ * @param {IModalWidgetCreateData} data Data to supply to the modal widget.
+ * @param {WidgetType} type The type of modal widget.
+ * @returns {Promise<void>} Resolves when the modal widget has been opened.
+ */
+ }, {
+ key: "openModalWidget",
+ value: function openModalWidget(url, name) {
+ var buttons = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [];
+ var data = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+ var type = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : _WidgetType.MatrixWidgetType.Custom;
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.OpenModalWidget, {
+ type: type,
+ url: url,
+ name: name,
+ buttons: buttons,
+ data: data
+ }).then();
+ }
+
+ /**
+ * Closes the modal widget. The widget's session will be terminated shortly after.
+ * @param {IModalWidgetReturnData} data Optional data to close the modal widget with.
+ * @returns {Promise<void>} Resolves when complete.
+ */
+ }, {
+ key: "closeModalWidget",
+ value: function closeModalWidget() {
+ var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.CloseModalWidget, data).then();
+ }
+ }, {
+ key: "sendRoomEvent",
+ value: function sendRoomEvent(eventType, content, roomId) {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SendEvent, {
+ type: eventType,
+ content: content,
+ room_id: roomId
+ });
+ }
+ }, {
+ key: "sendStateEvent",
+ value: function sendStateEvent(eventType, stateKey, content, roomId) {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SendEvent, {
+ type: eventType,
+ content: content,
+ state_key: stateKey,
+ room_id: roomId
+ });
+ }
+
+ /**
+ * Sends a to-device event.
+ * @param {string} eventType The type of events being sent.
+ * @param {boolean} encrypted Whether to encrypt the message contents.
+ * @param {Object} contentMap A map from user IDs to device IDs to message contents.
+ * @returns {Promise<ISendToDeviceFromWidgetResponseData>} Resolves when complete.
+ */
+ }, {
+ key: "sendToDevice",
+ value: function sendToDevice(eventType, encrypted, contentMap) {
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SendToDevice, {
+ type: eventType,
+ encrypted: encrypted,
+ messages: contentMap
+ });
+ }
+ }, {
+ key: "readRoomEvents",
+ value: function readRoomEvents(eventType, limit, msgtype, roomIds) {
+ var data = {
+ type: eventType,
+ msgtype: msgtype
+ };
+ if (limit !== undefined) {
+ data.limit = limit;
+ }
+ if (roomIds) {
+ if (roomIds.includes(_Symbols.Symbols.AnyRoom)) {
+ data.room_ids = _Symbols.Symbols.AnyRoom;
+ } else {
+ data.room_ids = roomIds;
+ }
+ }
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC2876ReadEvents, data).then(function (r) {
+ return r.events;
+ });
+ }
+
+ /**
+ * Reads all related events given a known eventId.
+ * @param eventId The id of the parent event to be read.
+ * @param roomId The room to look within. When undefined, the user's currently
+ * viewed room.
+ * @param relationType The relationship type of child events to search for.
+ * When undefined, all relations are returned.
+ * @param eventType The event type of child events to search for. When undefined,
+ * all related events are returned.
+ * @param limit The maximum number of events to retrieve per room. If not
+ * supplied, the server will apply a default limit.
+ * @param from The pagination token to start returning results from, as
+ * received from a previous call. If not supplied, results start at the most
+ * recent topological event known to the server.
+ * @param to The pagination token to stop returning results at. If not
+ * supplied, results continue up to limit or until there are no more events.
+ * @param direction The direction to search for according to MSC3715.
+ * @returns Resolves to the room relations.
+ */
+ }, {
+ key: "readEventRelations",
+ value: function () {
+ var _readEventRelations = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(eventId, roomId, relationType, eventType, limit, from, to, direction) {
+ var versions, data;
+ return _regeneratorRuntime().wrap(function _callee$(_context) {
+ while (1) switch (_context.prev = _context.next) {
+ case 0:
+ _context.next = 2;
+ return this.getClientVersions();
+ case 2:
+ versions = _context.sent;
+ if (versions.includes(_ApiVersion.UnstableApiVersion.MSC3869)) {
+ _context.next = 5;
+ break;
+ }
+ throw new Error("The read_relations action is not supported by the client.");
+ case 5:
+ data = {
+ event_id: eventId,
+ rel_type: relationType,
+ event_type: eventType,
+ room_id: roomId,
+ to: to,
+ from: from,
+ limit: limit,
+ direction: direction
+ };
+ return _context.abrupt("return", this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC3869ReadRelations, data));
+ case 7:
+ case "end":
+ return _context.stop();
+ }
+ }, _callee, this);
+ }));
+ function readEventRelations(_x, _x2, _x3, _x4, _x5, _x6, _x7, _x8) {
+ return _readEventRelations.apply(this, arguments);
+ }
+ return readEventRelations;
+ }()
+ }, {
+ key: "readStateEvents",
+ value: function readStateEvents(eventType, limit, stateKey, roomIds) {
+ var data = {
+ type: eventType,
+ state_key: stateKey === undefined ? true : stateKey
+ };
+ if (limit !== undefined) {
+ data.limit = limit;
+ }
+ if (roomIds) {
+ if (roomIds.includes(_Symbols.Symbols.AnyRoom)) {
+ data.room_ids = _Symbols.Symbols.AnyRoom;
+ } else {
+ data.room_ids = roomIds;
+ }
+ }
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC2876ReadEvents, data).then(function (r) {
+ return r.events;
+ });
+ }
+
+ /**
+ * Sets a button as disabled or enabled on the modal widget. Buttons are enabled by default.
+ * @param {ModalButtonID} buttonId The button ID to enable/disable.
+ * @param {boolean} isEnabled Whether or not the button is enabled.
+ * @returns {Promise<void>} Resolves when complete.
+ * @throws Throws if the button cannot be disabled, or the client refuses to disable the button.
+ */
+ }, {
+ key: "setModalButtonEnabled",
+ value: function setModalButtonEnabled(buttonId, isEnabled) {
+ if (buttonId === _ModalWidgetActions.BuiltInModalButtonID.Close) {
+ throw new Error("The close button cannot be disabled");
+ }
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SetModalButtonEnabled, {
+ button: buttonId,
+ enabled: isEnabled
+ }).then();
+ }
+
+ /**
+ * Attempts to navigate the client to the given URI. This can only be called with Matrix URIs
+ * (currently only matrix.to, but in future a Matrix URI scheme will be defined).
+ * @param {string} uri The URI to navigate to.
+ * @returns {Promise<void>} Resolves when complete.
+ * @throws Throws if the URI is invalid or cannot be processed.
+ * @deprecated This currently relies on an unstable MSC (MSC2931).
+ */
+ }, {
+ key: "navigateTo",
+ value: function navigateTo(uri) {
+ if (!uri || !uri.startsWith("https://matrix.to/#")) {
+ throw new Error("Invalid matrix.to URI");
+ }
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC2931Navigate, {
+ uri: uri
+ }).then();
+ }
+
+ /**
+ * Starts watching for TURN servers, yielding an initial set of credentials as soon as possible,
+ * and thereafter yielding new credentials whenever the previous ones expire.
+ * @yields {ITurnServer} The TURN server URIs and credentials currently available to the widget.
+ */
+ }, {
+ key: "getTurnServers",
+ value: function getTurnServers() {
+ var _this = this;
+ return _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3() {
+ var setTurnServer, onUpdateTurnServers;
+ return _regeneratorRuntime().wrap(function _callee3$(_context3) {
+ while (1) switch (_context3.prev = _context3.next) {
+ case 0:
+ onUpdateTurnServers = /*#__PURE__*/function () {
+ var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(ev) {
+ return _regeneratorRuntime().wrap(function _callee2$(_context2) {
+ while (1) switch (_context2.prev = _context2.next) {
+ case 0:
+ ev.preventDefault();
+ setTurnServer(ev.detail.data);
+ _context2.next = 4;
+ return _this.transport.reply(ev.detail, {});
+ case 4:
+ case "end":
+ return _context2.stop();
+ }
+ }, _callee2);
+ }));
+ return function onUpdateTurnServers(_x9) {
+ return _ref.apply(this, arguments);
+ };
+ }(); // Start listening for updates before we even start watching, to catch
+ // TURN data that is sent immediately
+ _this.on("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.UpdateTurnServers), onUpdateTurnServers);
+
+ // Only send the 'watch' action if we aren't already watching
+ if (!(_this.turnServerWatchers === 0)) {
+ _context3.next = 12;
+ break;
+ }
+ _context3.prev = 3;
+ _context3.next = 6;
+ return _awaitAsyncGenerator(_this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.WatchTurnServers, {}));
+ case 6:
+ _context3.next = 12;
+ break;
+ case 8:
+ _context3.prev = 8;
+ _context3.t0 = _context3["catch"](3);
+ _this.off("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.UpdateTurnServers), onUpdateTurnServers);
+ throw _context3.t0;
+ case 12:
+ _this.turnServerWatchers++;
+ _context3.prev = 13;
+ case 14:
+ if (!true) {
+ _context3.next = 21;
+ break;
+ }
+ _context3.next = 17;
+ return _awaitAsyncGenerator(new Promise(function (resolve) {
+ return setTurnServer = resolve;
+ }));
+ case 17:
+ _context3.next = 19;
+ return _context3.sent;
+ case 19:
+ _context3.next = 14;
+ break;
+ case 21:
+ _context3.prev = 21;
+ // The loop was broken by the caller - clean up
+ _this.off("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.UpdateTurnServers), onUpdateTurnServers);
+
+ // Since sending the 'unwatch' action will end updates for all other
+ // consumers, only send it if we're the only consumer remaining
+ _this.turnServerWatchers--;
+ if (!(_this.turnServerWatchers === 0)) {
+ _context3.next = 27;
+ break;
+ }
+ _context3.next = 27;
+ return _awaitAsyncGenerator(_this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.UnwatchTurnServers, {}));
+ case 27:
+ return _context3.finish(21);
+ case 28:
+ case "end":
+ return _context3.stop();
+ }
+ }, _callee3, null, [[3, 8], [13,, 21, 28]]);
+ }))();
+ }
+
+ /**
+ * Search for users in the user directory.
+ * @param searchTerm The term to search for.
+ * @param limit The maximum number of results to return. If not supplied, the
+ * @returns Resolves to the search results.
+ */
+ }, {
+ key: "searchUserDirectory",
+ value: function () {
+ var _searchUserDirectory = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(searchTerm, limit) {
+ var versions, data;
+ return _regeneratorRuntime().wrap(function _callee4$(_context4) {
+ while (1) switch (_context4.prev = _context4.next) {
+ case 0:
+ _context4.next = 2;
+ return this.getClientVersions();
+ case 2:
+ versions = _context4.sent;
+ if (versions.includes(_ApiVersion.UnstableApiVersion.MSC3973)) {
+ _context4.next = 5;
+ break;
+ }
+ throw new Error("The user_directory_search action is not supported by the client.");
+ case 5:
+ data = {
+ search_term: searchTerm,
+ limit: limit
+ };
+ return _context4.abrupt("return", this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, data));
+ case 7:
+ case "end":
+ return _context4.stop();
+ }
+ }, _callee4, this);
+ }));
+ function searchUserDirectory(_x10, _x11) {
+ return _searchUserDirectory.apply(this, arguments);
+ }
+ return searchUserDirectory;
+ }()
+ /**
+ * Starts the communication channel. This should be done early to ensure
+ * that messages are not missed. Communication can only be stopped by the client.
+ */
+ }, {
+ key: "start",
+ value: function start() {
+ var _this5 = this;
+ this.transport.start();
+ this.getClientVersions().then(function (v) {
+ if (v.includes(_ApiVersion.UnstableApiVersion.MSC2974)) {
+ _this5.supportsMSC2974Renegotiate = true;
+ }
+ });
+ }
+ }, {
+ key: "handleMessage",
+ value: function handleMessage(ev) {
+ var actionEv = new CustomEvent("action:".concat(ev.detail.action), {
+ detail: ev.detail,
+ cancelable: true
+ });
+ this.emit("action:".concat(ev.detail.action), actionEv);
+ if (!actionEv.defaultPrevented) {
+ switch (ev.detail.action) {
+ case _WidgetApiAction.WidgetApiToWidgetAction.SupportedApiVersions:
+ return this.replyVersions(ev.detail);
+ case _WidgetApiAction.WidgetApiToWidgetAction.Capabilities:
+ return this.handleCapabilities(ev.detail);
+ case _WidgetApiAction.WidgetApiToWidgetAction.UpdateVisibility:
+ return this.transport.reply(ev.detail, {});
+ // ack to avoid error spam
+ case _WidgetApiAction.WidgetApiToWidgetAction.NotifyCapabilities:
+ return this.transport.reply(ev.detail, {});
+ // ack to avoid error spam
+ default:
+ return this.transport.reply(ev.detail, {
+ error: {
+ message: "Unknown or unsupported action: " + ev.detail.action
+ }
+ });
+ }
+ }
+ }
+ }, {
+ key: "replyVersions",
+ value: function replyVersions(request) {
+ this.transport.reply(request, {
+ supported_versions: _ApiVersion.CurrentApiVersions
+ });
+ }
+ }, {
+ key: "getClientVersions",
+ value: function getClientVersions() {
+ var _this6 = this;
+ if (Array.isArray(this.cachedClientVersions)) {
+ return Promise.resolve(this.cachedClientVersions);
+ }
+ return this.transport.send(_WidgetApiAction.WidgetApiFromWidgetAction.SupportedApiVersions, {}).then(function (r) {
+ _this6.cachedClientVersions = r.supported_versions;
+ return r.supported_versions;
+ })["catch"](function (e) {
+ console.warn("non-fatal error getting supported client versions: ", e);
+ return [];
+ });
+ }
+ }, {
+ key: "handleCapabilities",
+ value: function handleCapabilities(request) {
+ var _this7 = this;
+ if (this.capabilitiesFinished) {
+ return this.transport.reply(request, {
+ error: {
+ message: "Capability negotiation already completed"
+ }
+ });
+ }
+
+ // See if we can expect a capabilities notification or not
+ return this.getClientVersions().then(function (v) {
+ if (v.includes(_ApiVersion.UnstableApiVersion.MSC2871)) {
+ _this7.once("action:".concat(_WidgetApiAction.WidgetApiToWidgetAction.NotifyCapabilities), function (ev) {
+ _this7.approvedCapabilities = ev.detail.data.approved;
+ _this7.emit("ready");
+ });
+ } else {
+ // if we can't expect notification, we're as done as we can be
+ _this7.emit("ready");
+ }
+
+ // in either case, reply to that capabilities request
+ _this7.capabilitiesFinished = true;
+ return _this7.transport.reply(request, {
+ capabilities: _this7.requestedCapabilities
+ });
+ });
+ }
+ }]);
+ return WidgetApi;
+}(_events.EventEmitter);
+exports.WidgetApi = WidgetApi;
+//# sourceMappingURL=WidgetApi.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/driver/WidgetDriver.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/driver/WidgetDriver.js
new file mode 100644
index 0000000000..17621a9f87
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/driver/WidgetDriver.js
@@ -0,0 +1,239 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetDriver = void 0;
+var _ = require("..");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ * 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.
+ */
+/**
+ * Represents the functions and behaviour the widget-api is unable to
+ * do, such as prompting the user for information or interacting with
+ * the UI. Clients are expected to implement this class and override
+ * any functions they need/want to support.
+ *
+ * This class assumes the client will have a context of a Widget
+ * instance already.
+ */
+var WidgetDriver = /*#__PURE__*/function () {
+ function WidgetDriver() {
+ _classCallCheck(this, WidgetDriver);
+ }
+ _createClass(WidgetDriver, [{
+ key: "validateCapabilities",
+ value:
+ /**
+ * Verifies the widget's requested capabilities, returning the ones
+ * it is approved to use. Mutating the requested capabilities will
+ * have no effect.
+ *
+ * This SHOULD result in the user being prompted to approve/deny
+ * capabilities.
+ *
+ * By default this rejects all capabilities (returns an empty set).
+ * @param {Set<Capability>} requested The set of requested capabilities.
+ * @returns {Promise<Set<Capability>>} Resolves to the allowed capabilities.
+ */
+ function validateCapabilities(requested) {
+ return Promise.resolve(new Set());
+ }
+
+ /**
+ * Sends an event into a room. If `roomId` is falsy, the client should send the event
+ * into the room the user is currently looking at. The widget API will have already
+ * verified that the widget is capable of sending the event to that room.
+ * @param {string} eventType The event type to be sent.
+ * @param {*} content The content for the event.
+ * @param {string|null} stateKey The state key if this is a state event, otherwise null.
+ * May be an empty string.
+ * @param {string|null} roomId The room ID to send the event to. If falsy, the room the
+ * user is currently looking at.
+ * @returns {Promise<ISendEventDetails>} Resolves when the event has been sent with
+ * details of that event.
+ * @throws Rejected when the event could not be sent.
+ */
+ }, {
+ key: "sendEvent",
+ value: function sendEvent(eventType, content) {
+ var stateKey = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null;
+ var roomId = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
+ return Promise.reject(new Error("Failed to override function"));
+ }
+
+ /**
+ * Sends a to-device event. The widget API will have already verified that the widget
+ * is capable of sending the event.
+ * @param {string} eventType The event type to be sent.
+ * @param {boolean} encrypted Whether to encrypt the message contents.
+ * @param {Object} contentMap A map from user ID and device ID to event content.
+ * @returns {Promise<void>} Resolves when the event has been sent.
+ * @throws Rejected when the event could not be sent.
+ */
+ }, {
+ key: "sendToDevice",
+ value: function sendToDevice(eventType, encrypted, contentMap) {
+ return Promise.reject(new Error("Failed to override function"));
+ }
+
+ /**
+ * Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
+ * the user has access to. The widget API will have already verified that the widget is
+ * capable of receiving the events. Less events than the limit are allowed to be returned,
+ * but not more. If `roomIds` is supplied, it may contain `Symbols.AnyRoom` to denote that
+ * `limit` in each of the client's known rooms should be returned. When `null`, only the
+ * room the user is currently looking at should be considered.
+ * @param eventType The event type to be read.
+ * @param msgtype The msgtype of the events to be read, if applicable/defined.
+ * @param limit The maximum number of events to retrieve per room. Will be zero to denote "as many
+ * as possible".
+ * @param roomIds When null, the user's currently viewed room. Otherwise, the list of room IDs
+ * to look within, possibly containing Symbols.AnyRoom to denote all known rooms.
+ * @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
+ */
+ }, {
+ key: "readRoomEvents",
+ value: function readRoomEvents(eventType, msgtype, limit) {
+ var roomIds = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
+ return Promise.resolve([]);
+ }
+
+ /**
+ * Reads all events of the given type, and optionally state key (if applicable/defined),
+ * the user has access to. The widget API will have already verified that the widget is
+ * capable of receiving the events. Less events than the limit are allowed to be returned,
+ * but not more. If `roomIds` is supplied, it may contain `Symbols.AnyRoom` to denote that
+ * `limit` in each of the client's known rooms should be returned. When `null`, only the
+ * room the user is currently looking at should be considered.
+ * @param eventType The event type to be read.
+ * @param stateKey The state key of the events to be read, if applicable/defined.
+ * @param limit The maximum number of events to retrieve. Will be zero to denote "as many
+ * as possible".
+ * @param roomIds When null, the user's currently viewed room. Otherwise, the list of room IDs
+ * to look within, possibly containing Symbols.AnyRoom to denote all known rooms.
+ * @returns {Promise<IRoomEvent[]>} Resolves to the state events, or an empty array.
+ */
+ }, {
+ key: "readStateEvents",
+ value: function readStateEvents(eventType, stateKey, limit) {
+ var roomIds = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;
+ return Promise.resolve([]);
+ }
+
+ /**
+ * Reads all events that are related to a given event. The widget API will
+ * have already verified that the widget is capable of receiving the event,
+ * or will make sure to reject access to events which are returned from this
+ * function, but are not capable of receiving. If `relationType` or `eventType`
+ * are set, the returned events should already be filtered. Less events than
+ * the limit are allowed to be returned, but not more.
+ * @param eventId The id of the parent event to be read.
+ * @param roomId The room to look within. When undefined, the user's
+ * currently viewed room.
+ * @param relationType The relationship type of child events to search for.
+ * When undefined, all relations are returned.
+ * @param eventType The event type of child events to search for. When undefined,
+ * all related events are returned.
+ * @param from The pagination token to start returning results from, as
+ * received from a previous call. If not supplied, results start at the most
+ * recent topological event known to the server.
+ * @param to The pagination token to stop returning results at. If not
+ * supplied, results continue up to limit or until there are no more events.
+ * @param limit The maximum number of events to retrieve per room. If not
+ * supplied, the server will apply a default limit.
+ * @param direction The direction to search for according to MSC3715
+ * @returns Resolves to the room relations.
+ */
+ }, {
+ key: "readEventRelations",
+ value: function readEventRelations(eventId, roomId, relationType, eventType, from, to, limit, direction) {
+ return Promise.resolve({
+ chunk: []
+ });
+ }
+
+ /**
+ * Asks the user for permission to validate their identity through OpenID Connect. The
+ * interface for this function is an observable which accepts the state machine of the
+ * OIDC exchange flow. For example, if the client/user blocks the request then it would
+ * feed back a `{state: Blocked}` into the observable. Similarly, if the user already
+ * approved the widget then a `{state: Allowed}` would be fed into the observable alongside
+ * the token itself. If the client is asking for permission, it should feed in a
+ * `{state: PendingUserConfirmation}` followed by the relevant Allowed or Blocked state.
+ *
+ * The widget API will reject the widget's request with an error if this contract is not
+ * met properly. By default, the widget driver will block all OIDC requests.
+ * @param {SimpleObservable<IOpenIDUpdate>} observer The observable to feed updates into.
+ */
+ }, {
+ key: "askOpenID",
+ value: function askOpenID(observer) {
+ observer.update({
+ state: _.OpenIDRequestState.Blocked
+ });
+ }
+
+ /**
+ * Navigates the client with a matrix.to URI. In future this function will also be provided
+ * with the Matrix URIs once matrix.to is replaced. The given URI will have already been
+ * lightly checked to ensure it looks like a valid URI, though the implementation is recommended
+ * to do further checks on the URI.
+ * @param {string} uri The URI to navigate to.
+ * @returns {Promise<void>} Resolves when complete.
+ * @throws Throws if there's a problem with the navigation, such as invalid format.
+ */
+ }, {
+ key: "navigate",
+ value: function navigate(uri) {
+ throw new Error("Navigation is not implemented");
+ }
+
+ /**
+ * Polls for TURN server data, yielding an initial set of credentials as soon as possible, and
+ * thereafter yielding new credentials whenever the previous ones expire. The widget API will
+ * have already verified that the widget has permission to access TURN servers.
+ * @yields {ITurnServer} The TURN server URIs and credentials currently available to the client.
+ */
+ }, {
+ key: "getTurnServers",
+ value: function getTurnServers() {
+ throw new Error("TURN server support is not implemented");
+ }
+
+ /**
+ * Search for users in the user directory.
+ * @param searchTerm The term to search for.
+ * @param limit The maximum number of results to return. If not supplied, the
+ * @returns Resolves to the search results.
+ */
+ }, {
+ key: "searchUserDirectory",
+ value: function searchUserDirectory(searchTerm, limit) {
+ return Promise.resolve({
+ limited: false,
+ results: []
+ });
+ }
+ }]);
+ return WidgetDriver;
+}();
+exports.WidgetDriver = WidgetDriver;
+//# sourceMappingURL=WidgetDriver.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/index.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/index.js
new file mode 100644
index 0000000000..20eb378a7b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/index.js
@@ -0,0 +1,512 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+var _WidgetApi = require("./WidgetApi");
+Object.keys(_WidgetApi).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetApi[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetApi[key];
+ }
+ });
+});
+var _ClientWidgetApi = require("./ClientWidgetApi");
+Object.keys(_ClientWidgetApi).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ClientWidgetApi[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ClientWidgetApi[key];
+ }
+ });
+});
+var _Symbols = require("./Symbols");
+Object.keys(_Symbols).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _Symbols[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _Symbols[key];
+ }
+ });
+});
+var _ITransport = require("./transport/ITransport");
+Object.keys(_ITransport).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ITransport[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ITransport[key];
+ }
+ });
+});
+var _PostmessageTransport = require("./transport/PostmessageTransport");
+Object.keys(_PostmessageTransport).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _PostmessageTransport[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _PostmessageTransport[key];
+ }
+ });
+});
+var _ICustomWidgetData = require("./interfaces/ICustomWidgetData");
+Object.keys(_ICustomWidgetData).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ICustomWidgetData[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ICustomWidgetData[key];
+ }
+ });
+});
+var _IJitsiWidgetData = require("./interfaces/IJitsiWidgetData");
+Object.keys(_IJitsiWidgetData).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IJitsiWidgetData[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IJitsiWidgetData[key];
+ }
+ });
+});
+var _IStickerpickerWidgetData = require("./interfaces/IStickerpickerWidgetData");
+Object.keys(_IStickerpickerWidgetData).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IStickerpickerWidgetData[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IStickerpickerWidgetData[key];
+ }
+ });
+});
+var _IWidget = require("./interfaces/IWidget");
+Object.keys(_IWidget).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IWidget[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IWidget[key];
+ }
+ });
+});
+var _WidgetType = require("./interfaces/WidgetType");
+Object.keys(_WidgetType).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetType[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetType[key];
+ }
+ });
+});
+var _IWidgetApiErrorResponse = require("./interfaces/IWidgetApiErrorResponse");
+Object.keys(_IWidgetApiErrorResponse).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IWidgetApiErrorResponse[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IWidgetApiErrorResponse[key];
+ }
+ });
+});
+var _IWidgetApiRequest = require("./interfaces/IWidgetApiRequest");
+Object.keys(_IWidgetApiRequest).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IWidgetApiRequest[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IWidgetApiRequest[key];
+ }
+ });
+});
+var _IWidgetApiResponse = require("./interfaces/IWidgetApiResponse");
+Object.keys(_IWidgetApiResponse).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IWidgetApiResponse[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IWidgetApiResponse[key];
+ }
+ });
+});
+var _WidgetApiAction = require("./interfaces/WidgetApiAction");
+Object.keys(_WidgetApiAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetApiAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetApiAction[key];
+ }
+ });
+});
+var _WidgetApiDirection = require("./interfaces/WidgetApiDirection");
+Object.keys(_WidgetApiDirection).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetApiDirection[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetApiDirection[key];
+ }
+ });
+});
+var _ApiVersion = require("./interfaces/ApiVersion");
+Object.keys(_ApiVersion).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ApiVersion[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ApiVersion[key];
+ }
+ });
+});
+var _Capabilities = require("./interfaces/Capabilities");
+Object.keys(_Capabilities).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _Capabilities[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _Capabilities[key];
+ }
+ });
+});
+var _CapabilitiesAction = require("./interfaces/CapabilitiesAction");
+Object.keys(_CapabilitiesAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _CapabilitiesAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _CapabilitiesAction[key];
+ }
+ });
+});
+var _ContentLoadedAction = require("./interfaces/ContentLoadedAction");
+Object.keys(_ContentLoadedAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ContentLoadedAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ContentLoadedAction[key];
+ }
+ });
+});
+var _ScreenshotAction = require("./interfaces/ScreenshotAction");
+Object.keys(_ScreenshotAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ScreenshotAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ScreenshotAction[key];
+ }
+ });
+});
+var _StickerAction = require("./interfaces/StickerAction");
+Object.keys(_StickerAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _StickerAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _StickerAction[key];
+ }
+ });
+});
+var _StickyAction = require("./interfaces/StickyAction");
+Object.keys(_StickyAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _StickyAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _StickyAction[key];
+ }
+ });
+});
+var _SupportedVersionsAction = require("./interfaces/SupportedVersionsAction");
+Object.keys(_SupportedVersionsAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _SupportedVersionsAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _SupportedVersionsAction[key];
+ }
+ });
+});
+var _VisibilityAction = require("./interfaces/VisibilityAction");
+Object.keys(_VisibilityAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _VisibilityAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _VisibilityAction[key];
+ }
+ });
+});
+var _GetOpenIDAction = require("./interfaces/GetOpenIDAction");
+Object.keys(_GetOpenIDAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _GetOpenIDAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _GetOpenIDAction[key];
+ }
+ });
+});
+var _OpenIDCredentialsAction = require("./interfaces/OpenIDCredentialsAction");
+Object.keys(_OpenIDCredentialsAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _OpenIDCredentialsAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _OpenIDCredentialsAction[key];
+ }
+ });
+});
+var _WidgetKind = require("./interfaces/WidgetKind");
+Object.keys(_WidgetKind).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetKind[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetKind[key];
+ }
+ });
+});
+var _ModalButtonKind = require("./interfaces/ModalButtonKind");
+Object.keys(_ModalButtonKind).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ModalButtonKind[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ModalButtonKind[key];
+ }
+ });
+});
+var _ModalWidgetActions = require("./interfaces/ModalWidgetActions");
+Object.keys(_ModalWidgetActions).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ModalWidgetActions[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ModalWidgetActions[key];
+ }
+ });
+});
+var _SetModalButtonEnabledAction = require("./interfaces/SetModalButtonEnabledAction");
+Object.keys(_SetModalButtonEnabledAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _SetModalButtonEnabledAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _SetModalButtonEnabledAction[key];
+ }
+ });
+});
+var _WidgetConfigAction = require("./interfaces/WidgetConfigAction");
+Object.keys(_WidgetConfigAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetConfigAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetConfigAction[key];
+ }
+ });
+});
+var _SendEventAction = require("./interfaces/SendEventAction");
+Object.keys(_SendEventAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _SendEventAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _SendEventAction[key];
+ }
+ });
+});
+var _SendToDeviceAction = require("./interfaces/SendToDeviceAction");
+Object.keys(_SendToDeviceAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _SendToDeviceAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _SendToDeviceAction[key];
+ }
+ });
+});
+var _ReadEventAction = require("./interfaces/ReadEventAction");
+Object.keys(_ReadEventAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ReadEventAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ReadEventAction[key];
+ }
+ });
+});
+var _IRoomEvent = require("./interfaces/IRoomEvent");
+Object.keys(_IRoomEvent).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _IRoomEvent[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _IRoomEvent[key];
+ }
+ });
+});
+var _NavigateAction = require("./interfaces/NavigateAction");
+Object.keys(_NavigateAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _NavigateAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _NavigateAction[key];
+ }
+ });
+});
+var _TurnServerActions = require("./interfaces/TurnServerActions");
+Object.keys(_TurnServerActions).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _TurnServerActions[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _TurnServerActions[key];
+ }
+ });
+});
+var _ReadRelationsAction = require("./interfaces/ReadRelationsAction");
+Object.keys(_ReadRelationsAction).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _ReadRelationsAction[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _ReadRelationsAction[key];
+ }
+ });
+});
+var _WidgetEventCapability = require("./models/WidgetEventCapability");
+Object.keys(_WidgetEventCapability).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetEventCapability[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetEventCapability[key];
+ }
+ });
+});
+var _url = require("./models/validation/url");
+Object.keys(_url).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _url[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _url[key];
+ }
+ });
+});
+var _utils = require("./models/validation/utils");
+Object.keys(_utils).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _utils[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _utils[key];
+ }
+ });
+});
+var _Widget = require("./models/Widget");
+Object.keys(_Widget).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _Widget[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _Widget[key];
+ }
+ });
+});
+var _WidgetParser = require("./models/WidgetParser");
+Object.keys(_WidgetParser).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetParser[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetParser[key];
+ }
+ });
+});
+var _urlTemplate = require("./templating/url-template");
+Object.keys(_urlTemplate).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _urlTemplate[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _urlTemplate[key];
+ }
+ });
+});
+var _SimpleObservable = require("./util/SimpleObservable");
+Object.keys(_SimpleObservable).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _SimpleObservable[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _SimpleObservable[key];
+ }
+ });
+});
+var _WidgetDriver = require("./driver/WidgetDriver");
+Object.keys(_WidgetDriver).forEach(function (key) {
+ if (key === "default" || key === "__esModule") return;
+ if (key in exports && exports[key] === _WidgetDriver[key]) return;
+ Object.defineProperty(exports, key, {
+ enumerable: true,
+ get: function get() {
+ return _WidgetDriver[key];
+ }
+ });
+});
+//# sourceMappingURL=index.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ApiVersion.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ApiVersion.js
new file mode 100644
index 0000000000..c685445807
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ApiVersion.js
@@ -0,0 +1,45 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.UnstableApiVersion = exports.MatrixApiVersion = exports.CurrentApiVersions = void 0;
+/*
+ * 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.
+ */
+var MatrixApiVersion = /*#__PURE__*/function (MatrixApiVersion) {
+ MatrixApiVersion["Prerelease1"] = "0.0.1";
+ MatrixApiVersion["Prerelease2"] = "0.0.2";
+ return MatrixApiVersion;
+}({}); //V010 = "0.1.0", // first release
+exports.MatrixApiVersion = MatrixApiVersion;
+var UnstableApiVersion = /*#__PURE__*/function (UnstableApiVersion) {
+ UnstableApiVersion["MSC2762"] = "org.matrix.msc2762";
+ UnstableApiVersion["MSC2871"] = "org.matrix.msc2871";
+ UnstableApiVersion["MSC2931"] = "org.matrix.msc2931";
+ UnstableApiVersion["MSC2974"] = "org.matrix.msc2974";
+ UnstableApiVersion["MSC2876"] = "org.matrix.msc2876";
+ UnstableApiVersion["MSC3819"] = "org.matrix.msc3819";
+ UnstableApiVersion["MSC3846"] = "town.robin.msc3846";
+ UnstableApiVersion["MSC3869"] = "org.matrix.msc3869";
+ UnstableApiVersion["MSC3973"] = "org.matrix.msc3973";
+ return UnstableApiVersion;
+}({});
+exports.UnstableApiVersion = UnstableApiVersion;
+var CurrentApiVersions = [MatrixApiVersion.Prerelease1, MatrixApiVersion.Prerelease2,
+//MatrixApiVersion.V010,
+UnstableApiVersion.MSC2762, UnstableApiVersion.MSC2871, UnstableApiVersion.MSC2931, UnstableApiVersion.MSC2974, UnstableApiVersion.MSC2876, UnstableApiVersion.MSC3819, UnstableApiVersion.MSC3846, UnstableApiVersion.MSC3869, UnstableApiVersion.MSC3973];
+exports.CurrentApiVersions = CurrentApiVersions;
+//# sourceMappingURL=ApiVersion.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/Capabilities.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/Capabilities.js
new file mode 100644
index 0000000000..ce695ffcc7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/Capabilities.js
@@ -0,0 +1,69 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.VideoConferenceCapabilities = exports.StickerpickerCapabilities = exports.MatrixCapabilities = void 0;
+exports.getTimelineRoomIDFromCapability = getTimelineRoomIDFromCapability;
+exports.isTimelineCapability = isTimelineCapability;
+exports.isTimelineCapabilityFor = isTimelineCapabilityFor;
+/*
+ * 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.
+ */
+var MatrixCapabilities = /*#__PURE__*/function (MatrixCapabilities) {
+ MatrixCapabilities["Screenshots"] = "m.capability.screenshot";
+ MatrixCapabilities["StickerSending"] = "m.sticker";
+ MatrixCapabilities["AlwaysOnScreen"] = "m.always_on_screen";
+ MatrixCapabilities["RequiresClient"] = "io.element.requires_client";
+ MatrixCapabilities["MSC2931Navigate"] = "org.matrix.msc2931.navigate";
+ MatrixCapabilities["MSC3846TurnServers"] = "town.robin.msc3846.turn_servers";
+ MatrixCapabilities["MSC3973UserDirectorySearch"] = "org.matrix.msc3973.user_directory_search";
+ return MatrixCapabilities;
+}({});
+exports.MatrixCapabilities = MatrixCapabilities;
+var StickerpickerCapabilities = [MatrixCapabilities.StickerSending];
+exports.StickerpickerCapabilities = StickerpickerCapabilities;
+var VideoConferenceCapabilities = [MatrixCapabilities.AlwaysOnScreen];
+
+/**
+ * Determines if a capability is a capability for a timeline.
+ * @param {Capability} capability The capability to test.
+ * @returns {boolean} True if a timeline capability, false otherwise.
+ */
+exports.VideoConferenceCapabilities = VideoConferenceCapabilities;
+function isTimelineCapability(capability) {
+ // TODO: Change when MSC2762 becomes stable.
+ return capability === null || capability === void 0 ? void 0 : capability.startsWith("org.matrix.msc2762.timeline:");
+}
+
+/**
+ * Determines if a capability is a timeline capability for the given room.
+ * @param {Capability} capability The capability to test.
+ * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` for that designation.
+ * @returns {boolean} True if a matching capability, false otherwise.
+ */
+function isTimelineCapabilityFor(capability, roomId) {
+ return capability === "org.matrix.msc2762.timeline:".concat(roomId);
+}
+
+/**
+ * Gets the room ID described by a timeline capability.
+ * @param {string} capability The capability to parse.
+ * @returns {string} The room ID.
+ */
+function getTimelineRoomIDFromCapability(capability) {
+ return capability.substring(capability.indexOf(":") + 1);
+}
+//# sourceMappingURL=Capabilities.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/CapabilitiesAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/CapabilitiesAction.js
new file mode 100644
index 0000000000..c6ce50b3be
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/CapabilitiesAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=CapabilitiesAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ContentLoadedAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ContentLoadedAction.js
new file mode 100644
index 0000000000..65d778b468
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ContentLoadedAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ContentLoadedAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/GetOpenIDAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/GetOpenIDAction.js
new file mode 100644
index 0000000000..45cd3d90e1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/GetOpenIDAction.js
@@ -0,0 +1,29 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.OpenIDRequestState = void 0;
+/*
+ * 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.
+ */
+var OpenIDRequestState = /*#__PURE__*/function (OpenIDRequestState) {
+ OpenIDRequestState["Allowed"] = "allowed";
+ OpenIDRequestState["Blocked"] = "blocked";
+ OpenIDRequestState["PendingUserConfirmation"] = "request";
+ return OpenIDRequestState;
+}({});
+exports.OpenIDRequestState = OpenIDRequestState;
+//# sourceMappingURL=GetOpenIDAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ICustomWidgetData.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ICustomWidgetData.js
new file mode 100644
index 0000000000..8edf8d96a2
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ICustomWidgetData.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ICustomWidgetData.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IJitsiWidgetData.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IJitsiWidgetData.js
new file mode 100644
index 0000000000..c63beb0538
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IJitsiWidgetData.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IJitsiWidgetData.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IRoomEvent.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IRoomEvent.js
new file mode 100644
index 0000000000..44478222d0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IRoomEvent.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IRoomEvent.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IStickerpickerWidgetData.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IStickerpickerWidgetData.js
new file mode 100644
index 0000000000..4085bb0a5e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IStickerpickerWidgetData.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IStickerpickerWidgetData.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidget.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidget.js
new file mode 100644
index 0000000000..d7e607baa7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidget.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IWidget.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiErrorResponse.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiErrorResponse.js
new file mode 100644
index 0000000000..c3168d40b4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiErrorResponse.js
@@ -0,0 +1,30 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isErrorResponse = isErrorResponse;
+/*
+ * 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.
+ */
+
+function isErrorResponse(responseData) {
+ if ("error" in responseData) {
+ var err = responseData;
+ return !!err.error.message;
+ }
+ return false;
+}
+//# sourceMappingURL=IWidgetApiErrorResponse.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiRequest.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiRequest.js
new file mode 100644
index 0000000000..4596846264
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiRequest.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IWidgetApiRequest.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiResponse.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiResponse.js
new file mode 100644
index 0000000000..3324d18dd7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiResponse.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=IWidgetApiResponse.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalButtonKind.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalButtonKind.js
new file mode 100644
index 0000000000..4fb8c0ee1a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalButtonKind.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.ModalButtonKind = void 0;
+/*
+ * 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.
+ */
+var ModalButtonKind = /*#__PURE__*/function (ModalButtonKind) {
+ ModalButtonKind["Primary"] = "m.primary";
+ ModalButtonKind["Secondary"] = "m.secondary";
+ ModalButtonKind["Warning"] = "m.warning";
+ ModalButtonKind["Danger"] = "m.danger";
+ ModalButtonKind["Link"] = "m.link";
+ return ModalButtonKind;
+}({});
+exports.ModalButtonKind = ModalButtonKind;
+//# sourceMappingURL=ModalButtonKind.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalWidgetActions.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalWidgetActions.js
new file mode 100644
index 0000000000..ab9ddb8e3f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalWidgetActions.js
@@ -0,0 +1,30 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.BuiltInModalButtonID = void 0;
+/*
+ * 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.
+ */
+var BuiltInModalButtonID = /*#__PURE__*/function (BuiltInModalButtonID) {
+ BuiltInModalButtonID["Close"] = "m.close";
+ return BuiltInModalButtonID;
+}({}); // Types for a normal modal requesting the opening a modal widget
+// Types for a modal widget receiving notifications that its buttons have been pressed
+// Types for a modal widget requesting close
+// Types for a normal widget being notified that the modal widget it opened has been closed
+exports.BuiltInModalButtonID = BuiltInModalButtonID;
+//# sourceMappingURL=ModalWidgetActions.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/NavigateAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/NavigateAction.js
new file mode 100644
index 0000000000..8ea051aa5f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/NavigateAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=NavigateAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/OpenIDCredentialsAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/OpenIDCredentialsAction.js
new file mode 100644
index 0000000000..7b56879ff0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/OpenIDCredentialsAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=OpenIDCredentialsAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadEventAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadEventAction.js
new file mode 100644
index 0000000000..4402d229b1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadEventAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ReadEventAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadRelationsAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadRelationsAction.js
new file mode 100644
index 0000000000..a655252d6d
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadRelationsAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ReadRelationsAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ScreenshotAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ScreenshotAction.js
new file mode 100644
index 0000000000..5f51dc8cf7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ScreenshotAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ScreenshotAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendEventAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendEventAction.js
new file mode 100644
index 0000000000..acad463560
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendEventAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=SendEventAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendToDeviceAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendToDeviceAction.js
new file mode 100644
index 0000000000..af55b1fa15
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendToDeviceAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=SendToDeviceAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SetModalButtonEnabledAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SetModalButtonEnabledAction.js
new file mode 100644
index 0000000000..fedab38cb1
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SetModalButtonEnabledAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=SetModalButtonEnabledAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickerAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickerAction.js
new file mode 100644
index 0000000000..004ea7f47e
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickerAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=StickerAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickyAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickyAction.js
new file mode 100644
index 0000000000..05f58f7105
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickyAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=StickyAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SupportedVersionsAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SupportedVersionsAction.js
new file mode 100644
index 0000000000..9faeccab03
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SupportedVersionsAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=SupportedVersionsAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/TurnServerActions.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/TurnServerActions.js
new file mode 100644
index 0000000000..01f7c8f98b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/TurnServerActions.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=TurnServerActions.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/UserDirectorySearchAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/UserDirectorySearchAction.js
new file mode 100644
index 0000000000..f051b1a48a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/UserDirectorySearchAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=UserDirectorySearchAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/VisibilityAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/VisibilityAction.js
new file mode 100644
index 0000000000..cc4c9902ad
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/VisibilityAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=VisibilityAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiAction.js
new file mode 100644
index 0000000000..f7e644bec4
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiAction.js
@@ -0,0 +1,59 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetApiToWidgetAction = exports.WidgetApiFromWidgetAction = void 0;
+/*
+ * 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.
+ */
+var WidgetApiToWidgetAction = /*#__PURE__*/function (WidgetApiToWidgetAction) {
+ WidgetApiToWidgetAction["SupportedApiVersions"] = "supported_api_versions";
+ WidgetApiToWidgetAction["Capabilities"] = "capabilities";
+ WidgetApiToWidgetAction["NotifyCapabilities"] = "notify_capabilities";
+ WidgetApiToWidgetAction["TakeScreenshot"] = "screenshot";
+ WidgetApiToWidgetAction["UpdateVisibility"] = "visibility";
+ WidgetApiToWidgetAction["OpenIDCredentials"] = "openid_credentials";
+ WidgetApiToWidgetAction["WidgetConfig"] = "widget_config";
+ WidgetApiToWidgetAction["CloseModalWidget"] = "close_modal";
+ WidgetApiToWidgetAction["ButtonClicked"] = "button_clicked";
+ WidgetApiToWidgetAction["SendEvent"] = "send_event";
+ WidgetApiToWidgetAction["SendToDevice"] = "send_to_device";
+ WidgetApiToWidgetAction["UpdateTurnServers"] = "update_turn_servers";
+ return WidgetApiToWidgetAction;
+}({});
+exports.WidgetApiToWidgetAction = WidgetApiToWidgetAction;
+var WidgetApiFromWidgetAction = /*#__PURE__*/function (WidgetApiFromWidgetAction) {
+ WidgetApiFromWidgetAction["SupportedApiVersions"] = "supported_api_versions";
+ WidgetApiFromWidgetAction["ContentLoaded"] = "content_loaded";
+ WidgetApiFromWidgetAction["SendSticker"] = "m.sticker";
+ WidgetApiFromWidgetAction["UpdateAlwaysOnScreen"] = "set_always_on_screen";
+ WidgetApiFromWidgetAction["GetOpenIDCredentials"] = "get_openid";
+ WidgetApiFromWidgetAction["CloseModalWidget"] = "close_modal";
+ WidgetApiFromWidgetAction["OpenModalWidget"] = "open_modal";
+ WidgetApiFromWidgetAction["SetModalButtonEnabled"] = "set_button_enabled";
+ WidgetApiFromWidgetAction["SendEvent"] = "send_event";
+ WidgetApiFromWidgetAction["SendToDevice"] = "send_to_device";
+ WidgetApiFromWidgetAction["WatchTurnServers"] = "watch_turn_servers";
+ WidgetApiFromWidgetAction["UnwatchTurnServers"] = "unwatch_turn_servers";
+ WidgetApiFromWidgetAction["MSC2876ReadEvents"] = "org.matrix.msc2876.read_events";
+ WidgetApiFromWidgetAction["MSC2931Navigate"] = "org.matrix.msc2931.navigate";
+ WidgetApiFromWidgetAction["MSC2974RenegotiateCapabilities"] = "org.matrix.msc2974.request_capabilities";
+ WidgetApiFromWidgetAction["MSC3869ReadRelations"] = "org.matrix.msc3869.read_relations";
+ WidgetApiFromWidgetAction["MSC3973UserDirectorySearch"] = "org.matrix.msc3973.user_directory_search";
+ return WidgetApiFromWidgetAction;
+}({});
+exports.WidgetApiFromWidgetAction = WidgetApiFromWidgetAction;
+//# sourceMappingURL=WidgetApiAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiDirection.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiDirection.js
new file mode 100644
index 0000000000..69a6ea679c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiDirection.js
@@ -0,0 +1,38 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetApiDirection = void 0;
+exports.invertedDirection = invertedDirection;
+/*
+ * 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.
+ */
+var WidgetApiDirection = /*#__PURE__*/function (WidgetApiDirection) {
+ WidgetApiDirection["ToWidget"] = "toWidget";
+ WidgetApiDirection["FromWidget"] = "fromWidget";
+ return WidgetApiDirection;
+}({});
+exports.WidgetApiDirection = WidgetApiDirection;
+function invertedDirection(dir) {
+ if (dir === WidgetApiDirection.ToWidget) {
+ return WidgetApiDirection.FromWidget;
+ } else if (dir === WidgetApiDirection.FromWidget) {
+ return WidgetApiDirection.ToWidget;
+ } else {
+ throw new Error("Invalid direction");
+ }
+}
+//# sourceMappingURL=WidgetApiDirection.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetConfigAction.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetConfigAction.js
new file mode 100644
index 0000000000..821abebdc0
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetConfigAction.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=WidgetConfigAction.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetKind.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetKind.js
new file mode 100644
index 0000000000..2d552e9c17
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetKind.js
@@ -0,0 +1,29 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetKind = void 0;
+/*
+ * 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.
+ */
+var WidgetKind = /*#__PURE__*/function (WidgetKind) {
+ WidgetKind["Room"] = "room";
+ WidgetKind["Account"] = "account";
+ WidgetKind["Modal"] = "modal";
+ return WidgetKind;
+}({});
+exports.WidgetKind = WidgetKind;
+//# sourceMappingURL=WidgetKind.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetType.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetType.js
new file mode 100644
index 0000000000..7eb82917a7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetType.js
@@ -0,0 +1,29 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.MatrixWidgetType = void 0;
+/*
+ * 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.
+ */
+var MatrixWidgetType = /*#__PURE__*/function (MatrixWidgetType) {
+ MatrixWidgetType["Custom"] = "m.custom";
+ MatrixWidgetType["JitsiMeet"] = "m.jitsi";
+ MatrixWidgetType["Stickerpicker"] = "m.stickerpicker";
+ return MatrixWidgetType;
+}({});
+exports.MatrixWidgetType = MatrixWidgetType;
+//# sourceMappingURL=WidgetType.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/models/Widget.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/Widget.js
new file mode 100644
index 0000000000..57d1e4a3b3
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/Widget.js
@@ -0,0 +1,142 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.Widget = void 0;
+var _utils = require("./validation/utils");
+var _ = require("..");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ * 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.
+ */
+/**
+ * Represents the barest form of widget.
+ */
+var Widget = /*#__PURE__*/function () {
+ function Widget(definition) {
+ _classCallCheck(this, Widget);
+ this.definition = definition;
+ if (!this.definition) throw new Error("Definition is required");
+ (0, _utils.assertPresent)(definition, "id");
+ (0, _utils.assertPresent)(definition, "creatorUserId");
+ (0, _utils.assertPresent)(definition, "type");
+ (0, _utils.assertPresent)(definition, "url");
+ }
+
+ /**
+ * The user ID who created the widget.
+ */
+ _createClass(Widget, [{
+ key: "creatorUserId",
+ get: function get() {
+ return this.definition.creatorUserId;
+ }
+
+ /**
+ * The type of widget.
+ */
+ }, {
+ key: "type",
+ get: function get() {
+ return this.definition.type;
+ }
+
+ /**
+ * The ID of the widget.
+ */
+ }, {
+ key: "id",
+ get: function get() {
+ return this.definition.id;
+ }
+
+ /**
+ * The name of the widget, or null if not set.
+ */
+ }, {
+ key: "name",
+ get: function get() {
+ return this.definition.name || null;
+ }
+
+ /**
+ * The title for the widget, or null if not set.
+ */
+ }, {
+ key: "title",
+ get: function get() {
+ return this.rawData.title || null;
+ }
+
+ /**
+ * The templated URL for the widget.
+ */
+ }, {
+ key: "templateUrl",
+ get: function get() {
+ return this.definition.url;
+ }
+
+ /**
+ * The origin for this widget.
+ */
+ }, {
+ key: "origin",
+ get: function get() {
+ return new URL(this.templateUrl).origin;
+ }
+
+ /**
+ * Whether or not the client should wait for the iframe to load. Defaults
+ * to true.
+ */
+ }, {
+ key: "waitForIframeLoad",
+ get: function get() {
+ if (this.definition.waitForIframeLoad === false) return false;
+ if (this.definition.waitForIframeLoad === true) return true;
+ return true; // default true
+ }
+
+ /**
+ * The raw data for the widget. This will always be defined, though
+ * may be empty.
+ */
+ }, {
+ key: "rawData",
+ get: function get() {
+ return this.definition.data || {};
+ }
+
+ /**
+ * Gets a complete widget URL for the client to render.
+ * @param {ITemplateParams} params The template parameters.
+ * @returns {string} A templated URL.
+ */
+ }, {
+ key: "getCompleteUrl",
+ value: function getCompleteUrl(params) {
+ return (0, _.runTemplate)(this.templateUrl, this.definition, params);
+ }
+ }]);
+ return Widget;
+}();
+exports.Widget = Widget;
+//# sourceMappingURL=Widget.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetEventCapability.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetEventCapability.js
new file mode 100644
index 0000000000..e0738905b9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetEventCapability.js
@@ -0,0 +1,237 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetEventCapability = exports.EventKind = exports.EventDirection = void 0;
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+ * 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.
+ */
+var EventKind = /*#__PURE__*/function (EventKind) {
+ EventKind["Event"] = "event";
+ EventKind["State"] = "state_event";
+ EventKind["ToDevice"] = "to_device";
+ return EventKind;
+}({});
+exports.EventKind = EventKind;
+var EventDirection = /*#__PURE__*/function (EventDirection) {
+ EventDirection["Send"] = "send";
+ EventDirection["Receive"] = "receive";
+ return EventDirection;
+}({});
+exports.EventDirection = EventDirection;
+var WidgetEventCapability = /*#__PURE__*/function () {
+ function WidgetEventCapability(direction, eventType, kind, keyStr, raw) {
+ _classCallCheck(this, WidgetEventCapability);
+ this.direction = direction;
+ this.eventType = eventType;
+ this.kind = kind;
+ this.keyStr = keyStr;
+ this.raw = raw;
+ }
+ _createClass(WidgetEventCapability, [{
+ key: "matchesAsStateEvent",
+ value: function matchesAsStateEvent(direction, eventType, stateKey) {
+ if (this.kind !== EventKind.State) return false; // not a state event
+ if (this.direction !== direction) return false; // direction mismatch
+ if (this.eventType !== eventType) return false; // event type mismatch
+ if (this.keyStr === null) return true; // all state keys are allowed
+ if (this.keyStr === stateKey) return true; // this state key is allowed
+
+ // Default not allowed
+ return false;
+ }
+ }, {
+ key: "matchesAsToDeviceEvent",
+ value: function matchesAsToDeviceEvent(direction, eventType) {
+ if (this.kind !== EventKind.ToDevice) return false; // not a to-device event
+ if (this.direction !== direction) return false; // direction mismatch
+ if (this.eventType !== eventType) return false; // event type mismatch
+
+ // Checks passed, the event is allowed
+ return true;
+ }
+ }, {
+ key: "matchesAsRoomEvent",
+ value: function matchesAsRoomEvent(direction, eventType) {
+ var msgtype = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null;
+ if (this.kind !== EventKind.Event) return false; // not a room event
+ if (this.direction !== direction) return false; // direction mismatch
+ if (this.eventType !== eventType) return false; // event type mismatch
+
+ if (this.eventType === "m.room.message") {
+ if (this.keyStr === null) return true; // all message types are allowed
+ if (this.keyStr === msgtype) return true; // this message type is allowed
+ } else {
+ return true; // already passed the check for if the event is allowed
+ }
+
+ // Default not allowed
+ return false;
+ }
+ }], [{
+ key: "forStateEvent",
+ value: function forStateEvent(direction, eventType, stateKey) {
+ // TODO: Enable support for m.* namespace once the MSC lands.
+ // https://github.com/matrix-org/matrix-widget-api/issues/22
+ eventType = eventType.replace(/#/g, '\\#');
+ stateKey = stateKey !== null && stateKey !== undefined ? "#".concat(stateKey) : '';
+ var str = "org.matrix.msc2762.".concat(direction, ".state_event:").concat(eventType).concat(stateKey);
+
+ // cheat by sending it through the processor
+ return WidgetEventCapability.findEventCapabilities([str])[0];
+ }
+ }, {
+ key: "forToDeviceEvent",
+ value: function forToDeviceEvent(direction, eventType) {
+ // TODO: Enable support for m.* namespace once the MSC lands.
+ // https://github.com/matrix-org/matrix-widget-api/issues/56
+ var str = "org.matrix.msc3819.".concat(direction, ".to_device:").concat(eventType);
+
+ // cheat by sending it through the processor
+ return WidgetEventCapability.findEventCapabilities([str])[0];
+ }
+ }, {
+ key: "forRoomEvent",
+ value: function forRoomEvent(direction, eventType) {
+ // TODO: Enable support for m.* namespace once the MSC lands.
+ // https://github.com/matrix-org/matrix-widget-api/issues/22
+ var str = "org.matrix.msc2762.".concat(direction, ".event:").concat(eventType);
+
+ // cheat by sending it through the processor
+ return WidgetEventCapability.findEventCapabilities([str])[0];
+ }
+ }, {
+ key: "forRoomMessageEvent",
+ value: function forRoomMessageEvent(direction, msgtype) {
+ // TODO: Enable support for m.* namespace once the MSC lands.
+ // https://github.com/matrix-org/matrix-widget-api/issues/22
+ msgtype = msgtype === null || msgtype === undefined ? '' : msgtype;
+ var str = "org.matrix.msc2762.".concat(direction, ".event:m.room.message#").concat(msgtype);
+
+ // cheat by sending it through the processor
+ return WidgetEventCapability.findEventCapabilities([str])[0];
+ }
+
+ /**
+ * Parses a capabilities request to find all the event capability requests.
+ * @param {Iterable<Capability>} capabilities The capabilities requested/to parse.
+ * @returns {WidgetEventCapability[]} An array of event capability requests. May be empty, but never null.
+ */
+ }, {
+ key: "findEventCapabilities",
+ value: function findEventCapabilities(capabilities) {
+ var parsed = [];
+ var _iterator = _createForOfIteratorHelper(capabilities),
+ _step;
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var cap = _step.value;
+ var _direction = null;
+ var eventSegment = void 0;
+ var _kind = null;
+
+ // TODO: Enable support for m.* namespace once the MSCs land.
+ // https://github.com/matrix-org/matrix-widget-api/issues/22
+ // https://github.com/matrix-org/matrix-widget-api/issues/56
+
+ if (cap.startsWith("org.matrix.msc2762.send.event:")) {
+ _direction = EventDirection.Send;
+ _kind = EventKind.Event;
+ eventSegment = cap.substring("org.matrix.msc2762.send.event:".length);
+ } else if (cap.startsWith("org.matrix.msc2762.send.state_event:")) {
+ _direction = EventDirection.Send;
+ _kind = EventKind.State;
+ eventSegment = cap.substring("org.matrix.msc2762.send.state_event:".length);
+ } else if (cap.startsWith("org.matrix.msc3819.send.to_device:")) {
+ _direction = EventDirection.Send;
+ _kind = EventKind.ToDevice;
+ eventSegment = cap.substring("org.matrix.msc3819.send.to_device:".length);
+ } else if (cap.startsWith("org.matrix.msc2762.receive.event:")) {
+ _direction = EventDirection.Receive;
+ _kind = EventKind.Event;
+ eventSegment = cap.substring("org.matrix.msc2762.receive.event:".length);
+ } else if (cap.startsWith("org.matrix.msc2762.receive.state_event:")) {
+ _direction = EventDirection.Receive;
+ _kind = EventKind.State;
+ eventSegment = cap.substring("org.matrix.msc2762.receive.state_event:".length);
+ } else if (cap.startsWith("org.matrix.msc3819.receive.to_device:")) {
+ _direction = EventDirection.Receive;
+ _kind = EventKind.ToDevice;
+ eventSegment = cap.substring("org.matrix.msc3819.receive.to_device:".length);
+ }
+ if (_direction === null || _kind === null || eventSegment === undefined) continue;
+
+ // The capability uses `#` as a separator between event type and state key/msgtype,
+ // so we split on that. However, a # is also valid in either one of those so we
+ // join accordingly.
+ // Eg: `m.room.message##m.text` is "m.room.message" event with msgtype "#m.text".
+ var expectingKeyStr = eventSegment.startsWith("m.room.message#") || _kind === EventKind.State;
+ var _keyStr = null;
+ if (eventSegment.includes('#') && expectingKeyStr) {
+ // Dev note: regex is difficult to write, so instead the rules are manually written
+ // out. This is probably just as understandable as a boring regex though, so win-win?
+
+ // Test cases:
+ // str eventSegment keyStr
+ // -------------------------------------------------------------
+ // m.room.message# m.room.message <empty string>
+ // m.room.message#test m.room.message test
+ // m.room.message\# m.room.message# test
+ // m.room.message##test m.room.message #test
+ // m.room.message\##test m.room.message# test
+ // m.room.message\\##test m.room.message\# test
+ // m.room.message\\###test m.room.message\# #test
+
+ // First step: explode the string
+ var parts = eventSegment.split('#');
+
+ // To form the eventSegment, we'll keep finding parts of the exploded string until
+ // there's one that doesn't end with the escape character (\). We'll then join those
+ // segments together with the exploding character. We have to remember to consume the
+ // escape character as well.
+ var idx = parts.findIndex(function (p) {
+ return !p.endsWith("\\");
+ });
+ eventSegment = parts.slice(0, idx + 1).map(function (p) {
+ return p.endsWith('\\') ? p.substring(0, p.length - 1) : p;
+ }).join('#');
+
+ // The keyStr is whatever is left over.
+ _keyStr = parts.slice(idx + 1).join('#');
+ }
+ parsed.push(new WidgetEventCapability(_direction, eventSegment, _kind, _keyStr, cap));
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+ return parsed;
+ }
+ }]);
+ return WidgetEventCapability;
+}();
+exports.WidgetEventCapability = WidgetEventCapability;
+//# sourceMappingURL=WidgetEventCapability.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetParser.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetParser.js
new file mode 100644
index 0000000000..cd2a742563
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetParser.js
@@ -0,0 +1,152 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.WidgetParser = void 0;
+var _Widget = require("./Widget");
+var _url = require("./validation/url");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ * 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.
+ */
+var WidgetParser = /*#__PURE__*/function () {
+ function WidgetParser() {
+ _classCallCheck(this, WidgetParser);
+ } // private constructor because this is a util class
+
+ /**
+ * Parses widgets from the "m.widgets" account data event. This will always
+ * return an array, though may be empty if no valid widgets were found.
+ * @param {IAccountDataWidgets} content The content of the "m.widgets" account data.
+ * @returns {Widget[]} The widgets in account data, or an empty array.
+ */
+ _createClass(WidgetParser, null, [{
+ key: "parseAccountData",
+ value: function parseAccountData(content) {
+ if (!content) return [];
+ var result = [];
+ for (var _i = 0, _Object$keys = Object.keys(content); _i < _Object$keys.length; _i++) {
+ var _widgetId = _Object$keys[_i];
+ var roughWidget = content[_widgetId];
+ if (!roughWidget) continue;
+ if (roughWidget.type !== "m.widget" && roughWidget.type !== "im.vector.modular.widgets") continue;
+ if (!roughWidget.sender) continue;
+ var probableWidgetId = roughWidget.state_key || roughWidget.id;
+ if (probableWidgetId !== _widgetId) continue;
+ var asStateEvent = {
+ content: roughWidget.content,
+ sender: roughWidget.sender,
+ type: "m.widget",
+ state_key: _widgetId,
+ event_id: "$example",
+ room_id: "!example",
+ origin_server_ts: 1
+ };
+ var widget = WidgetParser.parseRoomWidget(asStateEvent);
+ if (widget) result.push(widget);
+ }
+ return result;
+ }
+
+ /**
+ * Parses all the widgets possible in the given array. This will always return
+ * an array, though may be empty if no widgets could be parsed.
+ * @param {IStateEvent[]} currentState The room state to parse.
+ * @returns {Widget[]} The widgets in the state, or an empty array.
+ */
+ }, {
+ key: "parseWidgetsFromRoomState",
+ value: function parseWidgetsFromRoomState(currentState) {
+ if (!currentState) return [];
+ var result = [];
+ var _iterator = _createForOfIteratorHelper(currentState),
+ _step;
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var state = _step.value;
+ var widget = WidgetParser.parseRoomWidget(state);
+ if (widget) result.push(widget);
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+ return result;
+ }
+
+ /**
+ * Parses a state event into a widget. If the state event does not represent
+ * a widget (wrong event type, invalid widget, etc) then null is returned.
+ * @param {IStateEvent} stateEvent The state event.
+ * @returns {Widget|null} The widget, or null if invalid
+ */
+ }, {
+ key: "parseRoomWidget",
+ value: function parseRoomWidget(stateEvent) {
+ if (!stateEvent) return null;
+
+ // TODO: [Legacy] Remove legacy support
+ if (stateEvent.type !== "m.widget" && stateEvent.type !== "im.vector.modular.widgets") {
+ return null;
+ }
+
+ // Dev note: Throughout this function we have null safety to ensure that
+ // if the caller did not supply something useful that we don't error. This
+ // is done against the requirements of the interface because not everyone
+ // will have an interface to validate against.
+
+ var content = stateEvent.content || {};
+
+ // Form our best approximation of a widget with the information we have
+ var estimatedWidget = {
+ id: stateEvent.state_key,
+ creatorUserId: content['creatorUserId'] || stateEvent.sender,
+ name: content['name'],
+ type: content['type'],
+ url: content['url'],
+ waitForIframeLoad: content['waitForIframeLoad'],
+ data: content['data']
+ };
+
+ // Finally, process that widget
+ return WidgetParser.processEstimatedWidget(estimatedWidget);
+ }
+ }, {
+ key: "processEstimatedWidget",
+ value: function processEstimatedWidget(widget) {
+ // Validate that the widget has the best chance of passing as a widget
+ if (!widget.id || !widget.creatorUserId || !widget.type) {
+ return null;
+ }
+ if (!(0, _url.isValidUrl)(widget.url)) {
+ return null;
+ }
+ // TODO: Validate data for known widget types
+ return new _Widget.Widget(widget);
+ }
+ }]);
+ return WidgetParser;
+}();
+exports.WidgetParser = WidgetParser;
+//# sourceMappingURL=WidgetParser.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/url.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/url.js
new file mode 100644
index 0000000000..30bce1a380
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/url.js
@@ -0,0 +1,39 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.isValidUrl = isValidUrl;
+/*
+ * 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.
+ */
+
+function isValidUrl(val) {
+ if (!val) return false; // easy: not valid if not present
+
+ try {
+ var parsed = new URL(val);
+ if (parsed.protocol !== "http" && parsed.protocol !== "https") {
+ return false;
+ }
+ return true;
+ } catch (e) {
+ if (e instanceof TypeError) {
+ return false;
+ }
+ throw e;
+ }
+}
+//# sourceMappingURL=url.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/utils.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/utils.js
new file mode 100644
index 0000000000..0615393f73
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/utils.js
@@ -0,0 +1,28 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.assertPresent = assertPresent;
+/*
+ * 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.
+ */
+
+function assertPresent(obj, key) {
+ if (!obj[key]) {
+ throw new Error("".concat(String(key), " is required"));
+ }
+}
+//# sourceMappingURL=utils.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/templating/url-template.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/templating/url-template.js
new file mode 100644
index 0000000000..4cdfed5e4c
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/templating/url-template.js
@@ -0,0 +1,59 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.runTemplate = runTemplate;
+exports.toString = toString;
+/*
+ * 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.
+ */
+
+function runTemplate(url, widget, params) {
+ // Always apply the supplied params over top of data to ensure the data can't lie about them.
+ var variables = Object.assign({}, widget.data, {
+ 'matrix_room_id': params.widgetRoomId || "",
+ 'matrix_user_id': params.currentUserId,
+ 'matrix_display_name': params.userDisplayName || params.currentUserId,
+ 'matrix_avatar_url': params.userHttpAvatarUrl || "",
+ 'matrix_widget_id': widget.id,
+ // TODO: Convert to stable (https://github.com/matrix-org/matrix-doc/pull/2873)
+ 'org.matrix.msc2873.client_id': params.clientId || "",
+ 'org.matrix.msc2873.client_theme': params.clientTheme || "",
+ 'org.matrix.msc2873.client_language': params.clientLanguage || "",
+ // TODO: Convert to stable (https://github.com/matrix-org/matrix-spec-proposals/pull/3819)
+ 'org.matrix.msc3819.matrix_device_id': params.deviceId || ""
+ });
+ var result = url;
+ for (var _i = 0, _Object$keys = Object.keys(variables); _i < _Object$keys.length; _i++) {
+ var key = _Object$keys[_i];
+ // Regex escape from https://stackoverflow.com/a/6969486/7037379
+ var pattern = "$".concat(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+ var rexp = new RegExp(pattern, 'g');
+
+ // This is technically not what we're supposed to do for a couple of reasons:
+ // 1. We are assuming that there won't later be a $key match after we replace a variable.
+ // 2. We are assuming that the variable is in a place where it can be escaped (eg: path or query string).
+ result = result.replace(rexp, encodeURIComponent(toString(variables[key])));
+ }
+ return result;
+}
+function toString(a) {
+ if (a === null || a === undefined) {
+ return "".concat(a);
+ }
+ return String(a);
+}
+//# sourceMappingURL=url-template.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/ITransport.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/ITransport.js
new file mode 100644
index 0000000000..c5f48db031
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/ITransport.js
@@ -0,0 +1,6 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+//# sourceMappingURL=ITransport.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/PostmessageTransport.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/PostmessageTransport.js
new file mode 100644
index 0000000000..c387170213
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/transport/PostmessageTransport.js
@@ -0,0 +1,222 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.PostmessageTransport = void 0;
+var _events = require("events");
+var _ = require("..");
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); }
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } /*
+ * 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.
+ */
+/**
+ * Transport for the Widget API over postMessage.
+ */
+var PostmessageTransport = /*#__PURE__*/function (_EventEmitter) {
+ _inherits(PostmessageTransport, _EventEmitter);
+ var _super = _createSuper(PostmessageTransport);
+ function PostmessageTransport(sendDirection, initialWidgetId, transportWindow, inboundWindow) {
+ var _this;
+ _classCallCheck(this, PostmessageTransport);
+ _this = _super.call(this);
+ _this.sendDirection = sendDirection;
+ _this.initialWidgetId = initialWidgetId;
+ _this.transportWindow = transportWindow;
+ _this.inboundWindow = inboundWindow;
+ _defineProperty(_assertThisInitialized(_this), "strictOriginCheck", false);
+ _defineProperty(_assertThisInitialized(_this), "targetOrigin", "*");
+ _defineProperty(_assertThisInitialized(_this), "timeoutSeconds", 10);
+ _defineProperty(_assertThisInitialized(_this), "_ready", false);
+ _defineProperty(_assertThisInitialized(_this), "_widgetId", null);
+ _defineProperty(_assertThisInitialized(_this), "outboundRequests", new Map());
+ _defineProperty(_assertThisInitialized(_this), "stopController", new AbortController());
+ _this._widgetId = initialWidgetId;
+ return _this;
+ }
+ _createClass(PostmessageTransport, [{
+ key: "ready",
+ get: function get() {
+ return this._ready;
+ }
+ }, {
+ key: "widgetId",
+ get: function get() {
+ return this._widgetId || null;
+ }
+ }, {
+ key: "nextRequestId",
+ get: function get() {
+ var idBase = "widgetapi-".concat(Date.now());
+ var index = 0;
+ var id = idBase;
+ while (this.outboundRequests.has(id)) {
+ id = "".concat(idBase, "-").concat(index++);
+ }
+
+ // reserve the ID
+ this.outboundRequests.set(id, null);
+ return id;
+ }
+ }, {
+ key: "sendInternal",
+ value: function sendInternal(message) {
+ console.log("[PostmessageTransport] Sending object to ".concat(this.targetOrigin, ": "), message);
+ this.transportWindow.postMessage(message, this.targetOrigin);
+ }
+ }, {
+ key: "reply",
+ value: function reply(request, responseData) {
+ return this.sendInternal(_objectSpread(_objectSpread({}, request), {}, {
+ response: responseData
+ }));
+ }
+ }, {
+ key: "send",
+ value: function send(action, data) {
+ return this.sendComplete(action, data).then(function (r) {
+ return r.response;
+ });
+ }
+ }, {
+ key: "sendComplete",
+ value: function sendComplete(action, data) {
+ var _this2 = this;
+ if (!this.ready || !this.widgetId) {
+ return Promise.reject(new Error("Not ready or unknown widget ID"));
+ }
+ var request = {
+ api: this.sendDirection,
+ widgetId: this.widgetId,
+ requestId: this.nextRequestId,
+ action: action,
+ data: data
+ };
+ if (action === _.WidgetApiToWidgetAction.UpdateVisibility) {
+ request['visible'] = data['visible'];
+ }
+ return new Promise(function (prResolve, prReject) {
+ var resolve = function resolve(response) {
+ cleanUp();
+ prResolve(response);
+ };
+ var reject = function reject(err) {
+ cleanUp();
+ prReject(err);
+ };
+ var timerId = setTimeout(function () {
+ return reject(new Error("Request timed out"));
+ }, (_this2.timeoutSeconds || 1) * 1000);
+ var onStop = function onStop() {
+ return reject(new Error("Transport stopped"));
+ };
+ _this2.stopController.signal.addEventListener("abort", onStop);
+ var cleanUp = function cleanUp() {
+ _this2.outboundRequests["delete"](request.requestId);
+ clearTimeout(timerId);
+ _this2.stopController.signal.removeEventListener("abort", onStop);
+ };
+ _this2.outboundRequests.set(request.requestId, {
+ request: request,
+ resolve: resolve,
+ reject: reject
+ });
+ _this2.sendInternal(request);
+ });
+ }
+ }, {
+ key: "start",
+ value: function start() {
+ var _this3 = this;
+ this.inboundWindow.addEventListener("message", function (ev) {
+ _this3.handleMessage(ev);
+ });
+ this._ready = true;
+ }
+ }, {
+ key: "stop",
+ value: function stop() {
+ this._ready = false;
+ this.stopController.abort();
+ }
+ }, {
+ key: "handleMessage",
+ value: function handleMessage(ev) {
+ if (this.stopController.signal.aborted) return;
+ if (!ev.data) return; // invalid event
+
+ if (this.strictOriginCheck && ev.origin !== window.origin) return; // bad origin
+
+ // treat the message as a response first, then downgrade to a request
+ var response = ev.data;
+ if (!response.action || !response.requestId || !response.widgetId) return; // invalid request/response
+
+ if (!response.response) {
+ // it's a request
+ var request = response;
+ if (request.api !== (0, _.invertedDirection)(this.sendDirection)) return; // wrong direction
+ this.handleRequest(request);
+ } else {
+ // it's a response
+ if (response.api !== this.sendDirection) return; // wrong direction
+ this.handleResponse(response);
+ }
+ }
+ }, {
+ key: "handleRequest",
+ value: function handleRequest(request) {
+ if (this.widgetId) {
+ if (this.widgetId !== request.widgetId) return; // wrong widget
+ } else {
+ this._widgetId = request.widgetId;
+ }
+ this.emit("message", new CustomEvent("message", {
+ detail: request
+ }));
+ }
+ }, {
+ key: "handleResponse",
+ value: function handleResponse(response) {
+ if (response.widgetId !== this.widgetId) return; // wrong widget
+
+ var req = this.outboundRequests.get(response.requestId);
+ if (!req) return; // response to an unknown request
+
+ if ((0, _.isErrorResponse)(response.response)) {
+ var _err = response.response;
+ req.reject(new Error(_err.error.message));
+ } else {
+ req.resolve(response);
+ }
+ }
+ }]);
+ return PostmessageTransport;
+}(_events.EventEmitter);
+exports.PostmessageTransport = PostmessageTransport;
+//# sourceMappingURL=PostmessageTransport.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/matrix-widget-api/util/SimpleObservable.js b/comm/chat/protocols/matrix/lib/matrix-widget-api/util/SimpleObservable.js
new file mode 100644
index 0000000000..45bb9f215a
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/matrix-widget-api/util/SimpleObservable.js
@@ -0,0 +1,68 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+exports.SimpleObservable = void 0;
+function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
+function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
+function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
+function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
+function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
+function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
+/*
+ * 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.
+ */
+var SimpleObservable = /*#__PURE__*/function () {
+ function SimpleObservable(initialFn) {
+ _classCallCheck(this, SimpleObservable);
+ _defineProperty(this, "listeners", []);
+ if (initialFn) this.listeners.push(initialFn);
+ }
+ _createClass(SimpleObservable, [{
+ key: "onUpdate",
+ value: function onUpdate(fn) {
+ this.listeners.push(fn);
+ }
+ }, {
+ key: "update",
+ value: function update(val) {
+ var _iterator = _createForOfIteratorHelper(this.listeners),
+ _step;
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var listener = _step.value;
+ listener(val);
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+ }
+ }, {
+ key: "close",
+ value: function close() {
+ this.listeners = []; // reset
+ }
+ }]);
+ return SimpleObservable;
+}();
+exports.SimpleObservable = SimpleObservable;
+//# sourceMappingURL=SimpleObservable.js.map \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/moz.build b/comm/chat/protocols/matrix/lib/moz.build
new file mode 100644
index 0000000000..289e7b44df
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/moz.build
@@ -0,0 +1,365 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# The Matrix SDK.
+EXTRA_JS_MODULES.matrix.matrix_sdk += [
+ "matrix-sdk/autodiscovery.js",
+ "matrix-sdk/browser-index.js",
+ "matrix-sdk/client.js",
+ "matrix-sdk/content-helpers.js",
+ "matrix-sdk/content-repo.js",
+ "matrix-sdk/crypto-api.js",
+ "matrix-sdk/embedded.js",
+ "matrix-sdk/errors.js",
+ "matrix-sdk/event-mapper.js",
+ "matrix-sdk/feature.js",
+ "matrix-sdk/filter-component.js",
+ "matrix-sdk/filter.js",
+ "matrix-sdk/indexeddb-helpers.js",
+ "matrix-sdk/indexeddb-worker.js",
+ "matrix-sdk/interactive-auth.js",
+ "matrix-sdk/logger.js",
+ "matrix-sdk/matrix.js",
+ "matrix-sdk/NamespacedValue.js",
+ "matrix-sdk/pushprocessor.js",
+ "matrix-sdk/randomstring.js",
+ "matrix-sdk/realtime-callbacks.js",
+ "matrix-sdk/receipt-accumulator.js",
+ "matrix-sdk/ReEmitter.js",
+ "matrix-sdk/room-hierarchy.js",
+ "matrix-sdk/scheduler.js",
+ "matrix-sdk/secret-storage.js",
+ "matrix-sdk/service-types.js",
+ "matrix-sdk/sliding-sync-sdk.js",
+ "matrix-sdk/sliding-sync.js",
+ "matrix-sdk/sync-accumulator.js",
+ "matrix-sdk/sync.js",
+ "matrix-sdk/timeline-window.js",
+ "matrix-sdk/ToDeviceMessageQueue.js",
+ "matrix-sdk/utils.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto += [
+ "matrix-sdk/crypto/aes.js",
+ "matrix-sdk/crypto/api.js",
+ "matrix-sdk/crypto/backup.js",
+ "matrix-sdk/crypto/CrossSigning.js",
+ "matrix-sdk/crypto/crypto.js",
+ "matrix-sdk/crypto/dehydration.js",
+ "matrix-sdk/crypto/device-converter.js",
+ "matrix-sdk/crypto/deviceinfo.js",
+ "matrix-sdk/crypto/DeviceList.js",
+ "matrix-sdk/crypto/EncryptionSetup.js",
+ "matrix-sdk/crypto/index.js",
+ "matrix-sdk/crypto/key_passphrase.js",
+ "matrix-sdk/crypto/OlmDevice.js",
+ "matrix-sdk/crypto/olmlib.js",
+ "matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js",
+ "matrix-sdk/crypto/recoverykey.js",
+ "matrix-sdk/crypto/RoomList.js",
+ "matrix-sdk/crypto/SecretSharing.js",
+ "matrix-sdk/crypto/SecretStorage.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.algorithms += [
+ "matrix-sdk/crypto/algorithms/base.js",
+ "matrix-sdk/crypto/algorithms/index.js",
+ "matrix-sdk/crypto/algorithms/megolm.js",
+ "matrix-sdk/crypto/algorithms/olm.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.store += [
+ "matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js",
+ "matrix-sdk/crypto/store/indexeddb-crypto-store.js",
+ "matrix-sdk/crypto/store/localStorage-crypto-store.js",
+ "matrix-sdk/crypto/store/memory-crypto-store.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.verification += [
+ "matrix-sdk/crypto/verification/Base.js",
+ "matrix-sdk/crypto/verification/Error.js",
+ "matrix-sdk/crypto/verification/IllegalMethod.js",
+ "matrix-sdk/crypto/verification/QRCode.js",
+ "matrix-sdk/crypto/verification/SAS.js",
+ "matrix-sdk/crypto/verification/SASDecimal.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.verification.request += [
+ "matrix-sdk/crypto/verification/request/InRoomChannel.js",
+ "matrix-sdk/crypto/verification/request/ToDeviceChannel.js",
+ "matrix-sdk/crypto/verification/request/VerificationRequest.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto_api += [
+ "matrix-sdk/crypto-api/verification.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.extensible_events_v1 += [
+ "matrix-sdk/extensible_events_v1/ExtensibleEvent.js",
+ "matrix-sdk/extensible_events_v1/InvalidEventError.js",
+ "matrix-sdk/extensible_events_v1/MessageEvent.js",
+ "matrix-sdk/extensible_events_v1/PollEndEvent.js",
+ "matrix-sdk/extensible_events_v1/PollResponseEvent.js",
+ "matrix-sdk/extensible_events_v1/PollStartEvent.js",
+ "matrix-sdk/extensible_events_v1/utilities.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.http_api += [
+ "matrix-sdk/http-api/errors.js",
+ "matrix-sdk/http-api/fetch.js",
+ "matrix-sdk/http-api/index.js",
+ "matrix-sdk/http-api/interface.js",
+ "matrix-sdk/http-api/method.js",
+ "matrix-sdk/http-api/prefix.js",
+ "matrix-sdk/http-api/utils.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.models += [
+ "matrix-sdk/models/beacon.js",
+ "matrix-sdk/models/device.js",
+ "matrix-sdk/models/event-context.js",
+ "matrix-sdk/models/event-status.js",
+ "matrix-sdk/models/event-timeline-set.js",
+ "matrix-sdk/models/event-timeline.js",
+ "matrix-sdk/models/event.js",
+ "matrix-sdk/models/invites-ignorer.js",
+ "matrix-sdk/models/MSC3089Branch.js",
+ "matrix-sdk/models/MSC3089TreeSpace.js",
+ "matrix-sdk/models/poll.js",
+ "matrix-sdk/models/read-receipt.js",
+ "matrix-sdk/models/related-relations.js",
+ "matrix-sdk/models/relations-container.js",
+ "matrix-sdk/models/relations.js",
+ "matrix-sdk/models/room-member.js",
+ "matrix-sdk/models/room-state.js",
+ "matrix-sdk/models/room-summary.js",
+ "matrix-sdk/models/room.js",
+ "matrix-sdk/models/search-result.js",
+ "matrix-sdk/models/thread.js",
+ "matrix-sdk/models/typed-event-emitter.js",
+ "matrix-sdk/models/user.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.rendezvous += [
+ "matrix-sdk/rendezvous/index.js",
+ "matrix-sdk/rendezvous/MSC3906Rendezvous.js",
+ "matrix-sdk/rendezvous/RendezvousError.js",
+ "matrix-sdk/rendezvous/RendezvousFailureReason.js",
+ "matrix-sdk/rendezvous/RendezvousIntent.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.rendezvous.channels += [
+ "matrix-sdk/rendezvous/channels/index.js",
+ "matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.rendezvous.transports += [
+ "matrix-sdk/rendezvous/transports/index.js",
+ "matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.rust_crypto += [
+ "matrix-sdk/rust-crypto/browserify-index.js",
+ "matrix-sdk/rust-crypto/constants.js",
+ "matrix-sdk/rust-crypto/CrossSigningIdentity.js",
+ "matrix-sdk/rust-crypto/device-converter.js",
+ "matrix-sdk/rust-crypto/index.js",
+ "matrix-sdk/rust-crypto/KeyClaimManager.js",
+ "matrix-sdk/rust-crypto/OutgoingRequestProcessor.js",
+ "matrix-sdk/rust-crypto/RoomEncryptor.js",
+ "matrix-sdk/rust-crypto/rust-crypto.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.store += [
+ "matrix-sdk/store/indexeddb-local-backend.js",
+ "matrix-sdk/store/indexeddb-remote-backend.js",
+ "matrix-sdk/store/indexeddb-store-worker.js",
+ "matrix-sdk/store/indexeddb.js",
+ "matrix-sdk/store/local-storage-events-emitter.js",
+ "matrix-sdk/store/memory.js",
+ "matrix-sdk/store/stub.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.types += [
+ "matrix-sdk/@types/auth.js",
+ "matrix-sdk/@types/beacon.js",
+ "matrix-sdk/@types/event.js",
+ "matrix-sdk/@types/extensible_events.js",
+ "matrix-sdk/@types/location.js",
+ "matrix-sdk/@types/partials.js",
+ "matrix-sdk/@types/polls.js",
+ "matrix-sdk/@types/PushRules.js",
+ "matrix-sdk/@types/read_receipts.js",
+ "matrix-sdk/@types/search.js",
+ "matrix-sdk/@types/sync.js",
+ "matrix-sdk/@types/threepids.js",
+ "matrix-sdk/@types/topic.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.webrtc += [
+ "matrix-sdk/webrtc/audioContext.js",
+ "matrix-sdk/webrtc/call.js",
+ "matrix-sdk/webrtc/callEventHandler.js",
+ "matrix-sdk/webrtc/callEventTypes.js",
+ "matrix-sdk/webrtc/callFeed.js",
+ "matrix-sdk/webrtc/groupCall.js",
+ "matrix-sdk/webrtc/groupCallEventHandler.js",
+ "matrix-sdk/webrtc/mediaHandler.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.webrtc.stats += [
+ "matrix-sdk/webrtc/stats/callStatsReportGatherer.js",
+ "matrix-sdk/webrtc/stats/connectionStats.js",
+ "matrix-sdk/webrtc/stats/connectionStatsBuilder.js",
+ "matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js",
+ "matrix-sdk/webrtc/stats/groupCallStats.js",
+ "matrix-sdk/webrtc/stats/statsReport.js",
+ "matrix-sdk/webrtc/stats/statsReportEmitter.js",
+ "matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js",
+ "matrix-sdk/webrtc/stats/trackStatsBuilder.js",
+ "matrix-sdk/webrtc/stats/transportStatsBuilder.js",
+ "matrix-sdk/webrtc/stats/valueFormatter.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.webrtc.stats.media += [
+ "matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js",
+ "matrix-sdk/webrtc/stats/media/mediaTrackHandler.js",
+ "matrix-sdk/webrtc/stats/media/mediaTrackStats.js",
+ "matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js",
+]
+
+# Dependencies of the Matrix SDK.
+
+# Single file dependencies (with good names) are just added to the top-level
+# matrix module.
+EXTRA_JS_MODULES.matrix += [
+ "another-json/another-json.js",
+ "events/events.js",
+]
+
+EXTRA_JS_MODULES.matrix.base_x += [
+ "base-x/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.bs58 += [
+ "bs58/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.content_type += [
+ "content-type/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.unhomoglyph += [
+ "unhomoglyph/data.json",
+ "unhomoglyph/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.olm += [
+ "@matrix-org/olm/olm.js",
+ "@matrix-org/olm/olm.wasm",
+]
+
+EXTRA_JS_MODULES.matrix.p_retry += [
+ "p-retry/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.retry += [
+ "retry/index.js",
+]
+
+EXTRA_JS_MODULES.matrix.retry.lib += [
+ "retry/lib/retry.js",
+ "retry/lib/retry_operation.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_events_sdk += [
+ "matrix-events-sdk/ExtensibleEvents.js",
+ "matrix-events-sdk/index.js",
+ "matrix-events-sdk/InvalidEventError.js",
+ "matrix-events-sdk/NamespacedMap.js",
+ "matrix-events-sdk/NamespacedValue.js",
+ "matrix-events-sdk/types.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_events_sdk.events += [
+ "matrix-events-sdk/events/EmoteEvent.js",
+ "matrix-events-sdk/events/ExtensibleEvent.js",
+ "matrix-events-sdk/events/message_types.js",
+ "matrix-events-sdk/events/MessageEvent.js",
+ "matrix-events-sdk/events/NoticeEvent.js",
+ "matrix-events-sdk/events/poll_types.js",
+ "matrix-events-sdk/events/PollEndEvent.js",
+ "matrix-events-sdk/events/PollResponseEvent.js",
+ "matrix-events-sdk/events/PollStartEvent.js",
+ "matrix-events-sdk/events/relationship_types.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_events_sdk.interpreters.legacy += [
+ "matrix-events-sdk/interpreters/legacy/MRoomMessage.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_events_sdk.interpreters.modern += [
+ "matrix-events-sdk/interpreters/modern/MMessage.js",
+ "matrix-events-sdk/interpreters/modern/MPoll.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_events_sdk.utility += [
+ "matrix-events-sdk/utility/events.js",
+ "matrix-events-sdk/utility/MessageMatchers.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api += [
+ "matrix-widget-api/ClientWidgetApi.js",
+ "matrix-widget-api/index.js",
+ "matrix-widget-api/Symbols.js",
+ "matrix-widget-api/WidgetApi.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.driver += [
+ "matrix-widget-api/driver/WidgetDriver.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.interfaces += [
+ "matrix-widget-api/interfaces/ApiVersion.js",
+ "matrix-widget-api/interfaces/Capabilities.js",
+ "matrix-widget-api/interfaces/GetOpenIDAction.js",
+ "matrix-widget-api/interfaces/IWidgetApiErrorResponse.js",
+ "matrix-widget-api/interfaces/ModalButtonKind.js",
+ "matrix-widget-api/interfaces/ModalWidgetActions.js",
+ "matrix-widget-api/interfaces/WidgetApiAction.js",
+ "matrix-widget-api/interfaces/WidgetApiDirection.js",
+ "matrix-widget-api/interfaces/WidgetKind.js",
+ "matrix-widget-api/interfaces/WidgetType.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.models += [
+ "matrix-widget-api/models/Widget.js",
+ "matrix-widget-api/models/WidgetEventCapability.js",
+ "matrix-widget-api/models/WidgetParser.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.models.validation += [
+ "matrix-widget-api/models/validation/url.js",
+ "matrix-widget-api/models/validation/utils.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.templating += [
+ "matrix-widget-api/templating/url-template.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.transport += [
+ "matrix-widget-api/transport/PostmessageTransport.js",
+]
+
+EXTRA_JS_MODULES.matrix.matrix_widget_api.util += [
+ "matrix-widget-api/util/SimpleObservable.js",
+]
+
+EXTRA_JS_MODULES.matrix.sdp_transform += [
+ "sdp-transform/grammar.js",
+ "sdp-transform/index.js",
+ "sdp-transform/parser.js",
+ "sdp-transform/writer.js",
+]
diff --git a/comm/chat/protocols/matrix/lib/p-retry/index.js b/comm/chat/protocols/matrix/lib/p-retry/index.js
new file mode 100644
index 0000000000..3679399db8
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/p-retry/index.js
@@ -0,0 +1,85 @@
+'use strict';
+const retry = require('retry');
+
+const networkErrorMsgs = [
+ 'Failed to fetch', // Chrome
+ 'NetworkError when attempting to fetch resource.', // Firefox
+ 'The Internet connection appears to be offline.', // Safari
+ 'Network request failed' // `cross-fetch`
+];
+
+class AbortError extends Error {
+ constructor(message) {
+ super();
+
+ if (message instanceof Error) {
+ this.originalError = message;
+ ({message} = message);
+ } else {
+ this.originalError = new Error(message);
+ this.originalError.stack = this.stack;
+ }
+
+ this.name = 'AbortError';
+ this.message = message;
+ }
+}
+
+const decorateErrorWithCounts = (error, attemptNumber, options) => {
+ // Minus 1 from attemptNumber because the first attempt does not count as a retry
+ const retriesLeft = options.retries - (attemptNumber - 1);
+
+ error.attemptNumber = attemptNumber;
+ error.retriesLeft = retriesLeft;
+ return error;
+};
+
+const isNetworkError = errorMessage => networkErrorMsgs.includes(errorMessage);
+
+const pRetry = (input, options) => new Promise((resolve, reject) => {
+ options = {
+ onFailedAttempt: () => {},
+ retries: 10,
+ ...options
+ };
+
+ const operation = retry.operation(options);
+
+ operation.attempt(async attemptNumber => {
+ try {
+ resolve(await input(attemptNumber));
+ } catch (error) {
+ if (!(error instanceof Error)) {
+ reject(new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`));
+ return;
+ }
+
+ if (error instanceof AbortError) {
+ operation.stop();
+ reject(error.originalError);
+ } else if (error instanceof TypeError && !isNetworkError(error.message)) {
+ operation.stop();
+ reject(error);
+ } else {
+ decorateErrorWithCounts(error, attemptNumber, options);
+
+ try {
+ await options.onFailedAttempt(error);
+ } catch (error) {
+ reject(error);
+ return;
+ }
+
+ if (!operation.retry(error)) {
+ reject(operation.mainError());
+ }
+ }
+ }
+ });
+});
+
+module.exports = pRetry;
+// TODO: remove this in the next major version
+module.exports.default = pRetry;
+
+module.exports.AbortError = AbortError;
diff --git a/comm/chat/protocols/matrix/lib/p-retry/license b/comm/chat/protocols/matrix/lib/p-retry/license
new file mode 100644
index 0000000000..e7af2f7710
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/p-retry/license
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/retry/License b/comm/chat/protocols/matrix/lib/retry/License
new file mode 100644
index 0000000000..0b58de379f
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/retry/License
@@ -0,0 +1,21 @@
+Copyright (c) 2011:
+Tim Koschützki (tim@debuggable.com)
+Felix Geisendörfer (felix@debuggable.com)
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/retry/index.js b/comm/chat/protocols/matrix/lib/retry/index.js
new file mode 100644
index 0000000000..ee62f3a112
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/retry/index.js
@@ -0,0 +1 @@
+module.exports = require('./lib/retry'); \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/retry/lib/retry.js b/comm/chat/protocols/matrix/lib/retry/lib/retry.js
new file mode 100644
index 0000000000..5e85e79197
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/retry/lib/retry.js
@@ -0,0 +1,100 @@
+var RetryOperation = require('./retry_operation');
+
+exports.operation = function(options) {
+ var timeouts = exports.timeouts(options);
+ return new RetryOperation(timeouts, {
+ forever: options && (options.forever || options.retries === Infinity),
+ unref: options && options.unref,
+ maxRetryTime: options && options.maxRetryTime
+ });
+};
+
+exports.timeouts = function(options) {
+ if (options instanceof Array) {
+ return [].concat(options);
+ }
+
+ var opts = {
+ retries: 10,
+ factor: 2,
+ minTimeout: 1 * 1000,
+ maxTimeout: Infinity,
+ randomize: false
+ };
+ for (var key in options) {
+ opts[key] = options[key];
+ }
+
+ if (opts.minTimeout > opts.maxTimeout) {
+ throw new Error('minTimeout is greater than maxTimeout');
+ }
+
+ var timeouts = [];
+ for (var i = 0; i < opts.retries; i++) {
+ timeouts.push(this.createTimeout(i, opts));
+ }
+
+ if (options && options.forever && !timeouts.length) {
+ timeouts.push(this.createTimeout(i, opts));
+ }
+
+ // sort the array numerically ascending
+ timeouts.sort(function(a,b) {
+ return a - b;
+ });
+
+ return timeouts;
+};
+
+exports.createTimeout = function(attempt, opts) {
+ var random = (opts.randomize)
+ ? (Math.random() + 1)
+ : 1;
+
+ var timeout = Math.round(random * Math.max(opts.minTimeout, 1) * Math.pow(opts.factor, attempt));
+ timeout = Math.min(timeout, opts.maxTimeout);
+
+ return timeout;
+};
+
+exports.wrap = function(obj, options, methods) {
+ if (options instanceof Array) {
+ methods = options;
+ options = null;
+ }
+
+ if (!methods) {
+ methods = [];
+ for (var key in obj) {
+ if (typeof obj[key] === 'function') {
+ methods.push(key);
+ }
+ }
+ }
+
+ for (var i = 0; i < methods.length; i++) {
+ var method = methods[i];
+ var original = obj[method];
+
+ obj[method] = function retryWrapper(original) {
+ var op = exports.operation(options);
+ var args = Array.prototype.slice.call(arguments, 1);
+ var callback = args.pop();
+
+ args.push(function(err) {
+ if (op.retry(err)) {
+ return;
+ }
+ if (err) {
+ arguments[0] = op.mainError();
+ }
+ callback.apply(this, arguments);
+ });
+
+ op.attempt(function() {
+ original.apply(obj, args);
+ });
+ }.bind(obj, original);
+ obj[method].options = options;
+ }
+};
diff --git a/comm/chat/protocols/matrix/lib/retry/lib/retry_operation.js b/comm/chat/protocols/matrix/lib/retry/lib/retry_operation.js
new file mode 100644
index 0000000000..105ce72b2b
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/retry/lib/retry_operation.js
@@ -0,0 +1,162 @@
+function RetryOperation(timeouts, options) {
+ // Compatibility for the old (timeouts, retryForever) signature
+ if (typeof options === 'boolean') {
+ options = { forever: options };
+ }
+
+ this._originalTimeouts = JSON.parse(JSON.stringify(timeouts));
+ this._timeouts = timeouts;
+ this._options = options || {};
+ this._maxRetryTime = options && options.maxRetryTime || Infinity;
+ this._fn = null;
+ this._errors = [];
+ this._attempts = 1;
+ this._operationTimeout = null;
+ this._operationTimeoutCb = null;
+ this._timeout = null;
+ this._operationStart = null;
+ this._timer = null;
+
+ if (this._options.forever) {
+ this._cachedTimeouts = this._timeouts.slice(0);
+ }
+}
+module.exports = RetryOperation;
+
+RetryOperation.prototype.reset = function() {
+ this._attempts = 1;
+ this._timeouts = this._originalTimeouts.slice(0);
+}
+
+RetryOperation.prototype.stop = function() {
+ if (this._timeout) {
+ clearTimeout(this._timeout);
+ }
+ if (this._timer) {
+ clearTimeout(this._timer);
+ }
+
+ this._timeouts = [];
+ this._cachedTimeouts = null;
+};
+
+RetryOperation.prototype.retry = function(err) {
+ if (this._timeout) {
+ clearTimeout(this._timeout);
+ }
+
+ if (!err) {
+ return false;
+ }
+ var currentTime = new Date().getTime();
+ if (err && currentTime - this._operationStart >= this._maxRetryTime) {
+ this._errors.push(err);
+ this._errors.unshift(new Error('RetryOperation timeout occurred'));
+ return false;
+ }
+
+ this._errors.push(err);
+
+ var timeout = this._timeouts.shift();
+ if (timeout === undefined) {
+ if (this._cachedTimeouts) {
+ // retry forever, only keep last error
+ this._errors.splice(0, this._errors.length - 1);
+ timeout = this._cachedTimeouts.slice(-1);
+ } else {
+ return false;
+ }
+ }
+
+ var self = this;
+ this._timer = setTimeout(function() {
+ self._attempts++;
+
+ if (self._operationTimeoutCb) {
+ self._timeout = setTimeout(function() {
+ self._operationTimeoutCb(self._attempts);
+ }, self._operationTimeout);
+
+ if (self._options.unref) {
+ self._timeout.unref();
+ }
+ }
+
+ self._fn(self._attempts);
+ }, timeout);
+
+ if (this._options.unref) {
+ this._timer.unref();
+ }
+
+ return true;
+};
+
+RetryOperation.prototype.attempt = function(fn, timeoutOps) {
+ this._fn = fn;
+
+ if (timeoutOps) {
+ if (timeoutOps.timeout) {
+ this._operationTimeout = timeoutOps.timeout;
+ }
+ if (timeoutOps.cb) {
+ this._operationTimeoutCb = timeoutOps.cb;
+ }
+ }
+
+ var self = this;
+ if (this._operationTimeoutCb) {
+ this._timeout = setTimeout(function() {
+ self._operationTimeoutCb();
+ }, self._operationTimeout);
+ }
+
+ this._operationStart = new Date().getTime();
+
+ this._fn(this._attempts);
+};
+
+RetryOperation.prototype.try = function(fn) {
+ console.log('Using RetryOperation.try() is deprecated');
+ this.attempt(fn);
+};
+
+RetryOperation.prototype.start = function(fn) {
+ console.log('Using RetryOperation.start() is deprecated');
+ this.attempt(fn);
+};
+
+RetryOperation.prototype.start = RetryOperation.prototype.try;
+
+RetryOperation.prototype.errors = function() {
+ return this._errors;
+};
+
+RetryOperation.prototype.attempts = function() {
+ return this._attempts;
+};
+
+RetryOperation.prototype.mainError = function() {
+ if (this._errors.length === 0) {
+ return null;
+ }
+
+ var counts = {};
+ var mainError = null;
+ var mainErrorCount = 0;
+
+ for (var i = 0; i < this._errors.length; i++) {
+ var error = this._errors[i];
+ var message = error.message;
+ var count = (counts[message] || 0) + 1;
+
+ counts[message] = count;
+
+ if (count >= mainErrorCount) {
+ mainError = error;
+ mainErrorCount = count;
+ }
+ }
+
+ return mainError;
+};
diff --git a/comm/chat/protocols/matrix/lib/sdp-transform/LICENSE b/comm/chat/protocols/matrix/lib/sdp-transform/LICENSE
new file mode 100644
index 0000000000..5c2338f892
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/sdp-transform/LICENSE
@@ -0,0 +1,22 @@
+(The MIT License)
+
+Copyright (c) 2013 Eirik Albrigtsen
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/sdp-transform/grammar.js b/comm/chat/protocols/matrix/lib/sdp-transform/grammar.js
new file mode 100644
index 0000000000..d8178e86f9
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/sdp-transform/grammar.js
@@ -0,0 +1,494 @@
+var grammar = module.exports = {
+ v: [{
+ name: 'version',
+ reg: /^(\d*)$/
+ }],
+ o: [{
+ // o=- 20518 0 IN IP4 203.0.113.1
+ // NB: sessionId will be a String in most cases because it is huge
+ name: 'origin',
+ reg: /^(\S*) (\d*) (\d*) (\S*) IP(\d) (\S*)/,
+ names: ['username', 'sessionId', 'sessionVersion', 'netType', 'ipVer', 'address'],
+ format: '%s %s %d %s IP%d %s'
+ }],
+ // default parsing of these only (though some of these feel outdated)
+ s: [{ name: 'name' }],
+ i: [{ name: 'description' }],
+ u: [{ name: 'uri' }],
+ e: [{ name: 'email' }],
+ p: [{ name: 'phone' }],
+ z: [{ name: 'timezones' }], // TODO: this one can actually be parsed properly...
+ r: [{ name: 'repeats' }], // TODO: this one can also be parsed properly
+ // k: [{}], // outdated thing ignored
+ t: [{
+ // t=0 0
+ name: 'timing',
+ reg: /^(\d*) (\d*)/,
+ names: ['start', 'stop'],
+ format: '%d %d'
+ }],
+ c: [{
+ // c=IN IP4 10.47.197.26
+ name: 'connection',
+ reg: /^IN IP(\d) (\S*)/,
+ names: ['version', 'ip'],
+ format: 'IN IP%d %s'
+ }],
+ b: [{
+ // b=AS:4000
+ push: 'bandwidth',
+ reg: /^(TIAS|AS|CT|RR|RS):(\d*)/,
+ names: ['type', 'limit'],
+ format: '%s:%s'
+ }],
+ m: [{
+ // m=video 51744 RTP/AVP 126 97 98 34 31
+ // NB: special - pushes to session
+ // TODO: rtp/fmtp should be filtered by the payloads found here?
+ reg: /^(\w*) (\d*) ([\w/]*)(?: (.*))?/,
+ names: ['type', 'port', 'protocol', 'payloads'],
+ format: '%s %d %s %s'
+ }],
+ a: [
+ {
+ // a=rtpmap:110 opus/48000/2
+ push: 'rtp',
+ reg: /^rtpmap:(\d*) ([\w\-.]*)(?:\s*\/(\d*)(?:\s*\/(\S*))?)?/,
+ names: ['payload', 'codec', 'rate', 'encoding'],
+ format: function (o) {
+ return (o.encoding)
+ ? 'rtpmap:%d %s/%s/%s'
+ : o.rate
+ ? 'rtpmap:%d %s/%s'
+ : 'rtpmap:%d %s';
+ }
+ },
+ {
+ // a=fmtp:108 profile-level-id=24;object=23;bitrate=64000
+ // a=fmtp:111 minptime=10; useinbandfec=1
+ push: 'fmtp',
+ reg: /^fmtp:(\d*) ([\S| ]*)/,
+ names: ['payload', 'config'],
+ format: 'fmtp:%d %s'
+ },
+ {
+ // a=control:streamid=0
+ name: 'control',
+ reg: /^control:(.*)/,
+ format: 'control:%s'
+ },
+ {
+ // a=rtcp:65179 IN IP4 193.84.77.194
+ name: 'rtcp',
+ reg: /^rtcp:(\d*)(?: (\S*) IP(\d) (\S*))?/,
+ names: ['port', 'netType', 'ipVer', 'address'],
+ format: function (o) {
+ return (o.address != null)
+ ? 'rtcp:%d %s IP%d %s'
+ : 'rtcp:%d';
+ }
+ },
+ {
+ // a=rtcp-fb:98 trr-int 100
+ push: 'rtcpFbTrrInt',
+ reg: /^rtcp-fb:(\*|\d*) trr-int (\d*)/,
+ names: ['payload', 'value'],
+ format: 'rtcp-fb:%s trr-int %d'
+ },
+ {
+ // a=rtcp-fb:98 nack rpsi
+ push: 'rtcpFb',
+ reg: /^rtcp-fb:(\*|\d*) ([\w-_]*)(?: ([\w-_]*))?/,
+ names: ['payload', 'type', 'subtype'],
+ format: function (o) {
+ return (o.subtype != null)
+ ? 'rtcp-fb:%s %s %s'
+ : 'rtcp-fb:%s %s';
+ }
+ },
+ {
+ // a=extmap:2 urn:ietf:params:rtp-hdrext:toffset
+ // a=extmap:1/recvonly URI-gps-string
+ // a=extmap:3 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:smpte-tc 25@600/24
+ push: 'ext',
+ reg: /^extmap:(\d+)(?:\/(\w+))?(?: (urn:ietf:params:rtp-hdrext:encrypt))? (\S*)(?: (\S*))?/,
+ names: ['value', 'direction', 'encrypt-uri', 'uri', 'config'],
+ format: function (o) {
+ return (
+ 'extmap:%d' +
+ (o.direction ? '/%s' : '%v') +
+ (o['encrypt-uri'] ? ' %s' : '%v') +
+ ' %s' +
+ (o.config ? ' %s' : '')
+ );
+ }
+ },
+ {
+ // a=extmap-allow-mixed
+ name: 'extmapAllowMixed',
+ reg: /^(extmap-allow-mixed)/
+ },
+ {
+ // a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:PS1uQCVeeCFCanVmcjkpPywjNWhcYD0mXXtxaVBR|2^20|1:32
+ push: 'crypto',
+ reg: /^crypto:(\d*) ([\w_]*) (\S*)(?: (\S*))?/,
+ names: ['id', 'suite', 'config', 'sessionConfig'],
+ format: function (o) {
+ return (o.sessionConfig != null)
+ ? 'crypto:%d %s %s %s'
+ : 'crypto:%d %s %s';
+ }
+ },
+ {
+ // a=setup:actpass
+ name: 'setup',
+ reg: /^setup:(\w*)/,
+ format: 'setup:%s'
+ },
+ {
+ // a=connection:new
+ name: 'connectionType',
+ reg: /^connection:(new|existing)/,
+ format: 'connection:%s'
+ },
+ {
+ // a=mid:1
+ name: 'mid',
+ reg: /^mid:([^\s]*)/,
+ format: 'mid:%s'
+ },
+ {
+ // a=msid:0c8b064d-d807-43b4-b434-f92a889d8587 98178685-d409-46e0-8e16-7ef0db0db64a
+ name: 'msid',
+ reg: /^msid:(.*)/,
+ format: 'msid:%s'
+ },
+ {
+ // a=ptime:20
+ name: 'ptime',
+ reg: /^ptime:(\d*(?:\.\d*)*)/,
+ format: 'ptime:%d'
+ },
+ {
+ // a=maxptime:60
+ name: 'maxptime',
+ reg: /^maxptime:(\d*(?:\.\d*)*)/,
+ format: 'maxptime:%d'
+ },
+ {
+ // a=sendrecv
+ name: 'direction',
+ reg: /^(sendrecv|recvonly|sendonly|inactive)/
+ },
+ {
+ // a=ice-lite
+ name: 'icelite',
+ reg: /^(ice-lite)/
+ },
+ {
+ // a=ice-ufrag:F7gI
+ name: 'iceUfrag',
+ reg: /^ice-ufrag:(\S*)/,
+ format: 'ice-ufrag:%s'
+ },
+ {
+ // a=ice-pwd:x9cml/YzichV2+XlhiMu8g
+ name: 'icePwd',
+ reg: /^ice-pwd:(\S*)/,
+ format: 'ice-pwd:%s'
+ },
+ {
+ // a=fingerprint:SHA-1 00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33
+ name: 'fingerprint',
+ reg: /^fingerprint:(\S*) (\S*)/,
+ names: ['type', 'hash'],
+ format: 'fingerprint:%s %s'
+ },
+ {
+ // a=candidate:0 1 UDP 2113667327 203.0.113.1 54400 typ host
+ // a=candidate:1162875081 1 udp 2113937151 192.168.34.75 60017 typ host generation 0 network-id 3 network-cost 10
+ // a=candidate:3289912957 2 udp 1845501695 193.84.77.194 60017 typ srflx raddr 192.168.34.75 rport 60017 generation 0 network-id 3 network-cost 10
+ // a=candidate:229815620 1 tcp 1518280447 192.168.150.19 60017 typ host tcptype active generation 0 network-id 3 network-cost 10
+ // a=candidate:3289912957 2 tcp 1845501695 193.84.77.194 60017 typ srflx raddr 192.168.34.75 rport 60017 tcptype passive generation 0 network-id 3 network-cost 10
+ push:'candidates',
+ reg: /^candidate:(\S*) (\d*) (\S*) (\d*) (\S*) (\d*) typ (\S*)(?: raddr (\S*) rport (\d*))?(?: tcptype (\S*))?(?: generation (\d*))?(?: network-id (\d*))?(?: network-cost (\d*))?/,
+ names: ['foundation', 'component', 'transport', 'priority', 'ip', 'port', 'type', 'raddr', 'rport', 'tcptype', 'generation', 'network-id', 'network-cost'],
+ format: function (o) {
+ var str = 'candidate:%s %d %s %d %s %d typ %s';
+
+ str += (o.raddr != null) ? ' raddr %s rport %d' : '%v%v';
+
+ // NB: candidate has three optional chunks, so %void middles one if it's missing
+ str += (o.tcptype != null) ? ' tcptype %s' : '%v';
+
+ if (o.generation != null) {
+ str += ' generation %d';
+ }
+
+ str += (o['network-id'] != null) ? ' network-id %d' : '%v';
+ str += (o['network-cost'] != null) ? ' network-cost %d' : '%v';
+ return str;
+ }
+ },
+ {
+ // a=end-of-candidates (keep after the candidates line for readability)
+ name: 'endOfCandidates',
+ reg: /^(end-of-candidates)/
+ },
+ {
+ // a=remote-candidates:1 203.0.113.1 54400 2 203.0.113.1 54401 ...
+ name: 'remoteCandidates',
+ reg: /^remote-candidates:(.*)/,
+ format: 'remote-candidates:%s'
+ },
+ {
+ // a=ice-options:google-ice
+ name: 'iceOptions',
+ reg: /^ice-options:(\S*)/,
+ format: 'ice-options:%s'
+ },
+ {
+ // a=ssrc:2566107569 cname:t9YU8M1UxTF8Y1A1
+ push: 'ssrcs',
+ reg: /^ssrc:(\d*) ([\w_-]*)(?::(.*))?/,
+ names: ['id', 'attribute', 'value'],
+ format: function (o) {
+ var str = 'ssrc:%d';
+ if (o.attribute != null) {
+ str += ' %s';
+ if (o.value != null) {
+ str += ':%s';
+ }
+ }
+ return str;
+ }
+ },
+ {
+ // a=ssrc-group:FEC 1 2
+ // a=ssrc-group:FEC-FR 3004364195 1080772241
+ push: 'ssrcGroups',
+ // token-char = %x21 / %x23-27 / %x2A-2B / %x2D-2E / %x30-39 / %x41-5A / %x5E-7E
+ reg: /^ssrc-group:([\x21\x23\x24\x25\x26\x27\x2A\x2B\x2D\x2E\w]*) (.*)/,
+ names: ['semantics', 'ssrcs'],
+ format: 'ssrc-group:%s %s'
+ },
+ {
+ // a=msid-semantic: WMS Jvlam5X3SX1OP6pn20zWogvaKJz5Hjf9OnlV
+ name: 'msidSemantic',
+ reg: /^msid-semantic:\s?(\w*) (\S*)/,
+ names: ['semantic', 'token'],
+ format: 'msid-semantic: %s %s' // space after ':' is not accidental
+ },
+ {
+ // a=group:BUNDLE audio video
+ push: 'groups',
+ reg: /^group:(\w*) (.*)/,
+ names: ['type', 'mids'],
+ format: 'group:%s %s'
+ },
+ {
+ // a=rtcp-mux
+ name: 'rtcpMux',
+ reg: /^(rtcp-mux)/
+ },
+ {
+ // a=rtcp-rsize
+ name: 'rtcpRsize',
+ reg: /^(rtcp-rsize)/
+ },
+ {
+ // a=sctpmap:5000 webrtc-datachannel 1024
+ name: 'sctpmap',
+ reg: /^sctpmap:([\w_/]*) (\S*)(?: (\S*))?/,
+ names: ['sctpmapNumber', 'app', 'maxMessageSize'],
+ format: function (o) {
+ return (o.maxMessageSize != null)
+ ? 'sctpmap:%s %s %s'
+ : 'sctpmap:%s %s';
+ }
+ },
+ {
+ // a=x-google-flag:conference
+ name: 'xGoogleFlag',
+ reg: /^x-google-flag:([^\s]*)/,
+ format: 'x-google-flag:%s'
+ },
+ {
+ // a=rid:1 send max-width=1280;max-height=720;max-fps=30;depend=0
+ push: 'rids',
+ reg: /^rid:([\d\w]+) (\w+)(?: ([\S| ]*))?/,
+ names: ['id', 'direction', 'params'],
+ format: function (o) {
+ return (o.params) ? 'rid:%s %s %s' : 'rid:%s %s';
+ }
+ },
+ {
+ // a=imageattr:97 send [x=800,y=640,sar=1.1,q=0.6] [x=480,y=320] recv [x=330,y=250]
+ // a=imageattr:* send [x=800,y=640] recv *
+ // a=imageattr:100 recv [x=320,y=240]
+ push: 'imageattrs',
+ reg: new RegExp(
+ // a=imageattr:97
+ '^imageattr:(\\d+|\\*)' +
+ // send [x=800,y=640,sar=1.1,q=0.6] [x=480,y=320]
+ '[\\s\\t]+(send|recv)[\\s\\t]+(\\*|\\[\\S+\\](?:[\\s\\t]+\\[\\S+\\])*)' +
+ // recv [x=330,y=250]
+ '(?:[\\s\\t]+(recv|send)[\\s\\t]+(\\*|\\[\\S+\\](?:[\\s\\t]+\\[\\S+\\])*))?'
+ ),
+ names: ['pt', 'dir1', 'attrs1', 'dir2', 'attrs2'],
+ format: function (o) {
+ return 'imageattr:%s %s %s' + (o.dir2 ? ' %s %s' : '');
+ }
+ },
+ {
+ // a=simulcast:send 1,2,3;~4,~5 recv 6;~7,~8
+ // a=simulcast:recv 1;4,5 send 6;7
+ name: 'simulcast',
+ reg: new RegExp(
+ // a=simulcast:
+ '^simulcast:' +
+ // send 1,2,3;~4,~5
+ '(send|recv) ([a-zA-Z0-9\\-_~;,]+)' +
+ // space + recv 6;~7,~8
+ '(?:\\s?(send|recv) ([a-zA-Z0-9\\-_~;,]+))?' +
+ // end
+ '$'
+ ),
+ names: ['dir1', 'list1', 'dir2', 'list2'],
+ format: function (o) {
+ return 'simulcast:%s %s' + (o.dir2 ? ' %s %s' : '');
+ }
+ },
+ {
+ // old simulcast draft 03 (implemented by Firefox)
+ // https://tools.ietf.org/html/draft-ietf-mmusic-sdp-simulcast-03
+ // a=simulcast: recv pt=97;98 send pt=97
+ // a=simulcast: send rid=5;6;7 paused=6,7
+ name: 'simulcast_03',
+ reg: /^simulcast:[\s\t]+([\S+\s\t]+)$/,
+ names: ['value'],
+ format: 'simulcast: %s'
+ },
+ {
+ // a=framerate:25
+ // a=framerate:29.97
+ name: 'framerate',
+ reg: /^framerate:(\d+(?:$|\.\d+))/,
+ format: 'framerate:%s'
+ },
+ {
+ // RFC4570
+ // a=source-filter: incl IN IP4 239.5.2.31 10.1.15.5
+ name: 'sourceFilter',
+ reg: /^source-filter: *(excl|incl) (\S*) (IP4|IP6|\*) (\S*) (.*)/,
+ names: ['filterMode', 'netType', 'addressTypes', 'destAddress', 'srcList'],
+ format: 'source-filter: %s %s %s %s %s'
+ },
+ {
+ // a=bundle-only
+ name: 'bundleOnly',
+ reg: /^(bundle-only)/
+ },
+ {
+ // a=label:1
+ name: 'label',
+ reg: /^label:(.+)/,
+ format: 'label:%s'
+ },
+ {
+ // RFC version 26 for SCTP over DTLS
+ // https://tools.ietf.org/html/draft-ietf-mmusic-sctp-sdp-26#section-5
+ name: 'sctpPort',
+ reg: /^sctp-port:(\d+)$/,
+ format: 'sctp-port:%s'
+ },
+ {
+ // RFC version 26 for SCTP over DTLS
+ // https://tools.ietf.org/html/draft-ietf-mmusic-sctp-sdp-26#section-6
+ name: 'maxMessageSize',
+ reg: /^max-message-size:(\d+)$/,
+ format: 'max-message-size:%s'
+ },
+ {
+ // RFC7273
+ // a=ts-refclk:ptp=IEEE1588-2008:39-A7-94-FF-FE-07-CB-D0:37
+ push:'tsRefClocks',
+ reg: /^ts-refclk:([^\s=]*)(?:=(\S*))?/,
+ names: ['clksrc', 'clksrcExt'],
+ format: function (o) {
+ return 'ts-refclk:%s' + (o.clksrcExt != null ? '=%s' : '');
+ }
+ },
+ {
+ // RFC7273
+ // a=mediaclk:direct=963214424
+ name:'mediaClk',
+ reg: /^mediaclk:(?:id=(\S*))? *([^\s=]*)(?:=(\S*))?(?: *rate=(\d+)\/(\d+))?/,
+ names: ['id', 'mediaClockName', 'mediaClockValue', 'rateNumerator', 'rateDenominator'],
+ format: function (o) {
+ var str = 'mediaclk:';
+ str += (o.id != null ? 'id=%s %s' : '%v%s');
+ str += (o.mediaClockValue != null ? '=%s' : '');
+ str += (o.rateNumerator != null ? ' rate=%s' : '');
+ str += (o.rateDenominator != null ? '/%s' : '');
+ return str;
+ }
+ },
+ {
+ // a=keywds:keywords
+ name: 'keywords',
+ reg: /^keywds:(.+)$/,
+ format: 'keywds:%s'
+ },
+ {
+ // a=content:main
+ name: 'content',
+ reg: /^content:(.+)/,
+ format: 'content:%s'
+ },
+ // BFCP https://tools.ietf.org/html/rfc4583
+ {
+ // a=floorctrl:c-s
+ name: 'bfcpFloorCtrl',
+ reg: /^floorctrl:(c-only|s-only|c-s)/,
+ format: 'floorctrl:%s'
+ },
+ {
+ // a=confid:1
+ name: 'bfcpConfId',
+ reg: /^confid:(\d+)/,
+ format: 'confid:%s'
+ },
+ {
+ // a=userid:1
+ name: 'bfcpUserId',
+ reg: /^userid:(\d+)/,
+ format: 'userid:%s'
+ },
+ {
+ // a=floorid:1
+ name: 'bfcpFloorId',
+ reg: /^floorid:(.+) (?:m-stream|mstrm):(.+)/,
+ names: ['id', 'mStream'],
+ format: 'floorid:%s mstrm:%s'
+ },
+ {
+ // any a= that we don't understand is kept verbatim on media.invalid
+ push: 'invalid',
+ names: ['value']
+ }
+ ]
+};
+
+// set sensible defaults to avoid polluting the grammar with boring details
+Object.keys(grammar).forEach(function (key) {
+ var objs = grammar[key];
+ objs.forEach(function (obj) {
+ if (!obj.reg) {
+ obj.reg = /(.*)/;
+ }
+ if (!obj.format) {
+ obj.format = '%s';
+ }
+ });
+});
diff --git a/comm/chat/protocols/matrix/lib/sdp-transform/index.js b/comm/chat/protocols/matrix/lib/sdp-transform/index.js
new file mode 100644
index 0000000000..0a27894f89
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/sdp-transform/index.js
@@ -0,0 +1,11 @@
+var parser = require('./parser');
+var writer = require('./writer');
+
+exports.write = writer;
+exports.parse = parser.parse;
+exports.parseParams = parser.parseParams;
+exports.parseFmtpConfig = parser.parseFmtpConfig; // Alias of parseParams().
+exports.parsePayloads = parser.parsePayloads;
+exports.parseRemoteCandidates = parser.parseRemoteCandidates;
+exports.parseImageAttributes = parser.parseImageAttributes;
+exports.parseSimulcastStreamList = parser.parseSimulcastStreamList;
diff --git a/comm/chat/protocols/matrix/lib/sdp-transform/parser.js b/comm/chat/protocols/matrix/lib/sdp-transform/parser.js
new file mode 100644
index 0000000000..ac863971a7
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/sdp-transform/parser.js
@@ -0,0 +1,124 @@
+var toIntIfInt = function (v) {
+ return String(Number(v)) === v ? Number(v) : v;
+};
+
+var attachProperties = function (match, location, names, rawName) {
+ if (rawName && !names) {
+ location[rawName] = toIntIfInt(match[1]);
+ }
+ else {
+ for (var i = 0; i < names.length; i += 1) {
+ if (match[i+1] != null) {
+ location[names[i]] = toIntIfInt(match[i+1]);
+ }
+ }
+ }
+};
+
+var parseReg = function (obj, location, content) {
+ var needsBlank = obj.name && obj.names;
+ if (obj.push && !location[obj.push]) {
+ location[obj.push] = [];
+ }
+ else if (needsBlank && !location[obj.name]) {
+ location[obj.name] = {};
+ }
+ var keyLocation = obj.push ?
+ {} : // blank object that will be pushed
+ needsBlank ? location[obj.name] : location; // otherwise, named location or root
+
+ attachProperties(content.match(obj.reg), keyLocation, obj.names, obj.name);
+
+ if (obj.push) {
+ location[obj.push].push(keyLocation);
+ }
+};
+
+var grammar = require('./grammar');
+var validLine = RegExp.prototype.test.bind(/^([a-z])=(.*)/);
+
+exports.parse = function (sdp) {
+ var session = {}
+ , media = []
+ , location = session; // points at where properties go under (one of the above)
+
+ // parse lines we understand
+ sdp.split(/(\r\n|\r|\n)/).filter(validLine).forEach(function (l) {
+ var type = l[0];
+ var content = l.slice(2);
+ if (type === 'm') {
+ media.push({rtp: [], fmtp: []});
+ location = media[media.length-1]; // point at latest media line
+ }
+
+ for (var j = 0; j < (grammar[type] || []).length; j += 1) {
+ var obj = grammar[type][j];
+ if (obj.reg.test(content)) {
+ return parseReg(obj, location, content);
+ }
+ }
+ });
+
+ session.media = media; // link it up
+ return session;
+};
+
+var paramReducer = function (acc, expr) {
+ var s = expr.split(/=(.+)/, 2);
+ if (s.length === 2) {
+ acc[s[0]] = toIntIfInt(s[1]);
+ } else if (s.length === 1 && expr.length > 1) {
+ acc[s[0]] = undefined;
+ }
+ return acc;
+};
+
+exports.parseParams = function (str) {
+ return str.split(/;\s?/).reduce(paramReducer, {});
+};
+
+// For backward compatibility - alias will be removed in 3.0.0
+exports.parseFmtpConfig = exports.parseParams;
+
+exports.parsePayloads = function (str) {
+ return str.toString().split(' ').map(Number);
+};
+
+exports.parseRemoteCandidates = function (str) {
+ var candidates = [];
+ var parts = str.split(' ').map(toIntIfInt);
+ for (var i = 0; i < parts.length; i += 3) {
+ candidates.push({
+ component: parts[i],
+ ip: parts[i + 1],
+ port: parts[i + 2]
+ });
+ }
+ return candidates;
+};
+
+exports.parseImageAttributes = function (str) {
+ return str.split(' ').map(function (item) {
+ return item.substring(1, item.length-1).split(',').reduce(paramReducer, {});
+ });
+};
+
+exports.parseSimulcastStreamList = function (str) {
+ return str.split(';').map(function (stream) {
+ return stream.split(',').map(function (format) {
+ var scid, paused = false;
+
+ if (format[0] !== '~') {
+ scid = toIntIfInt(format);
+ } else {
+ scid = toIntIfInt(format.substring(1, format.length));
+ paused = true;
+ }
+
+ return {
+ scid: scid,
+ paused: paused
+ };
+ });
+ });
+};
diff --git a/comm/chat/protocols/matrix/lib/sdp-transform/writer.js b/comm/chat/protocols/matrix/lib/sdp-transform/writer.js
new file mode 100644
index 0000000000..decdf480a8
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/sdp-transform/writer.js
@@ -0,0 +1,114 @@
+var grammar = require('./grammar');
+
+// customized util.format - discards excess arguments and can void middle ones
+var formatRegExp = /%[sdv%]/g;
+var format = function (formatStr) {
+ var i = 1;
+ var args = arguments;
+ var len = args.length;
+ return formatStr.replace(formatRegExp, function (x) {
+ if (i >= len) {
+ return x; // missing argument
+ }
+ var arg = args[i];
+ i += 1;
+ switch (x) {
+ case '%%':
+ return '%';
+ case '%s':
+ return String(arg);
+ case '%d':
+ return Number(arg);
+ case '%v':
+ return '';
+ }
+ });
+ // NB: we discard excess arguments - they are typically undefined from makeLine
+};
+
+var makeLine = function (type, obj, location) {
+ var str = obj.format instanceof Function ?
+ (obj.format(obj.push ? location : location[obj.name])) :
+ obj.format;
+
+ var args = [type + '=' + str];
+ if (obj.names) {
+ for (var i = 0; i < obj.names.length; i += 1) {
+ var n = obj.names[i];
+ if (obj.name) {
+ args.push(location[obj.name][n]);
+ }
+ else { // for mLine and push attributes
+ args.push(location[obj.names[i]]);
+ }
+ }
+ }
+ else {
+ args.push(location[obj.name]);
+ }
+ return format.apply(null, args);
+};
+
+// RFC specified order
+// TODO: extend this with all the rest
+var defaultOuterOrder = [
+ 'v', 'o', 's', 'i',
+ 'u', 'e', 'p', 'c',
+ 'b', 't', 'r', 'z', 'a'
+];
+var defaultInnerOrder = ['i', 'c', 'b', 'a'];
+
+
+module.exports = function (session, opts) {
+ opts = opts || {};
+ // ensure certain properties exist
+ if (session.version == null) {
+ session.version = 0; // 'v=0' must be there (only defined version atm)
+ }
+ if (session.name == null) {
+ session.name = ' '; // 's= ' must be there if no meaningful name set
+ }
+ session.media.forEach(function (mLine) {
+ if (mLine.payloads == null) {
+ mLine.payloads = '';
+ }
+ });
+
+ var outerOrder = opts.outerOrder || defaultOuterOrder;
+ var innerOrder = opts.innerOrder || defaultInnerOrder;
+ var sdp = [];
+
+ // loop through outerOrder for matching properties on session
+ outerOrder.forEach(function (type) {
+ grammar[type].forEach(function (obj) {
+ if (obj.name in session && session[obj.name] != null) {
+ sdp.push(makeLine(type, obj, session));
+ }
+ else if (obj.push in session && session[obj.push] != null) {
+ session[obj.push].forEach(function (el) {
+ sdp.push(makeLine(type, obj, el));
+ });
+ }
+ });
+ });
+
+ // then for each media line, follow the innerOrder
+ session.media.forEach(function (mLine) {
+ sdp.push(makeLine('m', grammar.m[0], mLine));
+
+ innerOrder.forEach(function (type) {
+ grammar[type].forEach(function (obj) {
+ if (obj.name in mLine && mLine[obj.name] != null) {
+ sdp.push(makeLine(type, obj, mLine));
+ }
+ else if (obj.push in mLine && mLine[obj.push] != null) {
+ mLine[obj.push].forEach(function (el) {
+ sdp.push(makeLine(type, obj, el));
+ });
+ }
+ });
+ });
+ });
+
+ return sdp.join('\r\n') + '\r\n';
+};
diff --git a/comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE b/comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE
new file mode 100644
index 0000000000..1882d5d384
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2016 Vitaly Puzrin.
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/matrix/lib/unhomoglyph/data.json b/comm/chat/protocols/matrix/lib/unhomoglyph/data.json
new file mode 100644
index 0000000000..209962e8bf
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/unhomoglyph/data.json
@@ -0,0 +1,6313 @@
+{
+ "0": "O",
+ "1": "l",
+ "֭": "֖",
+ "֮": "֘",
+ "֨": "֙",
+ "֤": "֚",
+ "᪴": "ۛ",
+ "⃛": "ۛ",
+ "ؙ": "̓",
+ "ࣳ": "̓",
+ "̓": "̓",
+ "̕": "̓",
+ "ُ": "̓",
+ "ٝ": "̔",
+ "֜": "́",
+ "֝": "́",
+ "ؘ": "́",
+ "݇": "́",
+ "́": "́",
+ "॔": "́",
+ "َ": "́",
+ "̀": "̀",
+ "॓": "̀",
+ "̌": "̆",
+ "꙼": "̆",
+ "٘": "̆",
+ "ٚ": "̆",
+ "ͮ": "̆",
+ "ۨ": "̆̇",
+ "̐": "̆̇",
+ "ँ": "̆̇",
+ "ঁ": "̆̇",
+ "ઁ": "̆̇",
+ "ଁ": "̆̇",
+ "ఀ": "̆̇",
+ "ಁ": "̆̇",
+ "ഁ": "̆̇",
+ "𑒿": "̆̇",
+ "᳐": "̂",
+ "̑": "̂",
+ "ٛ": "̂",
+ "߮": "̂",
+ "꛰": "̂",
+ "֯": "̊",
+ "۟": "̊",
+ "៓": "̊",
+ "゚": "̊",
+ "ْ": "̊",
+ "ஂ": "̊",
+ "ံ": "̊",
+ "ំ": "̊",
+ "𑌀": "̊",
+ "ํ": "̊",
+ "ໍ": "̊",
+ "ͦ": "̊",
+ "ⷪ": "̊",
+ "࣫": "̈",
+ "߳": "̈",
+ "ً": "̋",
+ "ࣰ": "̋",
+ "͂": "̃",
+ "ٓ": "̃",
+ "ׄ": "̇",
+ "۬": "̇",
+ "݀": "̇",
+ "࣪": "̇",
+ "݁": "̇",
+ "͘": "̇",
+ "ֹ": "̇",
+ "ֺ": "̇",
+ "ׂ": "̇",
+ "ׁ": "̇",
+ "߭": "̇",
+ "ं": "̇",
+ "ਂ": "̇",
+ "ં": "̇",
+ "்": "̇",
+ "̷": "̸",
+ "᪷": "̨",
+ "̢": "̨",
+ "ͅ": "̨",
+ "᳒": "̄",
+ "̅": "̄",
+ "ٙ": "̄",
+ "߫": "̄",
+ "꛱": "̄",
+ "᳚": "̎",
+ "ٗ": "̒",
+ "͗": "͐",
+ "ࣿ": "͐",
+ "ࣸ": "͐",
+ "ऀ": "͒",
+ "᳭": "̖",
+ "᳜": "̩",
+ "ٖ": "̩",
+ "᳕": "̫",
+ "͇": "̳",
+ "ࣹ": "͔",
+ "ࣺ": "͕",
+ "゛": "゙",
+ "゜": "゚",
+ "̶": "̵",
+ "〬": "̉",
+ "ׅ": "̣",
+ "࣭": "̣",
+ "᳝": "̣",
+ "ִ": "̣",
+ "ٜ": "̣",
+ "़": "̣",
+ "়": "̣",
+ "਼": "̣",
+ "઼": "̣",
+ "଼": "̣",
+ "𑇊": "̣",
+ "𑓃": "̣",
+ "𐨺": "̣",
+ "࣮": "̤",
+ "᳞": "̤",
+ "༷": "̥",
+ "〭": "̥",
+ "̧": "̦",
+ "̡": "̦",
+ "̹": "̦",
+ "᳙": "̭",
+ "᳘": "̮",
+ "॒": "̱",
+ "̠": "̱",
+ "ࣱ": "ٌ",
+ "ࣨ": "ٌ",
+ "ࣥ": "ٌ",
+ "ﱞ": "ﹲّ",
+ "ࣲ": "ٍ",
+ "ﱟ": "ﹴّ",
+ "ﳲ": "ﹷّ",
+ "ﱠ": "ﹶّ",
+ "ﳳ": "ﹹّ",
+ "ﱡ": "ﹸّ",
+ "ؚ": "ِ",
+ "̗": "ِ",
+ "ﳴ": "ﹻّ",
+ "ﱢ": "ﹺّ",
+ "ﱣ": "ﹼٰ",
+ "ٟ": "ٕ",
+ "̍": "ٰ",
+ "݂": "ܼ",
+ "ਃ": "ঃ",
+ "ః": "ঃ",
+ "ಃ": "ঃ",
+ "ഃ": "ঃ",
+ "ඃ": "ঃ",
+ "း": "ঃ",
+ "𑓁": "ঃ",
+ "់": "่",
+ "່": "่",
+ "້": "้",
+ "໊": "๊",
+ "໋": "๋",
+ "꙯": "⃩",
+ "\u2028": " ",
+ "\u2029": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ " ": " ",
+ "ߺ": "_",
+ "﹍": "_",
+ "﹎": "_",
+ "﹏": "_",
+ "‐": "-",
+ "‑": "-",
+ "‒": "-",
+ "–": "-",
+ "﹘": "-",
+ "۔": "-",
+ "⁃": "-",
+ "˗": "-",
+ "−": "-",
+ "➖": "-",
+ "Ⲻ": "-",
+ "⨩": "-̓",
+ "⸚": "-̈",
+ "﬩": "-̇",
+ "∸": "-̇",
+ "⨪": "-̣",
+ "꓾": "-.",
+ "~": "〜",
+ "؍": ",",
+ "٫": ",",
+ "‚": ",",
+ "¸": ",",
+ "ꓹ": ",",
+ "⸲": "،",
+ "٬": "،",
+ ";": ";",
+ "⸵": "؛",
+ "ः": ":",
+ "ઃ": ":",
+ ":": ":",
+ "։": ":",
+ "܃": ":",
+ "܄": ":",
+ "᛬": ":",
+ "︰": ":",
+ "᠃": ":",
+ "᠉": ":",
+ "⁚": ":",
+ "׃": ":",
+ "˸": ":",
+ "꞉": ":",
+ "∶": ":",
+ "ː": ":",
+ "ꓽ": ":",
+ "⩴": "::=",
+ "⧴": ":→",
+ "!": "!",
+ "ǃ": "!",
+ "ⵑ": "!",
+ "‼": "!!",
+ "⁉": "!?",
+ "ʔ": "?",
+ "Ɂ": "?",
+ "ॽ": "?",
+ "Ꭾ": "?",
+ "ꛫ": "?",
+ "⁈": "?!",
+ "⁇": "??",
+ "⸮": "؟",
+ "𝅭": ".",
+ "․": ".",
+ "܁": ".",
+ "܂": ".",
+ "꘎": ".",
+ "𐩐": ".",
+ "٠": ".",
+ "۰": ".",
+ "ꓸ": ".",
+ "ꓻ": ".,",
+ "‥": "..",
+ "ꓺ": "..",
+ "…": "...",
+ "꛴": "꛳꛳",
+ "・": "·",
+ "・": "·",
+ "᛫": "·",
+ "·": "·",
+ "⸱": "·",
+ "𐄁": "·",
+ "•": "·",
+ "‧": "·",
+ "∙": "·",
+ "⋅": "·",
+ "ꞏ": "·",
+ "ᐧ": "·",
+ "⋯": "···",
+ "ⵈ": "···",
+ "ᑄ": "·<",
+ "⋗": "·>",
+ "ᐷ": "·>",
+ "ᑀ": "·>",
+ "ᔯ": "·4",
+ "ᑾ": "·b",
+ "ᒀ": "·ḃ",
+ "ᑺ": "·d",
+ "ᒘ": "·J",
+ "ᒶ": "·L",
+ "ᑶ": "·P",
+ "ᑗ": "·U",
+ "ᐺ": "·V",
+ "ᐼ": "·Ʌ",
+ "ᒮ": "·Γ",
+ "ᐎ": "·Δ",
+ "ᑙ": "·Ո",
+ "ᐌ": "·ᐁ",
+ "ᐐ": "·ᐄ",
+ "ᐒ": "·ᐅ",
+ "ᐔ": "·ᐆ",
+ "ᐗ": "·ᐊ",
+ "ᐙ": "·ᐋ",
+ "ᐾ": "·ᐲ",
+ "ᑂ": "·ᐴ",
+ "ᑆ": "·ᐹ",
+ "ᑛ": "·ᑏ",
+ "ᑔ": "·ᑐ",
+ "ᑝ": "·ᑐ",
+ "ᑟ": "·ᑑ",
+ "ᑡ": "·ᑕ",
+ "ᑣ": "·ᑖ",
+ "ᑴ": "·ᑫ",
+ "ᑸ": "·ᑮ",
+ "ᑼ": "·ᑰ",
+ "ᒒ": "·ᒉ",
+ "ᒔ": "·ᒋ",
+ "ᒖ": "·ᒌ",
+ "ᒚ": "·ᒎ",
+ "ᒜ": "·ᒐ",
+ "ᒞ": "·ᒑ",
+ "ᒬ": "·ᒣ",
+ "ᒰ": "·ᒦ",
+ "ᒲ": "·ᒧ",
+ "ᒴ": "·ᒨ",
+ "ᒸ": "·ᒫ",
+ "ᓉ": "·ᓀ",
+ "ᣆ": "·ᓂ",
+ "ᣈ": "·ᓃ",
+ "ᣊ": "·ᓄ",
+ "ᣌ": "·ᓅ",
+ "ᓋ": "·ᓇ",
+ "ᓍ": "·ᓈ",
+ "ᓜ": "·ᓓ",
+ "ᓞ": "·ᓕ",
+ "ᓠ": "·ᓖ",
+ "ᓢ": "·ᓗ",
+ "ᓤ": "·ᓘ",
+ "ᓦ": "·ᓚ",
+ "ᓨ": "·ᓛ",
+ "ᓶ": "·ᓭ",
+ "ᓸ": "·ᓯ",
+ "ᓺ": "·ᓰ",
+ "ᓼ": "·ᓱ",
+ "ᓾ": "·ᓲ",
+ "ᔀ": "·ᓴ",
+ "ᔂ": "·ᓵ",
+ "ᔗ": "·ᔐ",
+ "ᔙ": "·ᔑ",
+ "ᔛ": "·ᔒ",
+ "ᔝ": "·ᔓ",
+ "ᔟ": "·ᔔ",
+ "ᔡ": "·ᔕ",
+ "ᔣ": "·ᔖ",
+ "ᔱ": "·ᔨ",
+ "ᔳ": "·ᔩ",
+ "ᔵ": "·ᔪ",
+ "ᔷ": "·ᔫ",
+ "ᔹ": "·ᔭ",
+ "ᔻ": "·ᔮ",
+ "ᣎ": "·ᕃ",
+ "ᣏ": "·ᕆ",
+ "ᣐ": "·ᕇ",
+ "ᣑ": "·ᕈ",
+ "ᣒ": "·ᕉ",
+ "ᣓ": "·ᕋ",
+ "ᕎ": "·ᕌ",
+ "ᕛ": "·ᕚ",
+ "ᕨ": "·ᕧ",
+ "ᢳ": "·ᢱ",
+ "ᢶ": "·ᢴ",
+ "ᢹ": "·ᢸ",
+ "ᣂ": "·ᣀ",
+ "꠰": "।",
+ "॥": "।।",
+ "᰼": "᰻᰻",
+ "။": "၊၊",
+ "᪩": "᪨᪨",
+ "᪫": "᪪᪨",
+ "᭟": "᭞᭞",
+ "𐩗": "𐩖𐩖",
+ "𑑌": "𑑋𑑋",
+ "𑙂": "𑙁𑙁",
+ "𑱂": "𑱁𑱁",
+ "᱿": "᱾᱾",
+ "՝": "'",
+ "'": "'",
+ "‘": "'",
+ "’": "'",
+ "‛": "'",
+ "′": "'",
+ "‵": "'",
+ "՚": "'",
+ "׳": "'",
+ "`": "'",
+ "`": "'",
+ "`": "'",
+ "´": "'",
+ "΄": "'",
+ "´": "'",
+ "᾽": "'",
+ "᾿": "'",
+ "῾": "'",
+ "ʹ": "'",
+ "ʹ": "'",
+ "ˈ": "'",
+ "ˊ": "'",
+ "ˋ": "'",
+ "˴": "'",
+ "ʻ": "'",
+ "ʽ": "'",
+ "ʼ": "'",
+ "ʾ": "'",
+ "ꞌ": "'",
+ "י": "'",
+ "ߴ": "'",
+ "ߵ": "'",
+ "ᑊ": "'",
+ "ᛌ": "'",
+ "𖽑": "'",
+ "𖽒": "'",
+ "᳓": "''",
+ "\"": "''",
+ """: "''",
+ "“": "''",
+ "”": "''",
+ "‟": "''",
+ "″": "''",
+ "‶": "''",
+ "〃": "''",
+ "״": "''",
+ "˝": "''",
+ "ʺ": "''",
+ "˶": "''",
+ "ˮ": "''",
+ "ײ": "''",
+ "‴": "'''",
+ "‷": "'''",
+ "⁗": "''''",
+ "Ɓ": "'B",
+ "Ɗ": "'D",
+ "ʼn": "'n",
+ "Ƥ": "'P",
+ "Ƭ": "'T",
+ "Ƴ": "'Y",
+ "[": "(",
+ "❨": "(",
+ "❲": "(",
+ "〔": "(",
+ "﴾": "(",
+ "⸨": "((",
+ "㈠": "(ー)",
+ "⑵": "(2)",
+ "⒇": "(2O)",
+ "⑶": "(3)",
+ "⑷": "(4)",
+ "⑸": "(5)",
+ "⑹": "(6)",
+ "⑺": "(7)",
+ "⑻": "(8)",
+ "⑼": "(9)",
+ "⒜": "(a)",
+ "🄐": "(A)",
+ "⒝": "(b)",
+ "🄑": "(B)",
+ "⒞": "(c)",
+ "🄒": "(C)",
+ "⒟": "(d)",
+ "🄓": "(D)",
+ "⒠": "(e)",
+ "🄔": "(E)",
+ "⒡": "(f)",
+ "🄕": "(F)",
+ "⒢": "(g)",
+ "🄖": "(G)",
+ "⒣": "(h)",
+ "🄗": "(H)",
+ "⒤": "(i)",
+ "⒥": "(j)",
+ "🄙": "(J)",
+ "⒦": "(k)",
+ "🄚": "(K)",
+ "⑴": "(l)",
+ "🄘": "(l)",
+ "⒧": "(l)",
+ "🄛": "(L)",
+ "⑿": "(l2)",
+ "⒀": "(l3)",
+ "⒁": "(l4)",
+ "⒂": "(l5)",
+ "⒃": "(l6)",
+ "⒄": "(l7)",
+ "⒅": "(l8)",
+ "⒆": "(l9)",
+ "⑾": "(ll)",
+ "⑽": "(lO)",
+ "🄜": "(M)",
+ "⒩": "(n)",
+ "🄝": "(N)",
+ "⒪": "(o)",
+ "🄞": "(O)",
+ "⒫": "(p)",
+ "🄟": "(P)",
+ "⒬": "(q)",
+ "🄠": "(Q)",
+ "⒭": "(r)",
+ "🄡": "(R)",
+ "⒨": "(rn)",
+ "⒮": "(s)",
+ "🄢": "(S)",
+ "🄪": "(S)",
+ "⒯": "(t)",
+ "🄣": "(T)",
+ "⒰": "(u)",
+ "🄤": "(U)",
+ "⒱": "(v)",
+ "🄥": "(V)",
+ "⒲": "(w)",
+ "🄦": "(W)",
+ "⒳": "(x)",
+ "🄧": "(X)",
+ "⒴": "(y)",
+ "🄨": "(Y)",
+ "⒵": "(z)",
+ "🄩": "(Z)",
+ "㈀": "(ᄀ)",
+ "㈎": "(가)",
+ "㈁": "(ᄂ)",
+ "㈏": "(나)",
+ "㈂": "(ᄃ)",
+ "㈐": "(다)",
+ "㈃": "(ᄅ)",
+ "㈑": "(라)",
+ "㈄": "(ᄆ)",
+ "㈒": "(마)",
+ "㈅": "(ᄇ)",
+ "㈓": "(바)",
+ "㈆": "(ᄉ)",
+ "㈔": "(사)",
+ "㈇": "(ᄋ)",
+ "㈕": "(아)",
+ "㈝": "(오전)",
+ "㈞": "(오후)",
+ "㈈": "(ᄌ)",
+ "㈖": "(자)",
+ "㈜": "(주)",
+ "㈉": "(ᄎ)",
+ "㈗": "(차)",
+ "㈊": "(ᄏ)",
+ "㈘": "(카)",
+ "㈋": "(ᄐ)",
+ "㈙": "(타)",
+ "㈌": "(ᄑ)",
+ "㈚": "(파)",
+ "㈍": "(ᄒ)",
+ "㈛": "(하)",
+ "㈦": "(七)",
+ "㈢": "(三)",
+ "🉁": "(三)",
+ "㈨": "(九)",
+ "㈡": "(二)",
+ "🉂": "(二)",
+ "㈤": "(五)",
+ "㈹": "(代)",
+ "㈽": "(企)",
+ "㉁": "(休)",
+ "㈧": "(八)",
+ "㈥": "(六)",
+ "㈸": "(労)",
+ "🉇": "(勝)",
+ "㈩": "(十)",
+ "㈿": "(協)",
+ "㈴": "(名)",
+ "㈺": "(呼)",
+ "㈣": "(四)",
+ "㈯": "(土)",
+ "㈻": "(学)",
+ "🉃": "(安)",
+ "🉅": "(打)",
+ "🉈": "(敗)",
+ "㈰": "(日)",
+ "㈪": "(月)",
+ "㈲": "(有)",
+ "㈭": "(木)",
+ "🉀": "(本)",
+ "㈱": "(株)",
+ "㈬": "(水)",
+ "㈫": "(火)",
+ "🉄": "(点)",
+ "㈵": "(特)",
+ "🉆": "(盗)",
+ "㈼": "(監)",
+ "㈳": "(社)",
+ "㈷": "(祝)",
+ "㉀": "(祭)",
+ "㉂": "(自)",
+ "㉃": "(至)",
+ "㈶": "(財)",
+ "㈾": "(資)",
+ "㈮": "(金)",
+ "]": ")",
+ "❩": ")",
+ "❳": ")",
+ "〕": ")",
+ "﴿": ")",
+ "⸩": "))",
+ "❴": "{",
+ "𝄔": "{",
+ "❵": "}",
+ "〚": "⟦",
+ "〛": "⟧",
+ "⟨": "❬",
+ "〈": "❬",
+ "〈": "❬",
+ "㇛": "❬",
+ "く": "❬",
+ "𡿨": "❬",
+ "⟩": "❭",
+ "〉": "❭",
+ "〉": "❭",
+ "^": "︿",
+ "⸿": "¶",
+ "⁎": "*",
+ "٭": "*",
+ "∗": "*",
+ "𐌟": "*",
+ "᜵": "/",
+ "⁁": "/",
+ "∕": "/",
+ "⁄": "/",
+ "╱": "/",
+ "⟋": "/",
+ "⧸": "/",
+ "𝈺": "/",
+ "㇓": "/",
+ "〳": "/",
+ "Ⳇ": "/",
+ "ノ": "/",
+ "丿": "/",
+ "⼃": "/",
+ "⧶": "/̄",
+ "⫽": "//",
+ "⫻": "///",
+ "\": "\\",
+ "﹨": "\\",
+ "∖": "\\",
+ "⟍": "\\",
+ "⧵": "\\",
+ "⧹": "\\",
+ "𝈏": "\\",
+ "𝈻": "\\",
+ "㇔": "\\",
+ "丶": "\\",
+ "⼂": "\\",
+ "⳹": "\\\\",
+ "⑊": "\\\\",
+ "⟈": "\\ᑕ",
+ "ꝸ": "&",
+ "૰": "॰",
+ "𑂻": "॰",
+ "𑇇": "॰",
+ "⚬": "॰",
+ "𑇛": "꣼",
+ "៙": "๏",
+ "៕": "๚",
+ "៚": "๛",
+ "༌": "་",
+ "༎": "།།",
+ "˄": "^",
+ "ˆ": "^",
+ "꙾": "ˇ",
+ "˘": "ˇ",
+ "‾": "ˉ",
+ "﹉": "ˉ",
+ "﹊": "ˉ",
+ "﹋": "ˉ",
+ "﹌": "ˉ",
+ "¯": "ˉ",
+ " ̄": "ˉ",
+ "▔": "ˉ",
+ "ъ": "ˉb",
+ "ꙑ": "ˉbi",
+ "͵": "ˏ",
+ "˻": "˪",
+ "꜖": "˪",
+ "꜔": "˫",
+ "。": "˳",
+ "⸰": "°",
+ "˚": "°",
+ "∘": "°",
+ "○": "°",
+ "◦": "°",
+ "⍜": "°̲",
+ "⍤": "°̈",
+ "℃": "°C",
+ "℉": "°F",
+ "௵": "௳",
+ "༛": "༚༚",
+ "༟": "༚༝",
+ "࿎": "༝༚",
+ "༞": "༝༝",
+ "Ⓒ": "©",
+ "Ⓡ": "®",
+ "Ⓟ": "℗",
+ "𝈛": "⅄",
+ "⯬": "↞",
+ "⯭": "↟",
+ "⯮": "↠",
+ "⯯": "↡",
+ "↵": "↲",
+ "⥥": "⇃⇂",
+ "⥯": "⇃ᛚ",
+ "𝛛": "∂",
+ "𝜕": "∂",
+ "𝝏": "∂",
+ "𝞉": "∂",
+ "𝟃": "∂",
+ "𞣌": "∂",
+ "𞣍": "∂̵",
+ "ð": "∂̵",
+ "⌀": "∅",
+ "𝛁": "∇",
+ "𝛻": "∇",
+ "𝜵": "∇",
+ "𝝯": "∇",
+ "𝞩": "∇",
+ "𑢨": "∇",
+ "⍢": "∇̈",
+ "⍫": "∇̴",
+ "█": "∎",
+ "■": "∎",
+ "⨿": "∐",
+ "᛭": "+",
+ "➕": "+",
+ "𐊛": "+",
+ "⨣": "+̂",
+ "⨢": "+̊",
+ "⨤": "+̃",
+ "∔": "+̇",
+ "⨥": "+̣",
+ "⨦": "+̰",
+ "⨧": "+₂",
+ "➗": "÷",
+ "‹": "<",
+ "❮": "<",
+ "˂": "<",
+ "𝈶": "<",
+ "ᐸ": "<",
+ "ᚲ": "<",
+ "⋖": "<·",
+ "Ⲵ": "<·",
+ "ᑅ": "<·",
+ "≪": "<<",
+ "⋘": "<<<",
+ "᐀": "=",
+ "⹀": "=",
+ "゠": "=",
+ "꓿": "=",
+ "≚": "=̆",
+ "≙": "=̂",
+ "≗": "=̊",
+ "≐": "=̇",
+ "≑": "=̣̇",
+ "⩮": "=⃰",
+ "⩵": "==",
+ "⩶": "===",
+ "≞": "=ͫ",
+ "›": ">",
+ "❯": ">",
+ "˃": ">",
+ "𝈷": ">",
+ "ᐳ": ">",
+ "𖼿": ">",
+ "ᑁ": ">·",
+ "⪥": "><",
+ "≫": ">>",
+ "⨠": ">>",
+ "⋙": ">>>",
+ "⁓": "~",
+ "˜": "~",
+ "῀": "~",
+ "∼": "~",
+ "⍨": "~̈",
+ "⸞": "~̇",
+ "⩪": "~̇",
+ "⸟": "~̣",
+ "𞣈": "∠",
+ "⋀": "∧",
+ "∯": "∮∮",
+ "∰": "∮∮∮",
+ "⸫": "∴",
+ "⸪": "∵",
+ "⸬": "∷",
+ "𑇞": "≈",
+ "♎": "≏",
+ "🝞": "≏",
+ "≣": "≡",
+ "⨃": "⊍",
+ "⨄": "⊎",
+ "𝈸": "⊏",
+ "𝈹": "⊐",
+ "⨅": "⊓",
+ "⨆": "⊔",
+ "⨂": "⊗",
+ "⍟": "⊛",
+ "🝱": "⊠",
+ "🝕": "⊡",
+ "◁": "⊲",
+ "▷": "⊳",
+ "⍣": "⋆̈",
+ "︴": "⌇",
+ "◠": "⌒",
+ "⨽": "⌙",
+ "⌥": "⌤",
+ "⧇": "⌻",
+ "◎": "⌾",
+ "⦾": "⌾",
+ "⧅": "⍂",
+ "⦰": "⍉",
+ "⏃": "⍋",
+ "⏂": "⍎",
+ "⏁": "⍕",
+ "⏆": "⍭",
+ "☸": "⎈",
+ "︵": "⏜",
+ "︶": "⏝",
+ "︷": "⏞",
+ "︸": "⏟",
+ "︹": "⏠",
+ "︺": "⏡",
+ "▱": "⏥",
+ "⏼": "⏻",
+ "︱": "│",
+ "|": "│",
+ "┃": "│",
+ "┏": "┌",
+ "┣": "├",
+ "▐": "▌",
+ "▗": "▖",
+ "▝": "▘",
+ "☐": "□",
+ "■": "▪",
+ "▸": "▶",
+ "►": "▶",
+ "⳩": "☧",
+ "🜊": "☩",
+ "🌒": "☽",
+ "🌙": "☽",
+ "⏾": "☾",
+ "🌘": "☾",
+ "⧙": "⦚",
+ "🜺": "⧟",
+ "⨾": "⨟",
+ "𐆠": "⳨",
+ "♩": "𝅘𝅥",
+ "♪": "𝅘𝅥𝅮",
+ "⓪": "🄍",
+ "↺": "🄎",
+ "˙": "ॱ",
+ "ൎ": "ॱ",
+ "-": "ー",
+ "—": "ー",
+ "―": "ー",
+ "─": "ー",
+ "━": "ー",
+ "㇐": "ー",
+ "ꟷ": "ー",
+ "ᅳ": "ー",
+ "ㅡ": "ー",
+ "一": "ー",
+ "⼀": "ー",
+ "ᆖ": "ーー",
+ "ힹ": "ーᅡ",
+ "ힺ": "ーᅥ",
+ "ힻ": "ーᅥ丨",
+ "ힼ": "ーᅩ",
+ "ᆕ": "ーᅮ",
+ "ᅴ": "ー丨",
+ "ㅢ": "ー丨",
+ "ᆗ": "ー丨ᅮ",
+ "🄏": "$⃠",
+ "₤": "£",
+ "〒": "₸",
+ "〶": "₸",
+ "᭜": "᭐",
+ "꧆": "꧐",
+ "𑓑": "১",
+ "೧": "౧",
+ "ၥ": "၁",
+ "①": "➀",
+ "⑩": "➉",
+ "⏨": "₁₀",
+ "𝟐": "2",
+ "𝟚": "2",
+ "𝟤": "2",
+ "𝟮": "2",
+ "𝟸": "2",
+ "🯲": "2",
+ "Ꝛ": "2",
+ "Ƨ": "2",
+ "Ϩ": "2",
+ "Ꙅ": "2",
+ "ᒿ": "2",
+ "ꛯ": "2",
+ "ꧏ": "٢",
+ "۲": "٢",
+ "૨": "२",
+ "𑓒": "২",
+ "೨": "౨",
+ "②": "➁",
+ "ƻ": "2̵",
+ "🄃": "2,",
+ "⒉": "2.",
+ "㏵": "22日",
+ "㍮": "22点",
+ "㏶": "23日",
+ "㍯": "23点",
+ "㏷": "24日",
+ "㍰": "24点",
+ "㏸": "25日",
+ "㏹": "26日",
+ "㏺": "27日",
+ "㏻": "28日",
+ "㏼": "29日",
+ "㏴": "2l日",
+ "㍭": "2l点",
+ "⒛": "2O.",
+ "㏳": "2O日",
+ "㍬": "2O点",
+ "෩": "෨ා",
+ "෯": "෨ී",
+ "㏡": "2日",
+ "㋁": "2月",
+ "㍚": "2点",
+ "𝈆": "3",
+ "𝟑": "3",
+ "𝟛": "3",
+ "𝟥": "3",
+ "𝟯": "3",
+ "𝟹": "3",
+ "🯳": "3",
+ "Ɜ": "3",
+ "Ȝ": "3",
+ "Ʒ": "3",
+ "Ꝫ": "3",
+ "Ⳍ": "3",
+ "З": "3",
+ "Ӡ": "3",
+ "𖼻": "3",
+ "𑣊": "3",
+ "۳": "٣",
+ "𞣉": "٣",
+ "૩": "३",
+ "③": "➂",
+ "Ҙ": "3̦",
+ "🄄": "3,",
+ "⒊": "3.",
+ "㏾": "3l日",
+ "㏽": "3O日",
+ "㏢": "3日",
+ "㋂": "3月",
+ "㍛": "3点",
+ "𝟒": "4",
+ "𝟜": "4",
+ "𝟦": "4",
+ "𝟰": "4",
+ "𝟺": "4",
+ "🯴": "4",
+ "Ꮞ": "4",
+ "𑢯": "4",
+ "۴": "٤",
+ "૪": "४",
+ "④": "➃",
+ "🄅": "4,",
+ "⒋": "4.",
+ "ᔰ": "4·",
+ "㏣": "4日",
+ "㋃": "4月",
+ "㍜": "4点",
+ "𝟓": "5",
+ "𝟝": "5",
+ "𝟧": "5",
+ "𝟱": "5",
+ "𝟻": "5",
+ "🯵": "5",
+ "Ƽ": "5",
+ "𑢻": "5",
+ "⑤": "➄",
+ "🄆": "5,",
+ "⒌": "5.",
+ "㏤": "5日",
+ "㋄": "5月",
+ "㍝": "5点",
+ "𝟔": "6",
+ "𝟞": "6",
+ "𝟨": "6",
+ "𝟲": "6",
+ "𝟼": "6",
+ "🯶": "6",
+ "Ⳓ": "6",
+ "б": "6",
+ "Ꮾ": "6",
+ "𑣕": "6",
+ "۶": "٦",
+ "𑓖": "৬",
+ "⑥": "➅",
+ "🄇": "6,",
+ "⒍": "6.",
+ "㏥": "6日",
+ "㋅": "6月",
+ "㍞": "6点",
+ "𝈒": "7",
+ "𝟕": "7",
+ "𝟟": "7",
+ "𝟩": "7",
+ "𝟳": "7",
+ "𝟽": "7",
+ "🯷": "7",
+ "𐓒": "7",
+ "𑣆": "7",
+ "⑦": "➆",
+ "🄈": "7,",
+ "⒎": "7.",
+ "㏦": "7日",
+ "㋆": "7月",
+ "㍟": "7点",
+ "ଃ": "8",
+ "৪": "8",
+ "੪": "8",
+ "𞣋": "8",
+ "𝟖": "8",
+ "𝟠": "8",
+ "𝟪": "8",
+ "𝟴": "8",
+ "𝟾": "8",
+ "🯸": "8",
+ "ȣ": "8",
+ "Ȣ": "8",
+ "𐌚": "8",
+ "૮": "८",
+ "⑧": "➇",
+ "🄉": "8,",
+ "⒏": "8.",
+ "㏧": "8日",
+ "㋇": "8月",
+ "㍠": "8点",
+ "੧": "9",
+ "୨": "9",
+ "৭": "9",
+ "൭": "9",
+ "𝟗": "9",
+ "𝟡": "9",
+ "𝟫": "9",
+ "𝟵": "9",
+ "𝟿": "9",
+ "🯹": "9",
+ "Ꝯ": "9",
+ "Ⳋ": "9",
+ "𑣌": "9",
+ "𑢬": "9",
+ "𑣖": "9",
+ "१": "٩",
+ "𑣤": "٩",
+ "۹": "٩",
+ "೯": "౯",
+ "⑨": "➈",
+ "🄊": "9,",
+ "⒐": "9.",
+ "㏨": "9日",
+ "㋈": "9月",
+ "㍡": "9点",
+ "⍺": "a",
+ "a": "a",
+ "𝐚": "a",
+ "𝑎": "a",
+ "𝒂": "a",
+ "𝒶": "a",
+ "𝓪": "a",
+ "𝔞": "a",
+ "𝕒": "a",
+ "𝖆": "a",
+ "𝖺": "a",
+ "𝗮": "a",
+ "𝘢": "a",
+ "𝙖": "a",
+ "𝚊": "a",
+ "ɑ": "a",
+ "α": "a",
+ "𝛂": "a",
+ "𝛼": "a",
+ "𝜶": "a",
+ "𝝰": "a",
+ "𝞪": "a",
+ "а": "a",
+ "ⷶ": "ͣ",
+ "A": "A",
+ "𝐀": "A",
+ "𝐴": "A",
+ "𝑨": "A",
+ "𝒜": "A",
+ "𝓐": "A",
+ "𝔄": "A",
+ "𝔸": "A",
+ "𝕬": "A",
+ "𝖠": "A",
+ "𝗔": "A",
+ "𝘈": "A",
+ "𝘼": "A",
+ "𝙰": "A",
+ "Α": "A",
+ "𝚨": "A",
+ "𝛢": "A",
+ "𝜜": "A",
+ "𝝖": "A",
+ "𝞐": "A",
+ "А": "A",
+ "Ꭺ": "A",
+ "ᗅ": "A",
+ "ꓮ": "A",
+ "𖽀": "A",
+ "𐊠": "A",
+ "⍶": "a̲",
+ "ǎ": "ă",
+ "Ǎ": "Ă",
+ "ȧ": "å",
+ "Ȧ": "Å",
+ "ẚ": "ả",
+ "℀": "a/c",
+ "℁": "a/s",
+ "ꜳ": "aa",
+ "Ꜳ": "AA",
+ "æ": "ae",
+ "ӕ": "ae",
+ "Æ": "AE",
+ "Ӕ": "AE",
+ "ꜵ": "ao",
+ "Ꜵ": "AO",
+ "🜇": "AR",
+ "ꜷ": "au",
+ "Ꜷ": "AU",
+ "ꜹ": "av",
+ "ꜻ": "av",
+ "Ꜹ": "AV",
+ "Ꜻ": "AV",
+ "ꜽ": "ay",
+ "Ꜽ": "AY",
+ "ꭺ": "ᴀ",
+ "∀": "Ɐ",
+ "𝈗": "Ɐ",
+ "ᗄ": "Ɐ",
+ "ꓯ": "Ɐ",
+ "𐐟": "Ɒ",
+ "𝐛": "b",
+ "𝑏": "b",
+ "𝒃": "b",
+ "𝒷": "b",
+ "𝓫": "b",
+ "𝔟": "b",
+ "𝕓": "b",
+ "𝖇": "b",
+ "𝖻": "b",
+ "𝗯": "b",
+ "𝘣": "b",
+ "𝙗": "b",
+ "𝚋": "b",
+ "Ƅ": "b",
+ "Ь": "b",
+ "Ꮟ": "b",
+ "ᑲ": "b",
+ "ᖯ": "b",
+ "B": "B",
+ "ℬ": "B",
+ "𝐁": "B",
+ "𝐵": "B",
+ "𝑩": "B",
+ "𝓑": "B",
+ "𝔅": "B",
+ "𝔹": "B",
+ "𝕭": "B",
+ "𝖡": "B",
+ "𝗕": "B",
+ "𝘉": "B",
+ "𝘽": "B",
+ "𝙱": "B",
+ "Ꞵ": "B",
+ "Β": "B",
+ "𝚩": "B",
+ "𝛣": "B",
+ "𝜝": "B",
+ "𝝗": "B",
+ "𝞑": "B",
+ "В": "B",
+ "Ᏼ": "B",
+ "ᗷ": "B",
+ "ꓐ": "B",
+ "𐊂": "B",
+ "𐊡": "B",
+ "𐌁": "B",
+ "ɓ": "b̔",
+ "ᑳ": "ḃ",
+ "ƃ": "b̄",
+ "Ƃ": "b̄",
+ "Б": "b̄",
+ "ƀ": "b̵",
+ "ҍ": "b̵",
+ "Ҍ": "b̵",
+ "ѣ": "b̵",
+ "Ѣ": "b̵",
+ "ᑿ": "b·",
+ "ᒁ": "ḃ·",
+ "ᒈ": "b'",
+ "Ы": "bl",
+ "в": "ʙ",
+ "ᏼ": "ʙ",
+ "c": "c",
+ "ⅽ": "c",
+ "𝐜": "c",
+ "𝑐": "c",
+ "𝒄": "c",
+ "𝒸": "c",
+ "𝓬": "c",
+ "𝔠": "c",
+ "𝕔": "c",
+ "𝖈": "c",
+ "𝖼": "c",
+ "𝗰": "c",
+ "𝘤": "c",
+ "𝙘": "c",
+ "𝚌": "c",
+ "ᴄ": "c",
+ "ϲ": "c",
+ "ⲥ": "c",
+ "с": "c",
+ "ꮯ": "c",
+ "𐐽": "c",
+ "ⷭ": "ͨ",
+ "🝌": "C",
+ "𑣲": "C",
+ "𑣩": "C",
+ "C": "C",
+ "Ⅽ": "C",
+ "ℂ": "C",
+ "ℭ": "C",
+ "𝐂": "C",
+ "𝐶": "C",
+ "𝑪": "C",
+ "𝒞": "C",
+ "𝓒": "C",
+ "𝕮": "C",
+ "𝖢": "C",
+ "𝗖": "C",
+ "𝘊": "C",
+ "𝘾": "C",
+ "𝙲": "C",
+ "Ϲ": "C",
+ "Ⲥ": "C",
+ "С": "C",
+ "Ꮯ": "C",
+ "ꓚ": "C",
+ "𐊢": "C",
+ "𐌂": "C",
+ "𐐕": "C",
+ "𐔜": "C",
+ "¢": "c̸",
+ "ȼ": "c̸",
+ "₡": "C⃫",
+ "🅮": "C⃠",
+ "ç": "c̦",
+ "ҫ": "c̦",
+ "Ç": "C̦",
+ "Ҫ": "C̦",
+ "Ƈ": "C'",
+ "℅": "c/o",
+ "℆": "c/u",
+ "🅭": "㏄\t⃝",
+ "⋴": "ꞓ",
+ "ɛ": "ꞓ",
+ "ε": "ꞓ",
+ "ϵ": "ꞓ",
+ "𝛆": "ꞓ",
+ "𝛜": "ꞓ",
+ "𝜀": "ꞓ",
+ "𝜖": "ꞓ",
+ "𝜺": "ꞓ",
+ "𝝐": "ꞓ",
+ "𝝴": "ꞓ",
+ "𝞊": "ꞓ",
+ "𝞮": "ꞓ",
+ "𝟄": "ꞓ",
+ "ⲉ": "ꞓ",
+ "є": "ꞓ",
+ "ԑ": "ꞓ",
+ "ꮛ": "ꞓ",
+ "𑣎": "ꞓ",
+ "𐐩": "ꞓ",
+ "€": "Ꞓ",
+ "Ⲉ": "Ꞓ",
+ "Є": "Ꞓ",
+ "⍷": "ꞓ̲",
+ "ͽ": "ꜿ",
+ "Ͽ": "Ꜿ",
+ "ⅾ": "d",
+ "ⅆ": "d",
+ "𝐝": "d",
+ "𝑑": "d",
+ "𝒅": "d",
+ "𝒹": "d",
+ "𝓭": "d",
+ "𝔡": "d",
+ "𝕕": "d",
+ "𝖉": "d",
+ "𝖽": "d",
+ "𝗱": "d",
+ "𝘥": "d",
+ "𝙙": "d",
+ "𝚍": "d",
+ "ԁ": "d",
+ "Ꮷ": "d",
+ "ᑯ": "d",
+ "ꓒ": "d",
+ "Ⅾ": "D",
+ "ⅅ": "D",
+ "𝐃": "D",
+ "𝐷": "D",
+ "𝑫": "D",
+ "𝒟": "D",
+ "𝓓": "D",
+ "𝔇": "D",
+ "𝔻": "D",
+ "𝕯": "D",
+ "𝖣": "D",
+ "𝗗": "D",
+ "𝘋": "D",
+ "𝘿": "D",
+ "𝙳": "D",
+ "Ꭰ": "D",
+ "ᗞ": "D",
+ "ᗪ": "D",
+ "ꓓ": "D",
+ "ɗ": "d̔",
+ "ɖ": "d̨",
+ "ƌ": "d̄",
+ "đ": "d̵",
+ "Đ": "D̵",
+ "Ð": "D̵",
+ "Ɖ": "D̵",
+ "₫": "ḏ̵",
+ "ꝺ": "Ꝺ",
+ "ᑻ": "d·",
+ "ᒇ": "d'",
+ "ʤ": "dȝ",
+ "dz": "dz",
+ "ʣ": "dz",
+ "Dz": "Dz",
+ "DZ": "DZ",
+ "dž": "dž",
+ "Dž": "Dž",
+ "DŽ": "DŽ",
+ "ʥ": "dʑ",
+ "ꭰ": "ᴅ",
+ "⸹": "ẟ",
+ "δ": "ẟ",
+ "𝛅": "ẟ",
+ "𝛿": "ẟ",
+ "𝜹": "ẟ",
+ "𝝳": "ẟ",
+ "𝞭": "ẟ",
+ "ծ": "ẟ",
+ "ᕷ": "ẟ",
+ "℮": "e",
+ "e": "e",
+ "ℯ": "e",
+ "ⅇ": "e",
+ "𝐞": "e",
+ "𝑒": "e",
+ "𝒆": "e",
+ "𝓮": "e",
+ "𝔢": "e",
+ "𝕖": "e",
+ "𝖊": "e",
+ "𝖾": "e",
+ "𝗲": "e",
+ "𝘦": "e",
+ "𝙚": "e",
+ "𝚎": "e",
+ "ꬲ": "e",
+ "е": "e",
+ "ҽ": "e",
+ "ⷷ": "ͤ",
+ "⋿": "E",
+ "E": "E",
+ "ℰ": "E",
+ "𝐄": "E",
+ "𝐸": "E",
+ "𝑬": "E",
+ "𝓔": "E",
+ "𝔈": "E",
+ "𝔼": "E",
+ "𝕰": "E",
+ "𝖤": "E",
+ "𝗘": "E",
+ "𝘌": "E",
+ "𝙀": "E",
+ "𝙴": "E",
+ "Ε": "E",
+ "𝚬": "E",
+ "𝛦": "E",
+ "𝜠": "E",
+ "𝝚": "E",
+ "𝞔": "E",
+ "Е": "E",
+ "ⴹ": "E",
+ "Ꭼ": "E",
+ "ꓰ": "E",
+ "𑢦": "E",
+ "𑢮": "E",
+ "𐊆": "E",
+ "ě": "ĕ",
+ "Ě": "Ĕ",
+ "ɇ": "e̸",
+ "Ɇ": "E̸",
+ "ҿ": "ę",
+ "ꭼ": "ᴇ",
+ "ə": "ǝ",
+ "ә": "ǝ",
+ "∃": "Ǝ",
+ "ⴺ": "Ǝ",
+ "ꓱ": "Ǝ",
+ "ɚ": "ǝ˞",
+ "ᴔ": "ǝo",
+ "ꭁ": "ǝo̸",
+ "ꭂ": "ǝo̵",
+ "Ә": "Ə",
+ "𝈡": "Ɛ",
+ "ℇ": "Ɛ",
+ "Ԑ": "Ɛ",
+ "Ꮛ": "Ɛ",
+ "𖼭": "Ɛ",
+ "𐐁": "Ɛ",
+ "ᶟ": "ᵋ",
+ "ᴈ": "ɜ",
+ "з": "ɜ",
+ "ҙ": "ɜ̦",
+ "𐑂": "ɞ",
+ "ꞝ": "ʚ",
+ "𐐪": "ʚ",
+ "𝐟": "f",
+ "𝑓": "f",
+ "𝒇": "f",
+ "𝒻": "f",
+ "𝓯": "f",
+ "𝔣": "f",
+ "𝕗": "f",
+ "𝖋": "f",
+ "𝖿": "f",
+ "𝗳": "f",
+ "𝘧": "f",
+ "𝙛": "f",
+ "𝚏": "f",
+ "ꬵ": "f",
+ "ꞙ": "f",
+ "ſ": "f",
+ "ẝ": "f",
+ "ք": "f",
+ "𝈓": "F",
+ "ℱ": "F",
+ "𝐅": "F",
+ "𝐹": "F",
+ "𝑭": "F",
+ "𝓕": "F",
+ "𝔉": "F",
+ "𝔽": "F",
+ "𝕱": "F",
+ "𝖥": "F",
+ "𝗙": "F",
+ "𝘍": "F",
+ "𝙁": "F",
+ "𝙵": "F",
+ "Ꞙ": "F",
+ "Ϝ": "F",
+ "𝟊": "F",
+ "ᖴ": "F",
+ "ꓝ": "F",
+ "𑣂": "F",
+ "𑢢": "F",
+ "𐊇": "F",
+ "𐊥": "F",
+ "𐔥": "F",
+ "ƒ": "f̦",
+ "Ƒ": "F̦",
+ "ᵮ": "f̴",
+ "℻": "FAX",
+ "ff": "ff",
+ "ffi": "ffi",
+ "ffl": "ffl",
+ "fi": "fi",
+ "fl": "fl",
+ "ʩ": "fŋ",
+ "ᖵ": "Ⅎ",
+ "ꓞ": "Ⅎ",
+ "𝈰": "ꟻ",
+ "ᖷ": "ꟻ",
+ "g": "g",
+ "ℊ": "g",
+ "𝐠": "g",
+ "𝑔": "g",
+ "𝒈": "g",
+ "𝓰": "g",
+ "𝔤": "g",
+ "𝕘": "g",
+ "𝖌": "g",
+ "𝗀": "g",
+ "𝗴": "g",
+ "𝘨": "g",
+ "𝙜": "g",
+ "𝚐": "g",
+ "ɡ": "g",
+ "ᶃ": "g",
+ "ƍ": "g",
+ "ց": "g",
+ "𝐆": "G",
+ "𝐺": "G",
+ "𝑮": "G",
+ "𝒢": "G",
+ "𝓖": "G",
+ "𝔊": "G",
+ "𝔾": "G",
+ "𝕲": "G",
+ "𝖦": "G",
+ "𝗚": "G",
+ "𝘎": "G",
+ "𝙂": "G",
+ "𝙶": "G",
+ "Ԍ": "G",
+ "Ꮐ": "G",
+ "Ᏻ": "G",
+ "ꓖ": "G",
+ "ᶢ": "ᵍ",
+ "ɠ": "g̔",
+ "ǧ": "ğ",
+ "Ǧ": "Ğ",
+ "ǵ": "ģ",
+ "ǥ": "g̵",
+ "Ǥ": "G̵",
+ "Ɠ": "G'",
+ "ԍ": "ɢ",
+ "ꮐ": "ɢ",
+ "ᏻ": "ɢ",
+ "h": "h",
+ "ℎ": "h",
+ "𝐡": "h",
+ "𝒉": "h",
+ "𝒽": "h",
+ "𝓱": "h",
+ "𝔥": "h",
+ "𝕙": "h",
+ "𝖍": "h",
+ "𝗁": "h",
+ "𝗵": "h",
+ "𝘩": "h",
+ "𝙝": "h",
+ "𝚑": "h",
+ "һ": "h",
+ "հ": "h",
+ "Ꮒ": "h",
+ "H": "H",
+ "ℋ": "H",
+ "ℌ": "H",
+ "ℍ": "H",
+ "𝐇": "H",
+ "𝐻": "H",
+ "𝑯": "H",
+ "𝓗": "H",
+ "𝕳": "H",
+ "𝖧": "H",
+ "𝗛": "H",
+ "𝘏": "H",
+ "𝙃": "H",
+ "𝙷": "H",
+ "Η": "H",
+ "𝚮": "H",
+ "𝛨": "H",
+ "𝜢": "H",
+ "𝝜": "H",
+ "𝞖": "H",
+ "Ⲏ": "H",
+ "Н": "H",
+ "Ꮋ": "H",
+ "ᕼ": "H",
+ "ꓧ": "H",
+ "𐋏": "H",
+ "ᵸ": "ᴴ",
+ "ɦ": "h̔",
+ "ꚕ": "h̔",
+ "Ᏺ": "h̔",
+ "Ⱨ": "H̩",
+ "Ң": "H̩",
+ "ħ": "h̵",
+ "ℏ": "h̵",
+ "ћ": "h̵",
+ "Ħ": "H̵",
+ "Ӊ": "H̦",
+ "Ӈ": "H̦",
+ "н": "ʜ",
+ "ꮋ": "ʜ",
+ "ң": "ʜ̩",
+ "ӊ": "ʜ̦",
+ "ӈ": "ʜ̦",
+ "Ԋ": "Ƕ",
+ "ꮀ": "ⱶ",
+ "Ͱ": "Ⱶ",
+ "Ꭸ": "Ⱶ",
+ "Ꮀ": "Ⱶ",
+ "ꚱ": "Ⱶ",
+ "ꞕ": "ꜧ",
+ "˛": "i",
+ "⍳": "i",
+ "i": "i",
+ "ⅰ": "i",
+ "ℹ": "i",
+ "ⅈ": "i",
+ "𝐢": "i",
+ "𝑖": "i",
+ "𝒊": "i",
+ "𝒾": "i",
+ "𝓲": "i",
+ "𝔦": "i",
+ "𝕚": "i",
+ "𝖎": "i",
+ "𝗂": "i",
+ "𝗶": "i",
+ "𝘪": "i",
+ "𝙞": "i",
+ "𝚒": "i",
+ "ı": "i",
+ "𝚤": "i",
+ "ɪ": "i",
+ "ɩ": "i",
+ "ι": "i",
+ "ι": "i",
+ "ͺ": "i",
+ "𝛊": "i",
+ "𝜄": "i",
+ "𝜾": "i",
+ "𝝸": "i",
+ "𝞲": "i",
+ "і": "i",
+ "ꙇ": "i",
+ "ӏ": "i",
+ "ꭵ": "i",
+ "Ꭵ": "i",
+ "𑣃": "i",
+ "ⓛ": "Ⓘ",
+ "⍸": "i̲",
+ "ǐ": "ĭ",
+ "Ǐ": "Ĭ",
+ "ɨ": "i̵",
+ "ᵻ": "i̵",
+ "ᵼ": "i̵",
+ "ⅱ": "ii",
+ "ⅲ": "iii",
+ "ij": "ij",
+ "ⅳ": "iv",
+ "ⅸ": "ix",
+ "j": "j",
+ "ⅉ": "j",
+ "𝐣": "j",
+ "𝑗": "j",
+ "𝒋": "j",
+ "𝒿": "j",
+ "𝓳": "j",
+ "𝔧": "j",
+ "𝕛": "j",
+ "𝖏": "j",
+ "𝗃": "j",
+ "𝗷": "j",
+ "𝘫": "j",
+ "𝙟": "j",
+ "𝚓": "j",
+ "ϳ": "j",
+ "ј": "j",
+ "J": "J",
+ "𝐉": "J",
+ "𝐽": "J",
+ "𝑱": "J",
+ "𝒥": "J",
+ "𝓙": "J",
+ "𝔍": "J",
+ "𝕁": "J",
+ "𝕵": "J",
+ "𝖩": "J",
+ "𝗝": "J",
+ "𝘑": "J",
+ "𝙅": "J",
+ "𝙹": "J",
+ "Ʝ": "J",
+ "Ϳ": "J",
+ "Ј": "J",
+ "Ꭻ": "J",
+ "ᒍ": "J",
+ "ꓙ": "J",
+ "ɉ": "j̵",
+ "Ɉ": "J̵",
+ "ᒙ": "J·",
+ "𝚥": "ȷ",
+ "յ": "ȷ",
+ "ꭻ": "ᴊ",
+ "𝐤": "k",
+ "𝑘": "k",
+ "𝒌": "k",
+ "𝓀": "k",
+ "𝓴": "k",
+ "𝔨": "k",
+ "𝕜": "k",
+ "𝖐": "k",
+ "𝗄": "k",
+ "𝗸": "k",
+ "𝘬": "k",
+ "𝙠": "k",
+ "𝚔": "k",
+ "K": "K",
+ "K": "K",
+ "𝐊": "K",
+ "𝐾": "K",
+ "𝑲": "K",
+ "𝒦": "K",
+ "𝓚": "K",
+ "𝔎": "K",
+ "𝕂": "K",
+ "𝕶": "K",
+ "𝖪": "K",
+ "𝗞": "K",
+ "𝘒": "K",
+ "𝙆": "K",
+ "𝙺": "K",
+ "Κ": "K",
+ "𝚱": "K",
+ "𝛫": "K",
+ "𝜥": "K",
+ "𝝟": "K",
+ "𝞙": "K",
+ "Ⲕ": "K",
+ "К": "K",
+ "Ꮶ": "K",
+ "ᛕ": "K",
+ "ꓗ": "K",
+ "𐔘": "K",
+ "ƙ": "k̔",
+ "Ⱪ": "K̩",
+ "Қ": "K̩",
+ "₭": "K̵",
+ "Ꝁ": "K̵",
+ "Ҟ": "K̵",
+ "Ƙ": "K'",
+ "׀": "l",
+ "|": "l",
+ "∣": "l",
+ "⏽": "l",
+ "│": "l",
+ "١": "l",
+ "۱": "l",
+ "𐌠": "l",
+ "𞣇": "l",
+ "𝟏": "l",
+ "𝟙": "l",
+ "𝟣": "l",
+ "𝟭": "l",
+ "𝟷": "l",
+ "🯱": "l",
+ "I": "l",
+ "I": "l",
+ "Ⅰ": "l",
+ "ℐ": "l",
+ "ℑ": "l",
+ "𝐈": "l",
+ "𝐼": "l",
+ "𝑰": "l",
+ "𝓘": "l",
+ "𝕀": "l",
+ "𝕴": "l",
+ "𝖨": "l",
+ "𝗜": "l",
+ "𝘐": "l",
+ "𝙄": "l",
+ "𝙸": "l",
+ "Ɩ": "l",
+ "l": "l",
+ "ⅼ": "l",
+ "ℓ": "l",
+ "𝐥": "l",
+ "𝑙": "l",
+ "𝒍": "l",
+ "𝓁": "l",
+ "𝓵": "l",
+ "𝔩": "l",
+ "𝕝": "l",
+ "𝖑": "l",
+ "𝗅": "l",
+ "𝗹": "l",
+ "𝘭": "l",
+ "𝙡": "l",
+ "𝚕": "l",
+ "ǀ": "l",
+ "Ι": "l",
+ "𝚰": "l",
+ "𝛪": "l",
+ "𝜤": "l",
+ "𝝞": "l",
+ "𝞘": "l",
+ "Ⲓ": "l",
+ "І": "l",
+ "Ӏ": "l",
+ "ו": "l",
+ "ן": "l",
+ "ا": "l",
+ "𞸀": "l",
+ "𞺀": "l",
+ "ﺎ": "l",
+ "ﺍ": "l",
+ "ߊ": "l",
+ "ⵏ": "l",
+ "ᛁ": "l",
+ "ꓲ": "l",
+ "𖼨": "l",
+ "𐊊": "l",
+ "𐌉": "l",
+ "𝈪": "L",
+ "Ⅼ": "L",
+ "ℒ": "L",
+ "𝐋": "L",
+ "𝐿": "L",
+ "𝑳": "L",
+ "𝓛": "L",
+ "𝔏": "L",
+ "𝕃": "L",
+ "𝕷": "L",
+ "𝖫": "L",
+ "𝗟": "L",
+ "𝘓": "L",
+ "𝙇": "L",
+ "𝙻": "L",
+ "Ⳑ": "L",
+ "Ꮮ": "L",
+ "ᒪ": "L",
+ "ꓡ": "L",
+ "𖼖": "L",
+ "𑢣": "L",
+ "𑢲": "L",
+ "𐐛": "L",
+ "𐔦": "L",
+ "ﴼ": "l̋",
+ "ﴽ": "l̋",
+ "ł": "l̸",
+ "Ł": "L̸",
+ "ɭ": "l̨",
+ "Ɨ": "l̵",
+ "ƚ": "l̵",
+ "ɫ": "l̴",
+ "إ": "lٕ",
+ "ﺈ": "lٕ",
+ "ﺇ": "lٕ",
+ "ٳ": "lٕ",
+ "ŀ": "l·",
+ "Ŀ": "l·",
+ "ᒷ": "l·",
+ "🄂": "l,",
+ "⒈": "l.",
+ "ױ": "l'",
+ "⒓": "l2.",
+ "㏫": "l2日",
+ "㋋": "l2月",
+ "㍤": "l2点",
+ "⒔": "l3.",
+ "㏬": "l3日",
+ "㍥": "l3点",
+ "⒕": "l4.",
+ "㏭": "l4日",
+ "㍦": "l4点",
+ "⒖": "l5.",
+ "㏮": "l5日",
+ "㍧": "l5点",
+ "⒗": "l6.",
+ "㏯": "l6日",
+ "㍨": "l6点",
+ "⒘": "l7.",
+ "㏰": "l7日",
+ "㍩": "l7点",
+ "⒙": "l8.",
+ "㏱": "l8日",
+ "㍪": "l8点",
+ "⒚": "l9.",
+ "㏲": "l9日",
+ "㍫": "l9点",
+ "lj": "lj",
+ "IJ": "lJ",
+ "Lj": "Lj",
+ "LJ": "LJ",
+ "‖": "ll",
+ "∥": "ll",
+ "Ⅱ": "ll",
+ "ǁ": "ll",
+ "װ": "ll",
+ "𐆙": "l̵l̵",
+ "⒒": "ll.",
+ "Ⅲ": "lll",
+ "𐆘": "l̵l̵S̵",
+ "㏪": "ll日",
+ "㋊": "ll月",
+ "㍣": "ll点",
+ "Ю": "lO",
+ "⒑": "lO.",
+ "㏩": "lO日",
+ "㋉": "lO月",
+ "㍢": "lO点",
+ "ʪ": "ls",
+ "₶": "lt",
+ "Ⅳ": "lV",
+ "Ⅸ": "lX",
+ "ɮ": "lȝ",
+ "ʫ": "lz",
+ "أ": "lٴ",
+ "ﺄ": "lٴ",
+ "ﺃ": "lٴ",
+ "ٲ": "lٴ",
+ "ٵ": "lٴ",
+ "ﷳ": "lكبر",
+ "ﷲ": "lللّٰo",
+ "㏠": "l日",
+ "㋀": "l月",
+ "㍙": "l点",
+ "ⳑ": "ʟ",
+ "ꮮ": "ʟ",
+ "𐑃": "ʟ",
+ "M": "M",
+ "Ⅿ": "M",
+ "ℳ": "M",
+ "𝐌": "M",
+ "𝑀": "M",
+ "𝑴": "M",
+ "𝓜": "M",
+ "𝔐": "M",
+ "𝕄": "M",
+ "𝕸": "M",
+ "𝖬": "M",
+ "𝗠": "M",
+ "𝘔": "M",
+ "𝙈": "M",
+ "𝙼": "M",
+ "Μ": "M",
+ "𝚳": "M",
+ "𝛭": "M",
+ "𝜧": "M",
+ "𝝡": "M",
+ "𝞛": "M",
+ "Ϻ": "M",
+ "Ⲙ": "M",
+ "М": "M",
+ "Ꮇ": "M",
+ "ᗰ": "M",
+ "ᛖ": "M",
+ "ꓟ": "M",
+ "𐊰": "M",
+ "𐌑": "M",
+ "Ӎ": "M̦",
+ "🝫": "MB",
+ "ⷨ": "ᷟ",
+ "𝐧": "n",
+ "𝑛": "n",
+ "𝒏": "n",
+ "𝓃": "n",
+ "𝓷": "n",
+ "𝔫": "n",
+ "𝕟": "n",
+ "𝖓": "n",
+ "𝗇": "n",
+ "𝗻": "n",
+ "𝘯": "n",
+ "𝙣": "n",
+ "𝚗": "n",
+ "ո": "n",
+ "ռ": "n",
+ "N": "N",
+ "ℕ": "N",
+ "𝐍": "N",
+ "𝑁": "N",
+ "𝑵": "N",
+ "𝒩": "N",
+ "𝓝": "N",
+ "𝔑": "N",
+ "𝕹": "N",
+ "𝖭": "N",
+ "𝗡": "N",
+ "𝘕": "N",
+ "𝙉": "N",
+ "𝙽": "N",
+ "Ν": "N",
+ "𝚴": "N",
+ "𝛮": "N",
+ "𝜨": "N",
+ "𝝢": "N",
+ "𝞜": "N",
+ "Ⲛ": "N",
+ "ꓠ": "N",
+ "𐔓": "N",
+ "𐆎": "N̊",
+ "ɳ": "n̨",
+ "ƞ": "n̩",
+ "η": "n̩",
+ "𝛈": "n̩",
+ "𝜂": "n̩",
+ "𝜼": "n̩",
+ "𝝶": "n̩",
+ "𝞰": "n̩",
+ "Ɲ": "N̦",
+ "ᵰ": "n̴",
+ "nj": "nj",
+ "Nj": "Nj",
+ "NJ": "NJ",
+ "№": "No",
+ "ͷ": "ᴎ",
+ "и": "ᴎ",
+ "𐑍": "ᴎ",
+ "ņ": "ɲ",
+ "ం": "o",
+ "ಂ": "o",
+ "ം": "o",
+ "ං": "o",
+ "०": "o",
+ "੦": "o",
+ "૦": "o",
+ "௦": "o",
+ "౦": "o",
+ "೦": "o",
+ "൦": "o",
+ "๐": "o",
+ "໐": "o",
+ "၀": "o",
+ "٥": "o",
+ "۵": "o",
+ "o": "o",
+ "ℴ": "o",
+ "𝐨": "o",
+ "𝑜": "o",
+ "𝒐": "o",
+ "𝓸": "o",
+ "𝔬": "o",
+ "𝕠": "o",
+ "𝖔": "o",
+ "𝗈": "o",
+ "𝗼": "o",
+ "𝘰": "o",
+ "𝙤": "o",
+ "𝚘": "o",
+ "ᴏ": "o",
+ "ᴑ": "o",
+ "ꬽ": "o",
+ "ο": "o",
+ "𝛐": "o",
+ "𝜊": "o",
+ "𝝄": "o",
+ "𝝾": "o",
+ "𝞸": "o",
+ "σ": "o",
+ "𝛔": "o",
+ "𝜎": "o",
+ "𝝈": "o",
+ "𝞂": "o",
+ "𝞼": "o",
+ "ⲟ": "o",
+ "о": "o",
+ "ჿ": "o",
+ "օ": "o",
+ "ס": "o",
+ "ه": "o",
+ "𞸤": "o",
+ "𞹤": "o",
+ "𞺄": "o",
+ "ﻫ": "o",
+ "ﻬ": "o",
+ "ﻪ": "o",
+ "ﻩ": "o",
+ "ھ": "o",
+ "ﮬ": "o",
+ "ﮭ": "o",
+ "ﮫ": "o",
+ "ﮪ": "o",
+ "ہ": "o",
+ "ﮨ": "o",
+ "ﮩ": "o",
+ "ﮧ": "o",
+ "ﮦ": "o",
+ "ە": "o",
+ "ഠ": "o",
+ "ဝ": "o",
+ "𐓪": "o",
+ "𑣈": "o",
+ "𑣗": "o",
+ "𐐬": "o",
+ "߀": "O",
+ "০": "O",
+ "୦": "O",
+ "〇": "O",
+ "𑓐": "O",
+ "𑣠": "O",
+ "𝟎": "O",
+ "𝟘": "O",
+ "𝟢": "O",
+ "𝟬": "O",
+ "𝟶": "O",
+ "🯰": "O",
+ "O": "O",
+ "𝐎": "O",
+ "𝑂": "O",
+ "𝑶": "O",
+ "𝒪": "O",
+ "𝓞": "O",
+ "𝔒": "O",
+ "𝕆": "O",
+ "𝕺": "O",
+ "𝖮": "O",
+ "𝗢": "O",
+ "𝘖": "O",
+ "𝙊": "O",
+ "𝙾": "O",
+ "Ο": "O",
+ "𝚶": "O",
+ "𝛰": "O",
+ "𝜪": "O",
+ "𝝤": "O",
+ "𝞞": "O",
+ "Ⲟ": "O",
+ "О": "O",
+ "Օ": "O",
+ "ⵔ": "O",
+ "ዐ": "O",
+ "ଠ": "O",
+ "𐓂": "O",
+ "ꓳ": "O",
+ "𑢵": "O",
+ "𐊒": "O",
+ "𐊫": "O",
+ "𐐄": "O",
+ "𐔖": "O",
+ "⁰": "º",
+ "ᵒ": "º",
+ "ǒ": "ŏ",
+ "Ǒ": "Ŏ",
+ "ۿ": "ô",
+ "Ő": "Ö",
+ "ø": "o̸",
+ "ꬾ": "o̸",
+ "Ø": "O̸",
+ "ⵁ": "O̸",
+ "Ǿ": "Ó̸",
+ "ɵ": "o̵",
+ "ꝋ": "o̵",
+ "ө": "o̵",
+ "ѳ": "o̵",
+ "ꮎ": "o̵",
+ "ꮻ": "o̵",
+ "⊖": "O̵",
+ "⊝": "O̵",
+ "⍬": "O̵",
+ "𝈚": "O̵",
+ "🜔": "O̵",
+ "Ɵ": "O̵",
+ "Ꝋ": "O̵",
+ "θ": "O̵",
+ "ϑ": "O̵",
+ "𝛉": "O̵",
+ "𝛝": "O̵",
+ "𝜃": "O̵",
+ "𝜗": "O̵",
+ "𝜽": "O̵",
+ "𝝑": "O̵",
+ "𝝷": "O̵",
+ "𝞋": "O̵",
+ "𝞱": "O̵",
+ "𝟅": "O̵",
+ "Θ": "O̵",
+ "ϴ": "O̵",
+ "𝚯": "O̵",
+ "𝚹": "O̵",
+ "𝛩": "O̵",
+ "𝛳": "O̵",
+ "𝜣": "O̵",
+ "𝜭": "O̵",
+ "𝝝": "O̵",
+ "𝝧": "O̵",
+ "𝞗": "O̵",
+ "𝞡": "O̵",
+ "Ө": "O̵",
+ "Ѳ": "O̵",
+ "ⴱ": "O̵",
+ "Ꮎ": "O̵",
+ "Ꮻ": "O̵",
+ "ꭴ": "ơ",
+ "ﳙ": "oٰ",
+ "🄁": "O,",
+ "🄀": "O.",
+ "ơ": "o'",
+ "Ơ": "O'",
+ "Ꭴ": "O'",
+ "%": "º/₀",
+ "٪": "º/₀",
+ "⁒": "º/₀",
+ "‰": "º/₀₀",
+ "؉": "º/₀₀",
+ "‱": "º/₀₀₀",
+ "؊": "º/₀₀₀",
+ "œ": "oe",
+ "Œ": "OE",
+ "ɶ": "oᴇ",
+ "∞": "oo",
+ "ꝏ": "oo",
+ "ꚙ": "oo",
+ "Ꝏ": "OO",
+ "Ꚙ": "OO",
+ "ﳗ": "oج",
+ "ﱑ": "oج",
+ "ﳘ": "oم",
+ "ﱒ": "oم",
+ "ﶓ": "oمج",
+ "ﶔ": "oمم",
+ "ﱓ": "oى",
+ "ﱔ": "oى",
+ "ൟ": "oരo",
+ "တ": "oာ",
+ "㍘": "O点",
+ "ↄ": "ɔ",
+ "ᴐ": "ɔ",
+ "ͻ": "ɔ",
+ "𐑋": "ɔ",
+ "Ↄ": "Ɔ",
+ "Ͻ": "Ɔ",
+ "ꓛ": "Ɔ",
+ "𐐣": "Ɔ",
+ "ꬿ": "ɔ̸",
+ "ꭢ": "ɔe",
+ "𐐿": "ɷ",
+ "⍴": "p",
+ "p": "p",
+ "𝐩": "p",
+ "𝑝": "p",
+ "𝒑": "p",
+ "𝓅": "p",
+ "𝓹": "p",
+ "𝔭": "p",
+ "𝕡": "p",
+ "𝖕": "p",
+ "𝗉": "p",
+ "𝗽": "p",
+ "𝘱": "p",
+ "𝙥": "p",
+ "𝚙": "p",
+ "ρ": "p",
+ "ϱ": "p",
+ "𝛒": "p",
+ "𝛠": "p",
+ "𝜌": "p",
+ "𝜚": "p",
+ "𝝆": "p",
+ "𝝔": "p",
+ "𝞀": "p",
+ "𝞎": "p",
+ "𝞺": "p",
+ "𝟈": "p",
+ "ⲣ": "p",
+ "р": "p",
+ "P": "P",
+ "ℙ": "P",
+ "𝐏": "P",
+ "𝑃": "P",
+ "𝑷": "P",
+ "𝒫": "P",
+ "𝓟": "P",
+ "𝔓": "P",
+ "𝕻": "P",
+ "𝖯": "P",
+ "𝗣": "P",
+ "𝘗": "P",
+ "𝙋": "P",
+ "𝙿": "P",
+ "Ρ": "P",
+ "𝚸": "P",
+ "𝛲": "P",
+ "𝜬": "P",
+ "𝝦": "P",
+ "𝞠": "P",
+ "Ⲣ": "P",
+ "Р": "P",
+ "Ꮲ": "P",
+ "ᑭ": "P",
+ "ꓑ": "P",
+ "𐊕": "P",
+ "ƥ": "p̔",
+ "ᵽ": "p̵",
+ "ᑷ": "p·",
+ "ᒆ": "P'",
+ "ᴩ": "ᴘ",
+ "ꮲ": "ᴘ",
+ "φ": "ɸ",
+ "ϕ": "ɸ",
+ "𝛗": "ɸ",
+ "𝛟": "ɸ",
+ "𝜑": "ɸ",
+ "𝜙": "ɸ",
+ "𝝋": "ɸ",
+ "𝝓": "ɸ",
+ "𝞅": "ɸ",
+ "𝞍": "ɸ",
+ "𝞿": "ɸ",
+ "𝟇": "ɸ",
+ "ⲫ": "ɸ",
+ "ф": "ɸ",
+ "𝐪": "q",
+ "𝑞": "q",
+ "𝒒": "q",
+ "𝓆": "q",
+ "𝓺": "q",
+ "𝔮": "q",
+ "𝕢": "q",
+ "𝖖": "q",
+ "𝗊": "q",
+ "𝗾": "q",
+ "𝘲": "q",
+ "𝙦": "q",
+ "𝚚": "q",
+ "ԛ": "q",
+ "գ": "q",
+ "զ": "q",
+ "ℚ": "Q",
+ "𝐐": "Q",
+ "𝑄": "Q",
+ "𝑸": "Q",
+ "𝒬": "Q",
+ "𝓠": "Q",
+ "𝔔": "Q",
+ "𝕼": "Q",
+ "𝖰": "Q",
+ "𝗤": "Q",
+ "𝘘": "Q",
+ "𝙌": "Q",
+ "𝚀": "Q",
+ "ⵕ": "Q",
+ "ʠ": "q̔",
+ "🜀": "QE",
+ "ᶐ": "ɋ",
+ "ᴋ": "ĸ",
+ "κ": "ĸ",
+ "ϰ": "ĸ",
+ "𝛋": "ĸ",
+ "𝛞": "ĸ",
+ "𝜅": "ĸ",
+ "𝜘": "ĸ",
+ "𝜿": "ĸ",
+ "𝝒": "ĸ",
+ "𝝹": "ĸ",
+ "𝞌": "ĸ",
+ "𝞳": "ĸ",
+ "𝟆": "ĸ",
+ "ⲕ": "ĸ",
+ "к": "ĸ",
+ "ꮶ": "ĸ",
+ "қ": "ĸ̩",
+ "ҟ": "ĸ̵",
+ "𝐫": "r",
+ "𝑟": "r",
+ "𝒓": "r",
+ "𝓇": "r",
+ "𝓻": "r",
+ "𝔯": "r",
+ "𝕣": "r",
+ "𝖗": "r",
+ "𝗋": "r",
+ "𝗿": "r",
+ "𝘳": "r",
+ "𝙧": "r",
+ "𝚛": "r",
+ "ꭇ": "r",
+ "ꭈ": "r",
+ "ᴦ": "r",
+ "ⲅ": "r",
+ "г": "r",
+ "ꮁ": "r",
+ "𝈖": "R",
+ "ℛ": "R",
+ "ℜ": "R",
+ "ℝ": "R",
+ "𝐑": "R",
+ "𝑅": "R",
+ "𝑹": "R",
+ "𝓡": "R",
+ "𝕽": "R",
+ "𝖱": "R",
+ "𝗥": "R",
+ "𝘙": "R",
+ "𝙍": "R",
+ "𝚁": "R",
+ "Ʀ": "R",
+ "Ꭱ": "R",
+ "Ꮢ": "R",
+ "𐒴": "R",
+ "ᖇ": "R",
+ "ꓣ": "R",
+ "𖼵": "R",
+ "ɽ": "r̨",
+ "ɼ": "r̩",
+ "ɍ": "r̵",
+ "ғ": "r̵",
+ "ᵲ": "r̴",
+ "ґ": "r'",
+ "𑣣": "rn",
+ "m": "rn",
+ "ⅿ": "rn",
+ "𝐦": "rn",
+ "𝑚": "rn",
+ "𝒎": "rn",
+ "𝓂": "rn",
+ "𝓶": "rn",
+ "𝔪": "rn",
+ "𝕞": "rn",
+ "𝖒": "rn",
+ "𝗆": "rn",
+ "𝗺": "rn",
+ "𝘮": "rn",
+ "𝙢": "rn",
+ "𝚖": "rn",
+ "𑜀": "rn",
+ "₥": "rn̸",
+ "ɱ": "rn̦",
+ "ᵯ": "rn̴",
+ "₨": "Rs",
+ "ꭱ": "ʀ",
+ "ꮢ": "ʀ",
+ "я": "ᴙ",
+ "ᵳ": "ɾ̴",
+ "℩": "ɿ",
+ "s": "s",
+ "𝐬": "s",
+ "𝑠": "s",
+ "𝒔": "s",
+ "𝓈": "s",
+ "𝓼": "s",
+ "𝔰": "s",
+ "𝕤": "s",
+ "𝖘": "s",
+ "𝗌": "s",
+ "𝘀": "s",
+ "𝘴": "s",
+ "𝙨": "s",
+ "𝚜": "s",
+ "ꜱ": "s",
+ "ƽ": "s",
+ "ѕ": "s",
+ "ꮪ": "s",
+ "𑣁": "s",
+ "𐑈": "s",
+ "S": "S",
+ "𝐒": "S",
+ "𝑆": "S",
+ "𝑺": "S",
+ "𝒮": "S",
+ "𝓢": "S",
+ "𝔖": "S",
+ "𝕊": "S",
+ "𝕾": "S",
+ "𝖲": "S",
+ "𝗦": "S",
+ "𝘚": "S",
+ "𝙎": "S",
+ "𝚂": "S",
+ "Ѕ": "S",
+ "Տ": "S",
+ "Ꮥ": "S",
+ "Ꮪ": "S",
+ "ꓢ": "S",
+ "𖼺": "S",
+ "𐊖": "S",
+ "𐐠": "S",
+ "ʂ": "s̨",
+ "ᵴ": "s̴",
+ "ꞵ": "ß",
+ "β": "ß",
+ "ϐ": "ß",
+ "𝛃": "ß",
+ "𝛽": "ß",
+ "𝜷": "ß",
+ "𝝱": "ß",
+ "𝞫": "ß",
+ "Ᏸ": "ß",
+ "🝜": "sss",
+ "st": "st",
+ "∫": "ʃ",
+ "ꭍ": "ʃ",
+ "∑": "Ʃ",
+ "⅀": "Ʃ",
+ "Σ": "Ʃ",
+ "𝚺": "Ʃ",
+ "𝛴": "Ʃ",
+ "𝜮": "Ʃ",
+ "𝝨": "Ʃ",
+ "𝞢": "Ʃ",
+ "ⵉ": "Ʃ",
+ "∬": "ʃʃ",
+ "∭": "ʃʃʃ",
+ "⨌": "ʃʃʃʃ",
+ "𝐭": "t",
+ "𝑡": "t",
+ "𝒕": "t",
+ "𝓉": "t",
+ "𝓽": "t",
+ "𝔱": "t",
+ "𝕥": "t",
+ "𝖙": "t",
+ "𝗍": "t",
+ "𝘁": "t",
+ "𝘵": "t",
+ "𝙩": "t",
+ "𝚝": "t",
+ "⊤": "T",
+ "⟙": "T",
+ "🝨": "T",
+ "T": "T",
+ "𝐓": "T",
+ "𝑇": "T",
+ "𝑻": "T",
+ "𝒯": "T",
+ "𝓣": "T",
+ "𝔗": "T",
+ "𝕋": "T",
+ "𝕿": "T",
+ "𝖳": "T",
+ "𝗧": "T",
+ "𝘛": "T",
+ "𝙏": "T",
+ "𝚃": "T",
+ "Τ": "T",
+ "𝚻": "T",
+ "𝛵": "T",
+ "𝜯": "T",
+ "𝝩": "T",
+ "𝞣": "T",
+ "Ⲧ": "T",
+ "Т": "T",
+ "Ꭲ": "T",
+ "ꓔ": "T",
+ "𖼊": "T",
+ "𑢼": "T",
+ "𐊗": "T",
+ "𐊱": "T",
+ "𐌕": "T",
+ "ƭ": "t̔",
+ "⍡": "T̈",
+ "Ⱦ": "T̸",
+ "Ț": "Ţ",
+ "Ʈ": "T̨",
+ "Ҭ": "T̩",
+ "₮": "T⃫",
+ "ŧ": "t̵",
+ "Ŧ": "T̵",
+ "ᵵ": "t̴",
+ "Ⴀ": "Ꞇ",
+ "Ꜩ": "T3",
+ "ʨ": "tɕ",
+ "℡": "TEL",
+ "ꝷ": "tf",
+ "ʦ": "ts",
+ "ʧ": "tʃ",
+ "ꜩ": "tȝ",
+ "τ": "ᴛ",
+ "𝛕": "ᴛ",
+ "𝜏": "ᴛ",
+ "𝝉": "ᴛ",
+ "𝞃": "ᴛ",
+ "𝞽": "ᴛ",
+ "т": "ᴛ",
+ "ꭲ": "ᴛ",
+ "ҭ": "ᴛ̩",
+ "ţ": "ƫ",
+ "ț": "ƫ",
+ "Ꮏ": "ƫ",
+ "𝐮": "u",
+ "𝑢": "u",
+ "𝒖": "u",
+ "𝓊": "u",
+ "𝓾": "u",
+ "𝔲": "u",
+ "𝕦": "u",
+ "𝖚": "u",
+ "𝗎": "u",
+ "𝘂": "u",
+ "𝘶": "u",
+ "𝙪": "u",
+ "𝚞": "u",
+ "ꞟ": "u",
+ "ᴜ": "u",
+ "ꭎ": "u",
+ "ꭒ": "u",
+ "ʋ": "u",
+ "υ": "u",
+ "𝛖": "u",
+ "𝜐": "u",
+ "𝝊": "u",
+ "𝞄": "u",
+ "𝞾": "u",
+ "ս": "u",
+ "𐓶": "u",
+ "𑣘": "u",
+ "∪": "U",
+ "⋃": "U",
+ "𝐔": "U",
+ "𝑈": "U",
+ "𝑼": "U",
+ "𝒰": "U",
+ "𝓤": "U",
+ "𝔘": "U",
+ "𝕌": "U",
+ "𝖀": "U",
+ "𝖴": "U",
+ "𝗨": "U",
+ "𝘜": "U",
+ "𝙐": "U",
+ "𝚄": "U",
+ "Ս": "U",
+ "ሀ": "U",
+ "𐓎": "U",
+ "ᑌ": "U",
+ "ꓴ": "U",
+ "𖽂": "U",
+ "𑢸": "U",
+ "ǔ": "ŭ",
+ "Ǔ": "Ŭ",
+ "ᵾ": "u̵",
+ "ꮜ": "u̵",
+ "Ʉ": "U̵",
+ "Ꮜ": "U̵",
+ "ᑘ": "U·",
+ "ᑧ": "U'",
+ "ᵫ": "ue",
+ "ꭣ": "uo",
+ "ṃ": "ꭑ",
+ "պ": "ɰ",
+ "ሣ": "ɰ",
+ "℧": "Ʊ",
+ "ᘮ": "Ʊ",
+ "ᘴ": "Ʊ",
+ "ᵿ": "ʊ̵",
+ "∨": "v",
+ "⋁": "v",
+ "v": "v",
+ "ⅴ": "v",
+ "𝐯": "v",
+ "𝑣": "v",
+ "𝒗": "v",
+ "𝓋": "v",
+ "𝓿": "v",
+ "𝔳": "v",
+ "𝕧": "v",
+ "𝖛": "v",
+ "𝗏": "v",
+ "𝘃": "v",
+ "𝘷": "v",
+ "𝙫": "v",
+ "𝚟": "v",
+ "ᴠ": "v",
+ "ν": "v",
+ "𝛎": "v",
+ "𝜈": "v",
+ "𝝂": "v",
+ "𝝼": "v",
+ "𝞶": "v",
+ "ѵ": "v",
+ "ט": "v",
+ "𑜆": "v",
+ "ꮩ": "v",
+ "𑣀": "v",
+ "𝈍": "V",
+ "٧": "V",
+ "۷": "V",
+ "Ⅴ": "V",
+ "𝐕": "V",
+ "𝑉": "V",
+ "𝑽": "V",
+ "𝒱": "V",
+ "𝓥": "V",
+ "𝔙": "V",
+ "𝕍": "V",
+ "𝖁": "V",
+ "𝖵": "V",
+ "𝗩": "V",
+ "𝘝": "V",
+ "𝙑": "V",
+ "𝚅": "V",
+ "Ѵ": "V",
+ "ⴸ": "V",
+ "Ꮩ": "V",
+ "ᐯ": "V",
+ "ꛟ": "V",
+ "ꓦ": "V",
+ "𖼈": "V",
+ "𑢠": "V",
+ "𐔝": "V",
+ "𐆗": "V̵",
+ "ᐻ": "V·",
+ "🝬": "VB",
+ "ⅵ": "vi",
+ "ⅶ": "vii",
+ "ⅷ": "viii",
+ "Ⅵ": "Vl",
+ "Ⅶ": "Vll",
+ "Ⅷ": "Vlll",
+ "🜈": "Vᷤ",
+ "ᴧ": "ʌ",
+ "𐓘": "ʌ",
+ "٨": "Ʌ",
+ "۸": "Ʌ",
+ "Λ": "Ʌ",
+ "𝚲": "Ʌ",
+ "𝛬": "Ʌ",
+ "𝜦": "Ʌ",
+ "𝝠": "Ʌ",
+ "𝞚": "Ʌ",
+ "Л": "Ʌ",
+ "ⴷ": "Ʌ",
+ "𐒰": "Ʌ",
+ "ᐱ": "Ʌ",
+ "ꛎ": "Ʌ",
+ "ꓥ": "Ʌ",
+ "𖼽": "Ʌ",
+ "𐊍": "Ʌ",
+ "Ӆ": "Ʌ̦",
+ "ᐽ": "Ʌ·",
+ "ɯ": "w",
+ "𝐰": "w",
+ "𝑤": "w",
+ "𝒘": "w",
+ "𝓌": "w",
+ "𝔀": "w",
+ "𝔴": "w",
+ "𝕨": "w",
+ "𝖜": "w",
+ "𝗐": "w",
+ "𝘄": "w",
+ "𝘸": "w",
+ "𝙬": "w",
+ "𝚠": "w",
+ "ᴡ": "w",
+ "ѡ": "w",
+ "ԝ": "w",
+ "ա": "w",
+ "𑜊": "w",
+ "𑜎": "w",
+ "𑜏": "w",
+ "ꮃ": "w",
+ "𑣯": "W",
+ "𑣦": "W",
+ "𝐖": "W",
+ "𝑊": "W",
+ "𝑾": "W",
+ "𝒲": "W",
+ "𝓦": "W",
+ "𝔚": "W",
+ "𝕎": "W",
+ "𝖂": "W",
+ "𝖶": "W",
+ "𝗪": "W",
+ "𝘞": "W",
+ "𝙒": "W",
+ "𝚆": "W",
+ "Ԝ": "W",
+ "Ꮃ": "W",
+ "Ꮤ": "W",
+ "ꓪ": "W",
+ "ѽ": "w҆҇",
+ "𑓅": "ẇ",
+ "₩": "W̵",
+ "ꝡ": "w̦",
+ "ᴍ": "ʍ",
+ "м": "ʍ",
+ "ꮇ": "ʍ",
+ "ӎ": "ʍ̦",
+ "᙮": "x",
+ "×": "x",
+ "⤫": "x",
+ "⤬": "x",
+ "⨯": "x",
+ "x": "x",
+ "ⅹ": "x",
+ "𝐱": "x",
+ "𝑥": "x",
+ "𝒙": "x",
+ "𝓍": "x",
+ "𝔁": "x",
+ "𝔵": "x",
+ "𝕩": "x",
+ "𝖝": "x",
+ "𝗑": "x",
+ "𝘅": "x",
+ "𝘹": "x",
+ "𝙭": "x",
+ "𝚡": "x",
+ "х": "x",
+ "ᕁ": "x",
+ "ᕽ": "x",
+ "ⷯ": "ͯ",
+ "᙭": "X",
+ "╳": "X",
+ "𐌢": "X",
+ "𑣬": "X",
+ "X": "X",
+ "Ⅹ": "X",
+ "𝐗": "X",
+ "𝑋": "X",
+ "𝑿": "X",
+ "𝒳": "X",
+ "𝓧": "X",
+ "𝔛": "X",
+ "𝕏": "X",
+ "𝖃": "X",
+ "𝖷": "X",
+ "𝗫": "X",
+ "𝘟": "X",
+ "𝙓": "X",
+ "𝚇": "X",
+ "Ꭓ": "X",
+ "Χ": "X",
+ "𝚾": "X",
+ "𝛸": "X",
+ "𝜲": "X",
+ "𝝬": "X",
+ "𝞦": "X",
+ "Ⲭ": "X",
+ "Х": "X",
+ "ⵝ": "X",
+ "ᚷ": "X",
+ "ꓫ": "X",
+ "𐊐": "X",
+ "𐊴": "X",
+ "𐌗": "X",
+ "𐔧": "X",
+ "⨰": "ẋ",
+ "Ҳ": "X̩",
+ "𐆖": "X̵",
+ "ⅺ": "xi",
+ "ⅻ": "xii",
+ "Ⅺ": "Xl",
+ "Ⅻ": "Xll",
+ "ɣ": "y",
+ "ᶌ": "y",
+ "y": "y",
+ "𝐲": "y",
+ "𝑦": "y",
+ "𝒚": "y",
+ "𝓎": "y",
+ "𝔂": "y",
+ "𝔶": "y",
+ "𝕪": "y",
+ "𝖞": "y",
+ "𝗒": "y",
+ "𝘆": "y",
+ "𝘺": "y",
+ "𝙮": "y",
+ "𝚢": "y",
+ "ʏ": "y",
+ "ỿ": "y",
+ "ꭚ": "y",
+ "γ": "y",
+ "ℽ": "y",
+ "𝛄": "y",
+ "𝛾": "y",
+ "𝜸": "y",
+ "𝝲": "y",
+ "𝞬": "y",
+ "у": "y",
+ "ү": "y",
+ "ყ": "y",
+ "𑣜": "y",
+ "Y": "Y",
+ "𝐘": "Y",
+ "𝑌": "Y",
+ "𝒀": "Y",
+ "𝒴": "Y",
+ "𝓨": "Y",
+ "𝔜": "Y",
+ "𝕐": "Y",
+ "𝖄": "Y",
+ "𝖸": "Y",
+ "𝗬": "Y",
+ "𝘠": "Y",
+ "𝙔": "Y",
+ "𝚈": "Y",
+ "Υ": "Y",
+ "ϒ": "Y",
+ "𝚼": "Y",
+ "𝛶": "Y",
+ "𝜰": "Y",
+ "𝝪": "Y",
+ "𝞤": "Y",
+ "Ⲩ": "Y",
+ "У": "Y",
+ "Ү": "Y",
+ "Ꭹ": "Y",
+ "Ꮍ": "Y",
+ "ꓬ": "Y",
+ "𖽃": "Y",
+ "𑢤": "Y",
+ "𐊲": "Y",
+ "ƴ": "y̔",
+ "ɏ": "y̵",
+ "ұ": "y̵",
+ "¥": "Y̵",
+ "Ɏ": "Y̵",
+ "Ұ": "Y̵",
+ "ʒ": "ȝ",
+ "ꝫ": "ȝ",
+ "ⳍ": "ȝ",
+ "ӡ": "ȝ",
+ "ჳ": "ȝ",
+ "𝐳": "z",
+ "𝑧": "z",
+ "𝒛": "z",
+ "𝓏": "z",
+ "𝔃": "z",
+ "𝔷": "z",
+ "𝕫": "z",
+ "𝖟": "z",
+ "𝗓": "z",
+ "𝘇": "z",
+ "𝘻": "z",
+ "𝙯": "z",
+ "𝚣": "z",
+ "ᴢ": "z",
+ "ꮓ": "z",
+ "𑣄": "z",
+ "𐋵": "Z",
+ "𑣥": "Z",
+ "Z": "Z",
+ "ℤ": "Z",
+ "ℨ": "Z",
+ "𝐙": "Z",
+ "𝑍": "Z",
+ "𝒁": "Z",
+ "𝒵": "Z",
+ "𝓩": "Z",
+ "𝖅": "Z",
+ "𝖹": "Z",
+ "𝗭": "Z",
+ "𝘡": "Z",
+ "𝙕": "Z",
+ "𝚉": "Z",
+ "Ζ": "Z",
+ "𝚭": "Z",
+ "𝛧": "Z",
+ "𝜡": "Z",
+ "𝝛": "Z",
+ "𝞕": "Z",
+ "Ꮓ": "Z",
+ "ꓜ": "Z",
+ "𑢩": "Z",
+ "ʐ": "z̨",
+ "ƶ": "z̵",
+ "Ƶ": "Z̵",
+ "ȥ": "z̦",
+ "Ȥ": "Z̦",
+ "ᵶ": "z̴",
+ "ƿ": "þ",
+ "ϸ": "þ",
+ "Ϸ": "Þ",
+ "𐓄": "Þ",
+ "⁹": "ꝰ",
+ "ᴤ": "ƨ",
+ "ϩ": "ƨ",
+ "ꙅ": "ƨ",
+ "ь": "ƅ",
+ "ꮟ": "ƅ",
+ "ы": "ƅi",
+ "ꭾ": "ɂ",
+ "ˤ": "ˁ",
+ "ꛍ": "ʡ",
+ "⊙": "ʘ",
+ "☉": "ʘ",
+ "⨀": "ʘ",
+ "Ꙩ": "ʘ",
+ "ⵙ": "ʘ",
+ "𐓃": "ʘ",
+ "ℾ": "Γ",
+ "𝚪": "Γ",
+ "𝛤": "Γ",
+ "𝜞": "Γ",
+ "𝝘": "Γ",
+ "𝞒": "Γ",
+ "Ⲅ": "Γ",
+ "Г": "Γ",
+ "Ꮁ": "Γ",
+ "ᒥ": "Γ",
+ "𖼇": "Γ",
+ "Ғ": "Γ̵",
+ "ᒯ": "Γ·",
+ "Ґ": "Γ'",
+ "∆": "Δ",
+ "△": "Δ",
+ "🜂": "Δ",
+ "𝚫": "Δ",
+ "𝛥": "Δ",
+ "𝜟": "Δ",
+ "𝝙": "Δ",
+ "𝞓": "Δ",
+ "Ⲇ": "Δ",
+ "ⵠ": "Δ",
+ "ᐃ": "Δ",
+ "𖼚": "Δ",
+ "𐊅": "Δ",
+ "𐊣": "Δ",
+ "⍙": "Δ̲",
+ "ᐏ": "Δ·",
+ "ᐬ": "Δᐠ",
+ "𝟋": "ϝ",
+ "𝛇": "ζ",
+ "𝜁": "ζ",
+ "𝜻": "ζ",
+ "𝝵": "ζ",
+ "𝞯": "ζ",
+ "ⳤ": "ϗ",
+ "𝛌": "λ",
+ "𝜆": "λ",
+ "𝝀": "λ",
+ "𝝺": "λ",
+ "𝞴": "λ",
+ "Ⲗ": "λ",
+ "𐓛": "λ",
+ "µ": "μ",
+ "𝛍": "μ",
+ "𝜇": "μ",
+ "𝝁": "μ",
+ "𝝻": "μ",
+ "𝞵": "μ",
+ "𝛏": "ξ",
+ "𝜉": "ξ",
+ "𝝃": "ξ",
+ "𝝽": "ξ",
+ "𝞷": "ξ",
+ "𝚵": "Ξ",
+ "𝛯": "Ξ",
+ "𝜩": "Ξ",
+ "𝝣": "Ξ",
+ "𝞝": "Ξ",
+ "ϖ": "π",
+ "ℼ": "π",
+ "𝛑": "π",
+ "𝛡": "π",
+ "𝜋": "π",
+ "𝜛": "π",
+ "𝝅": "π",
+ "𝝕": "π",
+ "𝝿": "π",
+ "𝞏": "π",
+ "𝞹": "π",
+ "𝟉": "π",
+ "ᴨ": "π",
+ "п": "π",
+ "∏": "Π",
+ "ℿ": "Π",
+ "𝚷": "Π",
+ "𝛱": "Π",
+ "𝜫": "Π",
+ "𝝥": "Π",
+ "𝞟": "Π",
+ "Ⲡ": "Π",
+ "П": "Π",
+ "ꛛ": "Π",
+ "𐊭": "Ϙ",
+ "𐌒": "Ϙ",
+ "ϛ": "ς",
+ "𝛓": "ς",
+ "𝜍": "ς",
+ "𝝇": "ς",
+ "𝞁": "ς",
+ "𝞻": "ς",
+ "𝚽": "Φ",
+ "𝛷": "Φ",
+ "𝜱": "Φ",
+ "𝝫": "Φ",
+ "𝞥": "Φ",
+ "Ⲫ": "Φ",
+ "Ф": "Φ",
+ "Փ": "Φ",
+ "ቀ": "Φ",
+ "ᛰ": "Φ",
+ "𐊳": "Φ",
+ "ꭓ": "χ",
+ "ꭕ": "χ",
+ "𝛘": "χ",
+ "𝜒": "χ",
+ "𝝌": "χ",
+ "𝞆": "χ",
+ "𝟀": "χ",
+ "ⲭ": "χ",
+ "𝛙": "ψ",
+ "𝜓": "ψ",
+ "𝝍": "ψ",
+ "𝞇": "ψ",
+ "𝟁": "ψ",
+ "ѱ": "ψ",
+ "𐓹": "ψ",
+ "𝚿": "Ψ",
+ "𝛹": "Ψ",
+ "𝜳": "Ψ",
+ "𝝭": "Ψ",
+ "𝞧": "Ψ",
+ "Ⲯ": "Ψ",
+ "Ѱ": "Ψ",
+ "𐓑": "Ψ",
+ "ᛘ": "Ψ",
+ "𐊵": "Ψ",
+ "⍵": "ω",
+ "ꞷ": "ω",
+ "𝛚": "ω",
+ "𝜔": "ω",
+ "𝝎": "ω",
+ "𝞈": "ω",
+ "𝟂": "ω",
+ "ⲱ": "ω",
+ "ꙍ": "ω",
+ "Ω": "Ω",
+ "𝛀": "Ω",
+ "𝛺": "Ω",
+ "𝜴": "Ω",
+ "𝝮": "Ω",
+ "𝞨": "Ω",
+ "ᘯ": "Ω",
+ "ᘵ": "Ω",
+ "𐊶": "Ω",
+ "⍹": "ω̲",
+ "ώ": "ῴ",
+ "☰": "Ⲷ",
+ "Ⳝ": "Ϭ",
+ "җ": "ж̩",
+ "Җ": "Ж̩",
+ "𝈋": "И",
+ "Ͷ": "И",
+ "ꚡ": "И",
+ "𐐥": "И",
+ "Й": "Ѝ",
+ "Ҋ": "Ѝ̦",
+ "ѝ": "й",
+ "ҋ": "й̦",
+ "𐒼": "Ӄ",
+ "ᴫ": "л",
+ "ӆ": "л̦",
+ "ꭠ": "љ",
+ "𐓫": "ꙩ",
+ "ᷮ": "ⷬ",
+ "𐓍": "Ћ",
+ "𝈂": "Ӿ",
+ "𝈢": "Ѡ",
+ "Ꮗ": "Ѡ",
+ "ᗯ": "Ѡ",
+ "Ѽ": "Ѡ҆҇",
+ "ᣭ": "Ѡ·",
+ "Ꞷ": "Ꙍ",
+ "ӌ": "ҷ",
+ "Ӌ": "Ҷ",
+ "Ҿ": "Ҽ̨",
+ "ⲽ": "ш",
+ "Ⲽ": "Ш",
+ "Ꙑ": "Ъl",
+ "℈": "Э",
+ "🜁": "Ꙙ",
+ "𖼜": "Ꙙ",
+ "ꦒ": "ⰿ",
+ "և": "եւ",
+ "ኔ": "ձ",
+ "ﬔ": "մե",
+ "ﬕ": "մի",
+ "ﬗ": "մխ",
+ "ﬓ": "մն",
+ "∩": "Ո",
+ "⋂": "Ո",
+ "𝉅": "Ո",
+ "በ": "Ո",
+ "ᑎ": "Ո",
+ "ꓵ": "Ո",
+ "ᑚ": "Ո·",
+ "ᑨ": "Ո'",
+ "ﬖ": "վն",
+ "₽": "Ք",
+ "˓": "ՙ",
+ "ʿ": "ՙ",
+ "ℵ": "א",
+ "ﬡ": "א",
+ "אָ": "אַ",
+ "אּ": "אַ",
+ "ﭏ": "אל",
+ "ℶ": "ב",
+ "ℷ": "ג",
+ "ℸ": "ד",
+ "ﬢ": "ד",
+ "ﬣ": "ה",
+ "יּ": "יִ",
+ "ﬤ": "כ",
+ "ﬥ": "ל",
+ "ﬦ": "ם",
+ "ﬠ": "ע",
+ "ﬧ": "ר",
+ "שׂ": "שׁ",
+ "שּ": "שׁ",
+ "שּׂ": "שּׁ",
+ "ﬨ": "ת",
+ "ﺀ": "ء",
+ "۽": "ء͈",
+ "ﺂ": "آ",
+ "ﺁ": "آ",
+ "ﭑ": "ٱ",
+ "ﭐ": "ٱ",
+ "𞸁": "ب",
+ "𞸡": "ب",
+ "𞹡": "ب",
+ "𞺁": "ب",
+ "𞺡": "ب",
+ "ﺑ": "ب",
+ "ﺒ": "ب",
+ "ﺐ": "ب",
+ "ﺏ": "ب",
+ "ݑ": "بۛ",
+ "ࢶ": "بۢ",
+ "ࢡ": "بٔ",
+ "ﲠ": "بo",
+ "ﳢ": "بo",
+ "ﲜ": "بج",
+ "ﰅ": "بج",
+ "ﲝ": "بح",
+ "ﰆ": "بح",
+ "ﷂ": "بحى",
+ "ﲞ": "بخ",
+ "ﰇ": "بخ",
+ "ﳒ": "بخ",
+ "ﱋ": "بخ",
+ "ﶞ": "بخى",
+ "ﱪ": "بر",
+ "ﱫ": "بز",
+ "ﲟ": "بم",
+ "ﳡ": "بم",
+ "ﱬ": "بم",
+ "ﰈ": "بم",
+ "ﱭ": "بن",
+ "ﱮ": "بى",
+ "ﰉ": "بى",
+ "ﱯ": "بى",
+ "ﰊ": "بى",
+ "ﭔ": "ٻ",
+ "ﭕ": "ٻ",
+ "ﭓ": "ٻ",
+ "ﭒ": "ٻ",
+ "ې": "ٻ",
+ "ﯦ": "ٻ",
+ "ﯧ": "ٻ",
+ "ﯥ": "ٻ",
+ "ﯤ": "ٻ",
+ "ﭜ": "ڀ",
+ "ﭝ": "ڀ",
+ "ﭛ": "ڀ",
+ "ﭚ": "ڀ",
+ "ࢩ": "ݔ",
+ "ݧ": "ݔ",
+ "⍥": "ة",
+ "ö": "ة",
+ "ﺔ": "ة",
+ "ﺓ": "ة",
+ "ۃ": "ة",
+ "𞸕": "ت",
+ "𞸵": "ت",
+ "𞹵": "ت",
+ "𞺕": "ت",
+ "𞺵": "ت",
+ "ﺗ": "ت",
+ "ﺘ": "ت",
+ "ﺖ": "ت",
+ "ﺕ": "ت",
+ "ﲥ": "تo",
+ "ﳤ": "تo",
+ "ﲡ": "تج",
+ "ﰋ": "تج",
+ "ﵐ": "تجم",
+ "ﶠ": "تجى",
+ "ﶟ": "تجى",
+ "ﲢ": "تح",
+ "ﰌ": "تح",
+ "ﵒ": "تحج",
+ "ﵑ": "تحج",
+ "ﵓ": "تحم",
+ "ﲣ": "تخ",
+ "ﰍ": "تخ",
+ "ﵔ": "تخم",
+ "ﶢ": "تخى",
+ "ﶡ": "تخى",
+ "ﱰ": "تر",
+ "ﱱ": "تز",
+ "ﲤ": "تم",
+ "ﳣ": "تم",
+ "ﱲ": "تم",
+ "ﰎ": "تم",
+ "ﵕ": "تمج",
+ "ﵖ": "تمح",
+ "ﵗ": "تمخ",
+ "ﶤ": "تمى",
+ "ﶣ": "تمى",
+ "ﱳ": "تن",
+ "ﱴ": "تى",
+ "ﰏ": "تى",
+ "ﱵ": "تى",
+ "ﰐ": "تى",
+ "ﭠ": "ٺ",
+ "ﭡ": "ٺ",
+ "ﭟ": "ٺ",
+ "ﭞ": "ٺ",
+ "ﭤ": "ٿ",
+ "ﭥ": "ٿ",
+ "ﭣ": "ٿ",
+ "ﭢ": "ٿ",
+ "𞸂": "ج",
+ "𞸢": "ج",
+ "𞹂": "ج",
+ "𞹢": "ج",
+ "𞺂": "ج",
+ "𞺢": "ج",
+ "ﺟ": "ج",
+ "ﺠ": "ج",
+ "ﺞ": "ج",
+ "ﺝ": "ج",
+ "ﲧ": "جح",
+ "ﰕ": "جح",
+ "ﶦ": "جحى",
+ "ﶾ": "جحى",
+ "ﷻ": "جل جلlلo",
+ "ﲨ": "جم",
+ "ﰖ": "جم",
+ "ﵙ": "جمح",
+ "ﵘ": "جمح",
+ "ﶧ": "جمى",
+ "ﶥ": "جمى",
+ "ﴝ": "جى",
+ "ﴁ": "جى",
+ "ﴞ": "جى",
+ "ﴂ": "جى",
+ "ﭸ": "ڃ",
+ "ﭹ": "ڃ",
+ "ﭷ": "ڃ",
+ "ﭶ": "ڃ",
+ "ﭴ": "ڄ",
+ "ﭵ": "ڄ",
+ "ﭳ": "ڄ",
+ "ﭲ": "ڄ",
+ "ﭼ": "چ",
+ "ﭽ": "چ",
+ "ﭻ": "چ",
+ "ﭺ": "چ",
+ "ﮀ": "ڇ",
+ "ﮁ": "ڇ",
+ "ﭿ": "ڇ",
+ "ﭾ": "ڇ",
+ "𞸇": "ح",
+ "𞸧": "ح",
+ "𞹇": "ح",
+ "𞹧": "ح",
+ "𞺇": "ح",
+ "𞺧": "ح",
+ "ﺣ": "ح",
+ "ﺤ": "ح",
+ "ﺢ": "ح",
+ "ﺡ": "ح",
+ "څ": "حۛ",
+ "ځ": "حٔ",
+ "ݲ": "حٔ",
+ "ﲩ": "حج",
+ "ﰗ": "حج",
+ "ﶿ": "حجى",
+ "ﲪ": "حم",
+ "ﰘ": "حم",
+ "ﵛ": "حمى",
+ "ﵚ": "حمى",
+ "ﴛ": "حى",
+ "ﳿ": "حى",
+ "ﴜ": "حى",
+ "ﴀ": "حى",
+ "𞸗": "خ",
+ "𞸷": "خ",
+ "𞹗": "خ",
+ "𞹷": "خ",
+ "𞺗": "خ",
+ "𞺷": "خ",
+ "ﺧ": "خ",
+ "ﺨ": "خ",
+ "ﺦ": "خ",
+ "ﺥ": "خ",
+ "ﲫ": "خج",
+ "ﰙ": "خج",
+ "ﰚ": "خح",
+ "ﲬ": "خم",
+ "ﰛ": "خم",
+ "ﴟ": "خى",
+ "ﴃ": "خى",
+ "ﴠ": "خى",
+ "ﴄ": "خى",
+ "𐋡": "د",
+ "𞸃": "د",
+ "𞺃": "د",
+ "𞺣": "د",
+ "ﺪ": "د",
+ "ﺩ": "د",
+ "ڈ": "دؕ",
+ "ﮉ": "دؕ",
+ "ﮈ": "دؕ",
+ "ڎ": "دۛ",
+ "ﮇ": "دۛ",
+ "ﮆ": "دۛ",
+ "ۮ": "د̂",
+ "ࢮ": "د̤̣",
+ "𞸘": "ذ",
+ "𞺘": "ذ",
+ "𞺸": "ذ",
+ "ﺬ": "ذ",
+ "ﺫ": "ذ",
+ "ﱛ": "ذٰ",
+ "ڋ": "ڊؕ",
+ "ﮅ": "ڌ",
+ "ﮄ": "ڌ",
+ "ﮃ": "ڍ",
+ "ﮂ": "ڍ",
+ "𞸓": "ر",
+ "𞺓": "ر",
+ "𞺳": "ر",
+ "ﺮ": "ر",
+ "ﺭ": "ر",
+ "ڑ": "رؕ",
+ "ﮍ": "رؕ",
+ "ﮌ": "رؕ",
+ "ژ": "رۛ",
+ "ﮋ": "رۛ",
+ "ﮊ": "رۛ",
+ "ڒ": "ر̆",
+ "ࢹ": "ر̆̇",
+ "ۯ": "ر̂",
+ "ݬ": "رٔ",
+ "ﱜ": "رٰ",
+ "ﷶ": "رسول",
+ "﷼": "رىlل",
+ "𞸆": "ز",
+ "𞺆": "ز",
+ "𞺦": "ز",
+ "ﺰ": "ز",
+ "ﺯ": "ز",
+ "ࢲ": "ز̂",
+ "ݱ": "ڗؕ",
+ "𞸎": "س",
+ "𞸮": "س",
+ "𞹎": "س",
+ "𞹮": "س",
+ "𞺎": "س",
+ "𞺮": "س",
+ "ﺳ": "س",
+ "ﺴ": "س",
+ "ﺲ": "س",
+ "ﺱ": "س",
+ "ش": "سۛ",
+ "𞸔": "سۛ",
+ "𞸴": "سۛ",
+ "𞹔": "سۛ",
+ "𞹴": "سۛ",
+ "𞺔": "سۛ",
+ "𞺴": "سۛ",
+ "ﺷ": "سۛ",
+ "ﺸ": "سۛ",
+ "ﺶ": "سۛ",
+ "ﺵ": "سۛ",
+ "ݾ": "س̂",
+ "ﴱ": "سo",
+ "ﳨ": "سo",
+ "ﴲ": "سۛo",
+ "ﳪ": "سۛo",
+ "ﲭ": "سج",
+ "ﴴ": "سج",
+ "ﰜ": "سج",
+ "ﴭ": "سۛج",
+ "ﴷ": "سۛج",
+ "ﴥ": "سۛج",
+ "ﴉ": "سۛج",
+ "ﵝ": "سجح",
+ "ﵞ": "سجى",
+ "ﵩ": "سۛجى",
+ "ﲮ": "سح",
+ "ﴵ": "سح",
+ "ﰝ": "سح",
+ "ﴮ": "سۛح",
+ "ﴸ": "سۛح",
+ "ﴦ": "سۛح",
+ "ﴊ": "سۛح",
+ "ﵜ": "سحج",
+ "ﵨ": "سۛحم",
+ "ﵧ": "سۛحم",
+ "ﶪ": "سۛحى",
+ "ﲯ": "سخ",
+ "ﴶ": "سخ",
+ "ﰞ": "سخ",
+ "ﴯ": "سۛخ",
+ "ﴹ": "سۛخ",
+ "ﴧ": "سۛخ",
+ "ﴋ": "سۛخ",
+ "ﶨ": "سخى",
+ "ﷆ": "سخى",
+ "ﴪ": "سر",
+ "ﴎ": "سر",
+ "ﴩ": "سۛر",
+ "ﴍ": "سۛر",
+ "ﲰ": "سم",
+ "ﳧ": "سم",
+ "ﰟ": "سم",
+ "ﴰ": "سۛم",
+ "ﳩ": "سۛم",
+ "ﴨ": "سۛم",
+ "ﴌ": "سۛم",
+ "ﵡ": "سمج",
+ "ﵠ": "سمح",
+ "ﵟ": "سمح",
+ "ﵫ": "سۛمخ",
+ "ﵪ": "سۛمخ",
+ "ﵣ": "سمم",
+ "ﵢ": "سمم",
+ "ﵭ": "سۛمم",
+ "ﵬ": "سۛمم",
+ "ﴗ": "سى",
+ "ﳻ": "سى",
+ "ﴘ": "سى",
+ "ﳼ": "سى",
+ "ﴙ": "سۛى",
+ "ﳽ": "سۛى",
+ "ﴚ": "سۛى",
+ "ﳾ": "سۛى",
+ "𐋲": "ص",
+ "𞸑": "ص",
+ "𞸱": "ص",
+ "𞹑": "ص",
+ "𞹱": "ص",
+ "𞺑": "ص",
+ "𞺱": "ص",
+ "ﺻ": "ص",
+ "ﺼ": "ص",
+ "ﺺ": "ص",
+ "ﺹ": "ص",
+ "ڞ": "صۛ",
+ "ࢯ": "ص̤̣",
+ "ﲱ": "صح",
+ "ﰠ": "صح",
+ "ﵥ": "صحح",
+ "ﵤ": "صحح",
+ "ﶩ": "صحى",
+ "ﲲ": "صخ",
+ "ﴫ": "صر",
+ "ﴏ": "صر",
+ "ﷵ": "صلعم",
+ "ﷹ": "صلى",
+ "ﷰ": "صلى",
+ "ﷺ": "صلى lللo علىo وسلم",
+ "ﲳ": "صم",
+ "ﰡ": "صم",
+ "ﷅ": "صمم",
+ "ﵦ": "صمم",
+ "ﴡ": "صى",
+ "ﴅ": "صى",
+ "ﴢ": "صى",
+ "ﴆ": "صى",
+ "𞸙": "ض",
+ "𞸹": "ض",
+ "𞹙": "ض",
+ "𞹹": "ض",
+ "𞺙": "ض",
+ "𞺹": "ض",
+ "ﺿ": "ض",
+ "ﻀ": "ض",
+ "ﺾ": "ض",
+ "ﺽ": "ض",
+ "ﲴ": "ضج",
+ "ﰢ": "ضج",
+ "ﲵ": "ضح",
+ "ﰣ": "ضح",
+ "ﵮ": "ضحى",
+ "ﶫ": "ضحى",
+ "ﲶ": "ضخ",
+ "ﰤ": "ضخ",
+ "ﵰ": "ضخم",
+ "ﵯ": "ضخم",
+ "ﴬ": "ضر",
+ "ﴐ": "ضر",
+ "ﲷ": "ضم",
+ "ﰥ": "ضم",
+ "ﴣ": "ضى",
+ "ﴇ": "ضى",
+ "ﴤ": "ضى",
+ "ﴈ": "ضى",
+ "𐋨": "ط",
+ "𞸈": "ط",
+ "𞹨": "ط",
+ "𞺈": "ط",
+ "𞺨": "ط",
+ "ﻃ": "ط",
+ "ﻄ": "ط",
+ "ﻂ": "ط",
+ "ﻁ": "ط",
+ "ڟ": "طۛ",
+ "ﲸ": "طح",
+ "ﰦ": "طح",
+ "ﴳ": "طم",
+ "ﴺ": "طم",
+ "ﰧ": "طم",
+ "ﵲ": "طمح",
+ "ﵱ": "طمح",
+ "ﵳ": "طمم",
+ "ﵴ": "طمى",
+ "ﴑ": "طى",
+ "ﳵ": "طى",
+ "ﴒ": "طى",
+ "ﳶ": "طى",
+ "𞸚": "ظ",
+ "𞹺": "ظ",
+ "𞺚": "ظ",
+ "𞺺": "ظ",
+ "ﻇ": "ظ",
+ "ﻈ": "ظ",
+ "ﻆ": "ظ",
+ "ﻅ": "ظ",
+ "ﲹ": "ظم",
+ "ﴻ": "ظم",
+ "ﰨ": "ظم",
+ "؏": "ع",
+ "𞸏": "ع",
+ "𞸯": "ع",
+ "𞹏": "ع",
+ "𞹯": "ع",
+ "𞺏": "ع",
+ "𞺯": "ع",
+ "ﻋ": "ع",
+ "ﻌ": "ع",
+ "ﻊ": "ع",
+ "ﻉ": "ع",
+ "ﲺ": "عج",
+ "ﰩ": "عج",
+ "ﷄ": "عجم",
+ "ﵵ": "عجم",
+ "ﷷ": "علىo",
+ "ﲻ": "عم",
+ "ﰪ": "عم",
+ "ﵷ": "عمم",
+ "ﵶ": "عمم",
+ "ﵸ": "عمى",
+ "ﶶ": "عمى",
+ "ﴓ": "عى",
+ "ﳷ": "عى",
+ "ﴔ": "عى",
+ "ﳸ": "عى",
+ "𞸛": "غ",
+ "𞸻": "غ",
+ "𞹛": "غ",
+ "𞹻": "غ",
+ "𞺛": "غ",
+ "𞺻": "غ",
+ "ﻏ": "غ",
+ "ﻐ": "غ",
+ "ﻎ": "غ",
+ "ﻍ": "غ",
+ "ﲼ": "غج",
+ "ﰫ": "غج",
+ "ﲽ": "غم",
+ "ﰬ": "غم",
+ "ﵹ": "غمم",
+ "ﵻ": "غمى",
+ "ﵺ": "غمى",
+ "ﴕ": "غى",
+ "ﳹ": "غى",
+ "ﴖ": "غى",
+ "ﳺ": "غى",
+ "𞸐": "ف",
+ "𞸰": "ف",
+ "𞹰": "ف",
+ "𞺐": "ف",
+ "𞺰": "ف",
+ "ﻓ": "ف",
+ "ﻔ": "ف",
+ "ﻒ": "ف",
+ "ﻑ": "ف",
+ "ڧ": "ف",
+ "ﲾ": "فج",
+ "ﰭ": "فج",
+ "ﲿ": "فح",
+ "ﰮ": "فح",
+ "ﳀ": "فخ",
+ "ﰯ": "فخ",
+ "ﵽ": "فخم",
+ "ﵼ": "فخم",
+ "ﳁ": "فم",
+ "ﰰ": "فم",
+ "ﷁ": "فمى",
+ "ﱼ": "فى",
+ "ﰱ": "فى",
+ "ﱽ": "فى",
+ "ﰲ": "فى",
+ "𞸞": "ڡ",
+ "𞹾": "ڡ",
+ "ࢻ": "ڡ",
+ "ٯ": "ڡ",
+ "𞸟": "ڡ",
+ "𞹟": "ڡ",
+ "ࢼ": "ڡ",
+ "ڤ": "ڡۛ",
+ "ﭬ": "ڡۛ",
+ "ﭭ": "ڡۛ",
+ "ﭫ": "ڡۛ",
+ "ﭪ": "ڡۛ",
+ "ڨ": "ڡۛ",
+ "ࢤ": "ڢۛ",
+ "ﭰ": "ڦ",
+ "ﭱ": "ڦ",
+ "ﭯ": "ڦ",
+ "ﭮ": "ڦ",
+ "𞸒": "ق",
+ "𞸲": "ق",
+ "𞹒": "ق",
+ "𞹲": "ق",
+ "𞺒": "ق",
+ "𞺲": "ق",
+ "ﻗ": "ق",
+ "ﻘ": "ق",
+ "ﻖ": "ق",
+ "ﻕ": "ق",
+ "ﳂ": "قح",
+ "ﰳ": "قح",
+ "ﷱ": "قلى",
+ "ﳃ": "قم",
+ "ﰴ": "قم",
+ "ﶴ": "قمح",
+ "ﵾ": "قمح",
+ "ﵿ": "قمم",
+ "ﶲ": "قمى",
+ "ﱾ": "قى",
+ "ﰵ": "قى",
+ "ﱿ": "قى",
+ "ﰶ": "قى",
+ "𞸊": "ك",
+ "𞸪": "ك",
+ "𞹪": "ك",
+ "ﻛ": "ك",
+ "ﻜ": "ك",
+ "ﻚ": "ك",
+ "ﻙ": "ك",
+ "ک": "ك",
+ "ﮐ": "ك",
+ "ﮑ": "ك",
+ "ﮏ": "ك",
+ "ﮎ": "ك",
+ "ڪ": "ك",
+ "ڭ": "كۛ",
+ "ﯕ": "كۛ",
+ "ﯖ": "كۛ",
+ "ﯔ": "كۛ",
+ "ﯓ": "كۛ",
+ "ݣ": "كۛ",
+ "ﲀ": "كl",
+ "ﰷ": "كl",
+ "ﳄ": "كج",
+ "ﰸ": "كج",
+ "ﳅ": "كح",
+ "ﰹ": "كح",
+ "ﳆ": "كخ",
+ "ﰺ": "كخ",
+ "ﳇ": "كل",
+ "ﳫ": "كل",
+ "ﲁ": "كل",
+ "ﰻ": "كل",
+ "ﳈ": "كم",
+ "ﳬ": "كم",
+ "ﲂ": "كم",
+ "ﰼ": "كم",
+ "ﷃ": "كمم",
+ "ﶻ": "كمم",
+ "ﶷ": "كمى",
+ "ﲃ": "كى",
+ "ﰽ": "كى",
+ "ﲄ": "كى",
+ "ﰾ": "كى",
+ "ݢ": "ڬ",
+ "ﮔ": "گ",
+ "ﮕ": "گ",
+ "ﮓ": "گ",
+ "ﮒ": "گ",
+ "ࢰ": "گ",
+ "ڴ": "گۛ",
+ "ﮜ": "ڱ",
+ "ﮝ": "ڱ",
+ "ﮛ": "ڱ",
+ "ﮚ": "ڱ",
+ "ﮘ": "ڳ",
+ "ﮙ": "ڳ",
+ "ﮗ": "ڳ",
+ "ﮖ": "ڳ",
+ "𞸋": "ل",
+ "𞸫": "ل",
+ "𞹋": "ل",
+ "𞺋": "ل",
+ "𞺫": "ل",
+ "ﻟ": "ل",
+ "ﻠ": "ل",
+ "ﻞ": "ل",
+ "ﻝ": "ل",
+ "ڷ": "لۛ",
+ "ڵ": "ل̆",
+ "ﻼ": "لl",
+ "ﻻ": "لl",
+ "ﻺ": "لlٕ",
+ "ﻹ": "لlٕ",
+ "ﻸ": "لlٴ",
+ "ﻷ": "لlٴ",
+ "ﳍ": "لo",
+ "ﻶ": "لآ",
+ "ﻵ": "لآ",
+ "ﳉ": "لج",
+ "ﰿ": "لج",
+ "ﶃ": "لجج",
+ "ﶄ": "لجج",
+ "ﶺ": "لجم",
+ "ﶼ": "لجم",
+ "ﶬ": "لجى",
+ "ﳊ": "لح",
+ "ﱀ": "لح",
+ "ﶵ": "لحم",
+ "ﶀ": "لحم",
+ "ﶂ": "لحى",
+ "ﶁ": "لحى",
+ "ﳋ": "لخ",
+ "ﱁ": "لخ",
+ "ﶆ": "لخم",
+ "ﶅ": "لخم",
+ "ﳌ": "لم",
+ "ﳭ": "لم",
+ "ﲅ": "لم",
+ "ﱂ": "لم",
+ "ﶈ": "لمح",
+ "ﶇ": "لمح",
+ "ﶭ": "لمى",
+ "ﲆ": "لى",
+ "ﱃ": "لى",
+ "ﲇ": "لى",
+ "ﱄ": "لى",
+ "𞸌": "م",
+ "𞸬": "م",
+ "𞹬": "م",
+ "𞺌": "م",
+ "𞺬": "م",
+ "ﻣ": "م",
+ "ﻤ": "م",
+ "ﻢ": "م",
+ "ﻡ": "م",
+ "ࢧ": "مۛ",
+ "۾": "م͈",
+ "ﲈ": "مl",
+ "ﳎ": "مج",
+ "ﱅ": "مج",
+ "ﶌ": "مجح",
+ "ﶒ": "مجخ",
+ "ﶍ": "مجم",
+ "ﷀ": "مجى",
+ "ﳏ": "مح",
+ "ﱆ": "مح",
+ "ﶉ": "محج",
+ "ﶊ": "محم",
+ "ﷴ": "محمد",
+ "ﶋ": "محى",
+ "ﳐ": "مخ",
+ "ﱇ": "مخ",
+ "ﶎ": "مخج",
+ "ﶏ": "مخم",
+ "ﶹ": "مخى",
+ "ﳑ": "مم",
+ "ﲉ": "مم",
+ "ﱈ": "مم",
+ "ﶱ": "ممى",
+ "ﱉ": "مى",
+ "ﱊ": "مى",
+ "𞸍": "ن",
+ "𞸭": "ن",
+ "𞹍": "ن",
+ "𞹭": "ن",
+ "𞺍": "ن",
+ "𞺭": "ن",
+ "ﻧ": "ن",
+ "ﻨ": "ن",
+ "ﻦ": "ن",
+ "ﻥ": "ن",
+ "ݨ": "نؕ",
+ "ݩ": "ن̆",
+ "ﳖ": "نo",
+ "ﳯ": "نo",
+ "ﶸ": "نجح",
+ "ﶽ": "نجح",
+ "ﶘ": "نجم",
+ "ﶗ": "نجم",
+ "ﶙ": "نجى",
+ "ﷇ": "نجى",
+ "ﳓ": "نح",
+ "ﱌ": "نح",
+ "ﶕ": "نحم",
+ "ﶖ": "نحى",
+ "ﶳ": "نحى",
+ "ﳔ": "نخ",
+ "ﱍ": "نخ",
+ "ﲊ": "نر",
+ "ﲋ": "نز",
+ "ﳕ": "نم",
+ "ﳮ": "نم",
+ "ﲌ": "نم",
+ "ﱎ": "نم",
+ "ﶛ": "نمى",
+ "ﶚ": "نمى",
+ "ﲍ": "نن",
+ "ﲎ": "نى",
+ "ﱏ": "نى",
+ "ﲏ": "نى",
+ "ﱐ": "نى",
+ "ۂ": "ۀ",
+ "ﮥ": "ۀ",
+ "ﮤ": "ۀ",
+ "𐋤": "و",
+ "𞸅": "و",
+ "𞺅": "و",
+ "𞺥": "و",
+ "ﻮ": "و",
+ "ﻭ": "و",
+ "ࢱ": "و",
+ "ۋ": "وۛ",
+ "ﯟ": "وۛ",
+ "ﯞ": "وۛ",
+ "ۇ": "و̓",
+ "ﯘ": "و̓",
+ "ﯗ": "و̓",
+ "ۆ": "و̆",
+ "ﯚ": "و̆",
+ "ﯙ": "و̆",
+ "ۉ": "و̂",
+ "ﯣ": "و̂",
+ "ﯢ": "و̂",
+ "ۈ": "وٰ",
+ "ﯜ": "وٰ",
+ "ﯛ": "وٰ",
+ "ؤ": "وٴ",
+ "ﺆ": "وٴ",
+ "ﺅ": "وٴ",
+ "ٶ": "وٴ",
+ "ٷ": "و̓ٴ",
+ "ﯝ": "و̓ٴ",
+ "ﷸ": "وسلم",
+ "ﯡ": "ۅ",
+ "ﯠ": "ۅ",
+ "ٮ": "ى",
+ "𞸜": "ى",
+ "𞹼": "ى",
+ "ں": "ى",
+ "𞸝": "ى",
+ "𞹝": "ى",
+ "ﮟ": "ى",
+ "ﮞ": "ى",
+ "ࢽ": "ى",
+ "ﯨ": "ى",
+ "ﯩ": "ى",
+ "ﻰ": "ى",
+ "ﻯ": "ى",
+ "ي": "ى",
+ "𞸉": "ى",
+ "𞸩": "ى",
+ "𞹉": "ى",
+ "𞹩": "ى",
+ "𞺉": "ى",
+ "𞺩": "ى",
+ "ﻳ": "ى",
+ "ﻴ": "ى",
+ "ﻲ": "ى",
+ "ﻱ": "ى",
+ "ی": "ى",
+ "ﯾ": "ى",
+ "ﯿ": "ى",
+ "ﯽ": "ى",
+ "ﯼ": "ى",
+ "ے": "ى",
+ "ﮯ": "ى",
+ "ﮮ": "ى",
+ "ٹ": "ىؕ",
+ "ﭨ": "ىؕ",
+ "ﭩ": "ىؕ",
+ "ﭧ": "ىؕ",
+ "ﭦ": "ىؕ",
+ "ڻ": "ىؕ",
+ "ﮢ": "ىؕ",
+ "ﮣ": "ىؕ",
+ "ﮡ": "ىؕ",
+ "ﮠ": "ىؕ",
+ "پ": "ىۛ",
+ "ﭘ": "ىۛ",
+ "ﭙ": "ىۛ",
+ "ﭗ": "ىۛ",
+ "ﭖ": "ىۛ",
+ "ث": "ىۛ",
+ "𞸖": "ىۛ",
+ "𞸶": "ىۛ",
+ "𞹶": "ىۛ",
+ "𞺖": "ىۛ",
+ "𞺶": "ىۛ",
+ "ﺛ": "ىۛ",
+ "ﺜ": "ىۛ",
+ "ﺚ": "ىۛ",
+ "ﺙ": "ىۛ",
+ "ڽ": "ىۛ",
+ "ۑ": "ىۛ",
+ "ؿ": "ىۛ",
+ "ࢷ": "ىۛۢ",
+ "ݖ": "ى̆",
+ "ێ": "ى̆",
+ "ࢺ": "ى̆̇",
+ "ؽ": "ى̂",
+ "ࢨ": "ىٔ",
+ "ﲐ": "ىٰ",
+ "ﱝ": "ىٰ",
+ "ﳞ": "ىo",
+ "ﳱ": "ىo",
+ "ﳦ": "ىۛo",
+ "ئ": "ىٴ",
+ "ﺋ": "ىٴ",
+ "ﺌ": "ىٴ",
+ "ﺊ": "ىٴ",
+ "ﺉ": "ىٴ",
+ "ٸ": "ىٴ",
+ "ﯫ": "ىٴl",
+ "ﯪ": "ىٴl",
+ "ﲛ": "ىٴo",
+ "ﳠ": "ىٴo",
+ "ﯭ": "ىٴo",
+ "ﯬ": "ىٴo",
+ "ﯸ": "ىٴٻ",
+ "ﯷ": "ىٴٻ",
+ "ﯶ": "ىٴٻ",
+ "ﲗ": "ىٴج",
+ "ﰀ": "ىٴج",
+ "ﲘ": "ىٴح",
+ "ﰁ": "ىٴح",
+ "ﲙ": "ىٴخ",
+ "ﱤ": "ىٴر",
+ "ﱥ": "ىٴز",
+ "ﲚ": "ىٴم",
+ "ﳟ": "ىٴم",
+ "ﱦ": "ىٴم",
+ "ﰂ": "ىٴم",
+ "ﱧ": "ىٴن",
+ "ﯯ": "ىٴو",
+ "ﯮ": "ىٴو",
+ "ﯱ": "ىٴو̓",
+ "ﯰ": "ىٴو̓",
+ "ﯳ": "ىٴو̆",
+ "ﯲ": "ىٴو̆",
+ "ﯵ": "ىٴوٰ",
+ "ﯴ": "ىٴوٰ",
+ "ﯻ": "ىٴى",
+ "ﯺ": "ىٴى",
+ "ﱨ": "ىٴى",
+ "ﯹ": "ىٴى",
+ "ﰃ": "ىٴى",
+ "ﱩ": "ىٴى",
+ "ﰄ": "ىٴى",
+ "ﳚ": "ىج",
+ "ﱕ": "ىج",
+ "ﰑ": "ىۛج",
+ "ﶯ": "ىجى",
+ "ﳛ": "ىح",
+ "ﱖ": "ىح",
+ "ﶮ": "ىحى",
+ "ﳜ": "ىخ",
+ "ﱗ": "ىخ",
+ "ﲑ": "ىر",
+ "ﱶ": "ىۛر",
+ "ﲒ": "ىز",
+ "ﱷ": "ىۛز",
+ "ﳝ": "ىم",
+ "ﳰ": "ىم",
+ "ﲓ": "ىم",
+ "ﱘ": "ىم",
+ "ﲦ": "ىۛم",
+ "ﳥ": "ىۛم",
+ "ﱸ": "ىۛم",
+ "ﰒ": "ىۛم",
+ "ﶝ": "ىمم",
+ "ﶜ": "ىمم",
+ "ﶰ": "ىمى",
+ "ﲔ": "ىن",
+ "ﱹ": "ىۛن",
+ "ﲕ": "ىى",
+ "ﱙ": "ىى",
+ "ﲖ": "ىى",
+ "ﱚ": "ىى",
+ "ﱺ": "ىۛى",
+ "ﰓ": "ىۛى",
+ "ﱻ": "ىۛى",
+ "ﰔ": "ىۛى",
+ "ﮱ": "ۓ",
+ "ﮰ": "ۓ",
+ "𐊸": "ⵀ",
+ "⁞": "ⵂ",
+ "⸽": "ⵂ",
+ "⦙": "ⵂ",
+ "︙": "ⵗ",
+ "⁝": "ⵗ",
+ "⋮": "ⵗ",
+ "Մ": "ሆ",
+ "Ռ": "ቡ",
+ "Ի": "ኮ",
+ "Պ": "ጣ",
+ "आ": "अा",
+ "ऒ": "अाॆ",
+ "ओ": "अाे",
+ "औ": "अाै",
+ "ऄ": "अॆ",
+ "ऑ": "अॉ",
+ "ऍ": "एॅ",
+ "ऎ": "एॆ",
+ "ऐ": "एे",
+ "ई": "र्इ",
+ "ઽ": "ऽ",
+ "𑇜": "ꣻ",
+ "𑇋": "ऺ",
+ "ુ": "ु",
+ "ૂ": "ू",
+ "ੋ": "ॆ",
+ "੍": "्",
+ "્": "्",
+ "আ": "অা",
+ "ৠ": "ঋৃ",
+ "ৡ": "ঋৃ",
+ "𑒒": "ঘ",
+ "𑒔": "চ",
+ "𑒖": "জ",
+ "𑒘": "ঞ",
+ "𑒙": "ট",
+ "𑒛": "ড",
+ "𑒪": "ণ",
+ "𑒞": "ত",
+ "𑒟": "থ",
+ "𑒠": "দ",
+ "𑒡": "ধ",
+ "𑒢": "ন",
+ "𑒣": "প",
+ "𑒩": "ব",
+ "𑒧": "ম",
+ "𑒨": "য",
+ "𑒫": "র",
+ "𑒝": "ল",
+ "𑒭": "ষ",
+ "𑒮": "স",
+ "𑓄": "ঽ",
+ "𑒰": "া",
+ "𑒱": "ি",
+ "𑒹": "ে",
+ "𑒼": "ো",
+ "𑒾": "ৌ",
+ "𑓂": "্",
+ "𑒽": "ৗ",
+ "ਉ": "ੳੁ",
+ "ਊ": "ੳੂ",
+ "ਆ": "ਅਾ",
+ "ਐ": "ਅੈ",
+ "ਔ": "ਅੌ",
+ "ਇ": "ੲਿ",
+ "ਈ": "ੲੀ",
+ "ਏ": "ੲੇ",
+ "આ": "અા",
+ "ઑ": "અાૅ",
+ "ઓ": "અાે",
+ "ઔ": "અાૈ",
+ "ઍ": "અૅ",
+ "એ": "અે",
+ "ઐ": "અૈ",
+ "ଆ": "ଅା",
+ "௮": "அ",
+ "ர": "ஈ",
+ "ா": "ஈ",
+ "௫": "ஈு",
+ "௨": "உ",
+ "ഉ": "உ",
+ "ஊ": "உள",
+ "ഊ": "உൗ",
+ "௭": "எ",
+ "௷": "எவ",
+ "ஜ": "ஐ",
+ "ജ": "ஐ",
+ "௧": "க",
+ "௪": "ச",
+ "௬": "சு",
+ "௲": "சூ",
+ "ഺ": "டி",
+ "ണ": "ண",
+ "௺": "நீ",
+ "௴": "மீ",
+ "௰": "ய",
+ "ഴ": "ழ",
+ "ௗ": "ள",
+ "ை": "ன",
+ "ശ": "ஶ",
+ "௸": "ஷ",
+ "ി": "ி",
+ "ീ": "ி",
+ "ொ": "ெஈ",
+ "ௌ": "ெள",
+ "ோ": "ேஈ",
+ "ಅ": "అ",
+ "ಆ": "ఆ",
+ "ಇ": "ఇ",
+ "ౠ": "ఋా",
+ "ౡ": "ఌా",
+ "ಒ": "ఒ",
+ "ఔ": "ఒౌ",
+ "ಔ": "ఒౌ",
+ "ఓ": "ఒౕ",
+ "ಓ": "ఒౕ",
+ "ಜ": "జ",
+ "ಞ": "ఞ",
+ "ఢ": "డ̣",
+ "ಣ": "ణ",
+ "థ": "ధּ",
+ "భ": "బ̣",
+ "ಯ": "య",
+ "ఠ": "రּ",
+ "ಱ": "ఱ",
+ "ಲ": "ల",
+ "ష": "వ̣",
+ "హ": "వా",
+ "మ": "వు",
+ "ూ": "ుా",
+ "ౄ": "ృా",
+ "ೡ": "ಌಾ",
+ "ഈ": "ഇൗ",
+ "ഐ": "എെ",
+ "ഓ": "ഒാ",
+ "ഔ": "ഒൗ",
+ "ൡ": "ഞ",
+ "൫": "ദ്ര",
+ "൹": "നു",
+ "ഌ": "നു",
+ "ങ": "നു",
+ "൯": "ന്",
+ "ൻ": "ന്",
+ "൬": "ന്ന",
+ "൚": "ന്മ",
+ "റ": "ര",
+ "൪": "ര്",
+ "ർ": "ര്",
+ "൮": "വ്ര",
+ "൶": "ഹ്മ",
+ "ൂ": "ു",
+ "ൃ": "ു",
+ "ൈ": "െെ",
+ "෪": "ජ",
+ "෫": "ද",
+ "𑐓": "𑐴𑑂𑐒",
+ "𑐙": "𑐴𑑂𑐘",
+ "𑐤": "𑐴𑑂𑐣",
+ "𑐪": "𑐴𑑂𑐩",
+ "𑐭": "𑐴𑑂𑐬",
+ "𑐯": "𑐴𑑂𑐮",
+ "𑗘": "𑖂",
+ "𑗙": "𑖂",
+ "𑗚": "𑖃",
+ "𑗛": "𑖄",
+ "𑗜": "𑖲",
+ "𑗝": "𑖳",
+ "ฃ": "ข",
+ "ด": "ค",
+ "ต": "ค",
+ "ม": "ฆ",
+ "ຈ": "จ",
+ "ซ": "ช",
+ "ฏ": "ฎ",
+ "ท": "ฑ",
+ "ບ": "บ",
+ "ປ": "ป",
+ "ຝ": "ฝ",
+ "ພ": "พ",
+ "ຟ": "ฟ",
+ "ฦ": "ภ",
+ "ຍ": "ย",
+ "។": "ฯ",
+ "ๅ": "า",
+ "ำ": "̊า",
+ "ិ": "ิ",
+ "ី": "ี",
+ "ឹ": "ึ",
+ "ឺ": "ื",
+ "ຸ": "ุ",
+ "ູ": "ู",
+ "แ": "เเ",
+ "ໜ": "ຫນ",
+ "ໝ": "ຫມ",
+ "ຳ": "̊າ",
+ "༂": "འུྂཿ",
+ "༃": "འུྂ༔",
+ "ཪ": "ར",
+ "ༀ": "ཨོཾ",
+ "ཷ": "ྲཱྀ",
+ "ཹ": "ླཱྀ",
+ "𑲲": "𑲪",
+ "ႁ": "ဂှ",
+ "က": "ဂာ",
+ "ၰ": "ဃှ",
+ "ၦ": "ပှ",
+ "ဟ": "ပာ",
+ "ၯ": "ပာှ",
+ "ၾ": "ၽှ",
+ "ဩ": "သြ",
+ "ဪ": "သြော်",
+ "႞": "ႃ̊",
+ "ឣ": "អ",
+ "᧐": "ᦞ",
+ "᧑": "ᦱ",
+ "᪀": "ᩅ",
+ "᪐": "ᩅ",
+ "꩓": "ꨁ",
+ "꩖": "ꨣ",
+ "᭒": "ᬍ",
+ "᭓": "ᬑ",
+ "᭘": "ᬨ",
+ "ꦣ": "ꦝ",
+ "ᢖ": "ᡜ",
+ "ᡕ": "ᠵ",
+ "ῶ": "Ꮿ",
+ "ᐍ": "ᐁ·",
+ "ᐫ": "ᐁᐠ",
+ "ᐑ": "ᐄ·",
+ "ᐓ": "ᐅ·",
+ "ᐭ": "ᐅᐠ",
+ "ᐕ": "ᐆ·",
+ "ᐘ": "ᐊ·",
+ "ᐮ": "ᐊᐠ",
+ "ᐚ": "ᐋ·",
+ "ᣝ": "ᐞᣟ",
+ "ᓑ": "ᐡ",
+ "ᕀ": "ᐩ",
+ "ᐿ": "ᐲ·",
+ "ᑃ": "ᐴ·",
+ "⍩": "ᐵ",
+ "ᑇ": "ᐹ·",
+ "ᑜ": "ᑏ·",
+ "⸧": "ᑐ",
+ "⊃": "ᑐ",
+ "ᑞ": "ᑐ·",
+ "ᑩ": "ᑐ'",
+ "⟉": "ᑐ/",
+ "⫗": "ᑐᑕ",
+ "ᑠ": "ᑑ·",
+ "⸦": "ᑕ",
+ "⊂": "ᑕ",
+ "ᑢ": "ᑕ·",
+ "ᑪ": "ᑕ'",
+ "ᑤ": "ᑖ·",
+ "ᑵ": "ᑫ·",
+ "ᒅ": "ᑫ'",
+ "ᑹ": "ᑮ·",
+ "ᑽ": "ᑰ·",
+ "ᘃ": "ᒉ",
+ "ᒓ": "ᒉ·",
+ "ᒕ": "ᒋ·",
+ "ᒗ": "ᒌ·",
+ "ᒛ": "ᒎ·",
+ "ᘂ": "ᒐ",
+ "ᒝ": "ᒐ·",
+ "ᒟ": "ᒑ·",
+ "ᒭ": "ᒣ·",
+ "ᒱ": "ᒦ·",
+ "ᒳ": "ᒧ·",
+ "ᒵ": "ᒨ·",
+ "ᒹ": "ᒫ·",
+ "ᓊ": "ᓀ·",
+ "ᣇ": "ᓂ·",
+ "ᣉ": "ᓃ·",
+ "ᣋ": "ᓄ·",
+ "ᣍ": "ᓅ·",
+ "ᓌ": "ᓇ·",
+ "ᓎ": "ᓈ·",
+ "ᘄ": "ᓓ",
+ "ᓝ": "ᓓ·",
+ "ᓟ": "ᓕ·",
+ "ᓡ": "ᓖ·",
+ "ᓣ": "ᓗ·",
+ "ᓥ": "ᓘ·",
+ "ᘇ": "ᓚ",
+ "ᓧ": "ᓚ·",
+ "ᓩ": "ᓛ·",
+ "ᓷ": "ᓭ·",
+ "ᓹ": "ᓯ·",
+ "ᓻ": "ᓰ·",
+ "ᓽ": "ᓱ·",
+ "ᓿ": "ᓲ·",
+ "ᔁ": "ᓴ·",
+ "ᔃ": "ᓵ·",
+ "ᔌ": "ᔋ<",
+ "ᔎ": "ᔋb",
+ "ᔍ": "ᔋᑕ",
+ "ᔏ": "ᔋᒐ",
+ "ᔘ": "ᔐ·",
+ "ᔚ": "ᔑ·",
+ "ᔜ": "ᔒ·",
+ "ᔞ": "ᔓ·",
+ "ᔠ": "ᔔ·",
+ "ᔢ": "ᔕ·",
+ "ᔤ": "ᔖ·",
+ "ᔲ": "ᔨ·",
+ "ᔴ": "ᔩ·",
+ "ᔶ": "ᔪ·",
+ "ᔸ": "ᔫ·",
+ "ᔺ": "ᔭ·",
+ "ᔼ": "ᔮ·",
+ "ᘢ": "ᕃ",
+ "ᣠ": "ᕃ·",
+ "ᘣ": "ᕆ",
+ "ᘤ": "ᕊ",
+ "ᕏ": "ᕌ·",
+ "ᖃ": "ᕐb",
+ "ᖄ": "ᕐḃ",
+ "ᖁ": "ᕐd",
+ "ᕿ": "ᕐP",
+ "ᙯ": "ᕐᑫ",
+ "ᕾ": "ᕐᑬ",
+ "ᖀ": "ᕐᑮ",
+ "ᖂ": "ᕐᑰ",
+ "ᖅ": "ᕐᒃ",
+ "ᕜ": "ᕚ·",
+ "ᣣ": "ᕞ·",
+ "ᣤ": "ᕦ·",
+ "ᕩ": "ᕧ·",
+ "ᣥ": "ᕫ·",
+ "ᣨ": "ᖆ·",
+ "ᖑ": "ᖕJ",
+ "ᙰ": "ᖕᒉ",
+ "ᖎ": "ᖕᒊ",
+ "ᖏ": "ᖕᒋ",
+ "ᖐ": "ᖕᒌ",
+ "ᖒ": "ᖕᒎ",
+ "ᖓ": "ᖕᒐ",
+ "ᖔ": "ᖕᒑ",
+ "ᙳ": "ᖖJ",
+ "ᙱ": "ᖖᒋ",
+ "ᙲ": "ᖖᒌ",
+ "ᙴ": "ᖖᒎ",
+ "ᙵ": "ᖖᒐ",
+ "ᙶ": "ᖖᒑ",
+ "ᣪ": "ᖗ·",
+ "ᙷ": "ᖧ·",
+ "ᙸ": "ᖨ·",
+ "ᙹ": "ᖩ·",
+ "ᙺ": "ᖪ·",
+ "ᙻ": "ᖫ·",
+ "ᙼ": "ᖬ·",
+ "ᙽ": "ᖭ·",
+ "⪫": "ᗒ",
+ "⪪": "ᗕ",
+ "ꓷ": "ᗡ",
+ "ᣰ": "ᗴ·",
+ "ᣲ": "ᘛ·",
+ "ᶻ": "ᙆ",
+ "ꓭ": "ᙠ",
+ "ᶺ": "ᣔ",
+ "ᴾ": "ᣖ",
+ "ᣜ": "ᣟᐞ",
+ "ˡ": "ᣳ",
+ "ʳ": "ᣴ",
+ "ˢ": "ᣵ",
+ "ᣛ": "ᣵ",
+ "ꚰ": "ᚹ",
+ "ᛡ": "ᚼ",
+ "⍿": "ᚽ",
+ "ᛂ": "ᚽ",
+ "𝈿": "ᛋ",
+ "↑": "ᛏ",
+ "↿": "ᛐ",
+ "⥮": "ᛐ⇂",
+ "⥣": "ᛐᛚ",
+ "ⵣ": "ᛯ",
+ "↾": "ᛚ",
+ "⨡": "ᛚ",
+ "⋄": "ᛜ",
+ "◇": "ᛜ",
+ "◊": "ᛜ",
+ "♢": "ᛜ",
+ "🝔": "ᛜ",
+ "𑢷": "ᛜ",
+ "𐊔": "ᛜ",
+ "⍚": "ᛜ̲",
+ "⋈": "ᛞ",
+ "⨝": "ᛞ",
+ "𐓐": "ᛦ",
+ "↕": "ᛨ",
+ "𐳼": "𐲂",
+ "𐳺": "𐲥",
+ "ㄱ": "ᄀ",
+ "ᆨ": "ᄀ",
+ "ᄁ": "ᄀᄀ",
+ "ㄲ": "ᄀᄀ",
+ "ᆩ": "ᄀᄀ",
+ "ᇺ": "ᄀᄂ",
+ "ᅚ": "ᄀᄃ",
+ "ᇃ": "ᄀᄅ",
+ "ᇻ": "ᄀᄇ",
+ "ᆪ": "ᄀᄉ",
+ "ㄳ": "ᄀᄉ",
+ "ᇄ": "ᄀᄉᄀ",
+ "ᇼ": "ᄀᄎ",
+ "ᇽ": "ᄀᄏ",
+ "ᇾ": "ᄀᄒ",
+ "ㄴ": "ᄂ",
+ "ᆫ": "ᄂ",
+ "ᄓ": "ᄂᄀ",
+ "ᇅ": "ᄂᄀ",
+ "ᄔ": "ᄂᄂ",
+ "ㅥ": "ᄂᄂ",
+ "ᇿ": "ᄂᄂ",
+ "ᄕ": "ᄂᄃ",
+ "ㅦ": "ᄂᄃ",
+ "ᇆ": "ᄂᄃ",
+ "ퟋ": "ᄂᄅ",
+ "ᄖ": "ᄂᄇ",
+ "ᅛ": "ᄂᄉ",
+ "ᇇ": "ᄂᄉ",
+ "ㅧ": "ᄂᄉ",
+ "ᅜ": "ᄂᄌ",
+ "ᆬ": "ᄂᄌ",
+ "ㄵ": "ᄂᄌ",
+ "ퟌ": "ᄂᄎ",
+ "ᇉ": "ᄂᄐ",
+ "ᅝ": "ᄂᄒ",
+ "ᆭ": "ᄂᄒ",
+ "ㄶ": "ᄂᄒ",
+ "ᇈ": "ᄂᅀ",
+ "ㅨ": "ᄂᅀ",
+ "ㄷ": "ᄃ",
+ "ᆮ": "ᄃ",
+ "ᄗ": "ᄃᄀ",
+ "ᇊ": "ᄃᄀ",
+ "ᄄ": "ᄃᄃ",
+ "ㄸ": "ᄃᄃ",
+ "ퟍ": "ᄃᄃ",
+ "ퟎ": "ᄃᄃᄇ",
+ "ᅞ": "ᄃᄅ",
+ "ᇋ": "ᄃᄅ",
+ "ꥠ": "ᄃᄆ",
+ "ꥡ": "ᄃᄇ",
+ "ퟏ": "ᄃᄇ",
+ "ꥢ": "ᄃᄉ",
+ "ퟐ": "ᄃᄉ",
+ "ퟑ": "ᄃᄉᄀ",
+ "ꥣ": "ᄃᄌ",
+ "ퟒ": "ᄃᄌ",
+ "ퟓ": "ᄃᄎ",
+ "ퟔ": "ᄃᄐ",
+ "ㄹ": "ᄅ",
+ "ᆯ": "ᄅ",
+ "ꥤ": "ᄅᄀ",
+ "ᆰ": "ᄅᄀ",
+ "ㄺ": "ᄅᄀ",
+ "ꥥ": "ᄅᄀᄀ",
+ "ퟕ": "ᄅᄀᄀ",
+ "ᇌ": "ᄅᄀᄉ",
+ "ㅩ": "ᄅᄀᄉ",
+ "ퟖ": "ᄅᄀᄒ",
+ "ᄘ": "ᄅᄂ",
+ "ᇍ": "ᄅᄂ",
+ "ꥦ": "ᄅᄃ",
+ "ᇎ": "ᄅᄃ",
+ "ㅪ": "ᄅᄃ",
+ "ꥧ": "ᄅᄃᄃ",
+ "ᇏ": "ᄅᄃᄒ",
+ "ᄙ": "ᄅᄅ",
+ "ᇐ": "ᄅᄅ",
+ "ퟗ": "ᄅᄅᄏ",
+ "ꥨ": "ᄅᄆ",
+ "ᆱ": "ᄅᄆ",
+ "ㄻ": "ᄅᄆ",
+ "ᇑ": "ᄅᄆᄀ",
+ "ᇒ": "ᄅᄆᄉ",
+ "ퟘ": "ᄅᄆᄒ",
+ "ꥩ": "ᄅᄇ",
+ "ᆲ": "ᄅᄇ",
+ "ㄼ": "ᄅᄇ",
+ "ퟙ": "ᄅᄇᄃ",
+ "ꥪ": "ᄅᄇᄇ",
+ "ᇓ": "ᄅᄇᄉ",
+ "ㅫ": "ᄅᄇᄉ",
+ "ꥫ": "ᄅᄇᄋ",
+ "ᇕ": "ᄅᄇᄋ",
+ "ퟚ": "ᄅᄇᄑ",
+ "ᇔ": "ᄅᄇᄒ",
+ "ꥬ": "ᄅᄉ",
+ "ᆳ": "ᄅᄉ",
+ "ㄽ": "ᄅᄉ",
+ "ᇖ": "ᄅᄉᄉ",
+ "ᄛ": "ᄅᄋ",
+ "ퟝ": "ᄅᄋ",
+ "ꥭ": "ᄅᄌ",
+ "ꥮ": "ᄅᄏ",
+ "ᇘ": "ᄅᄏ",
+ "ᆴ": "ᄅᄐ",
+ "ㄾ": "ᄅᄐ",
+ "ᆵ": "ᄅᄑ",
+ "ㄿ": "ᄅᄑ",
+ "ᄚ": "ᄅᄒ",
+ "ㅀ": "ᄅᄒ",
+ "ᄻ": "ᄅᄒ",
+ "ᆶ": "ᄅᄒ",
+ "ퟲ": "ᄅᄒ",
+ "ᇗ": "ᄅᅀ",
+ "ㅬ": "ᄅᅀ",
+ "ퟛ": "ᄅᅌ",
+ "ᇙ": "ᄅᅙ",
+ "ㅭ": "ᄅᅙ",
+ "ퟜ": "ᄅᅙᄒ",
+ "ㅁ": "ᄆ",
+ "ᆷ": "ᄆ",
+ "ꥯ": "ᄆᄀ",
+ "ᇚ": "ᄆᄀ",
+ "ퟞ": "ᄆᄂ",
+ "ퟟ": "ᄆᄂᄂ",
+ "ꥰ": "ᄆᄃ",
+ "ᇛ": "ᄆᄅ",
+ "ퟠ": "ᄆᄆ",
+ "ᄜ": "ᄆᄇ",
+ "ㅮ": "ᄆᄇ",
+ "ᇜ": "ᄆᄇ",
+ "ퟡ": "ᄆᄇᄉ",
+ "ꥱ": "ᄆᄉ",
+ "ᇝ": "ᄆᄉ",
+ "ㅯ": "ᄆᄉ",
+ "ᇞ": "ᄆᄉᄉ",
+ "ᄝ": "ᄆᄋ",
+ "ㅱ": "ᄆᄋ",
+ "ᇢ": "ᄆᄋ",
+ "ퟢ": "ᄆᄌ",
+ "ᇠ": "ᄆᄎ",
+ "ᇡ": "ᄆᄒ",
+ "ᇟ": "ᄆᅀ",
+ "ㅰ": "ᄆᅀ",
+ "ㅂ": "ᄇ",
+ "ᆸ": "ᄇ",
+ "ᄞ": "ᄇᄀ",
+ "ㅲ": "ᄇᄀ",
+ "ᄟ": "ᄇᄂ",
+ "ᄠ": "ᄇᄃ",
+ "ㅳ": "ᄇᄃ",
+ "ퟣ": "ᄇᄃ",
+ "ᇣ": "ᄇᄅ",
+ "ퟤ": "ᄇᄅᄑ",
+ "ퟥ": "ᄇᄆ",
+ "ᄈ": "ᄇᄇ",
+ "ㅃ": "ᄇᄇ",
+ "ퟦ": "ᄇᄇ",
+ "ᄬ": "ᄇᄇᄋ",
+ "ㅹ": "ᄇᄇᄋ",
+ "ᄡ": "ᄇᄉ",
+ "ㅄ": "ᄇᄉ",
+ "ᆹ": "ᄇᄉ",
+ "ᄢ": "ᄇᄉᄀ",
+ "ㅴ": "ᄇᄉᄀ",
+ "ᄣ": "ᄇᄉᄃ",
+ "ㅵ": "ᄇᄉᄃ",
+ "ퟧ": "ᄇᄉᄃ",
+ "ᄤ": "ᄇᄉᄇ",
+ "ᄥ": "ᄇᄉᄉ",
+ "ᄦ": "ᄇᄉᄌ",
+ "ꥲ": "ᄇᄉᄐ",
+ "ᄫ": "ᄇᄋ",
+ "ㅸ": "ᄇᄋ",
+ "ᇦ": "ᄇᄋ",
+ "ᄧ": "ᄇᄌ",
+ "ㅶ": "ᄇᄌ",
+ "ퟨ": "ᄇᄌ",
+ "ᄨ": "ᄇᄎ",
+ "ퟩ": "ᄇᄎ",
+ "ꥳ": "ᄇᄏ",
+ "ᄩ": "ᄇᄐ",
+ "ㅷ": "ᄇᄐ",
+ "ᄪ": "ᄇᄑ",
+ "ᇤ": "ᄇᄑ",
+ "ꥴ": "ᄇᄒ",
+ "ᇥ": "ᄇᄒ",
+ "ㅅ": "ᄉ",
+ "ᆺ": "ᄉ",
+ "ᄭ": "ᄉᄀ",
+ "ㅺ": "ᄉᄀ",
+ "ᇧ": "ᄉᄀ",
+ "ᄮ": "ᄉᄂ",
+ "ㅻ": "ᄉᄂ",
+ "ᄯ": "ᄉᄃ",
+ "ㅼ": "ᄉᄃ",
+ "ᇨ": "ᄉᄃ",
+ "ᄰ": "ᄉᄅ",
+ "ᇩ": "ᄉᄅ",
+ "ᄱ": "ᄉᄆ",
+ "ퟪ": "ᄉᄆ",
+ "ᄲ": "ᄉᄇ",
+ "ㅽ": "ᄉᄇ",
+ "ᇪ": "ᄉᄇ",
+ "ᄳ": "ᄉᄇᄀ",
+ "ퟫ": "ᄉᄇᄋ",
+ "ᄊ": "ᄉᄉ",
+ "ㅆ": "ᄉᄉ",
+ "ᆻ": "ᄉᄉ",
+ "ퟬ": "ᄉᄉᄀ",
+ "ퟭ": "ᄉᄉᄃ",
+ "ꥵ": "ᄉᄉᄇ",
+ "ᄴ": "ᄉᄉᄉ",
+ "ᄵ": "ᄉᄋ",
+ "ᄶ": "ᄉᄌ",
+ "ㅾ": "ᄉᄌ",
+ "ퟯ": "ᄉᄌ",
+ "ᄷ": "ᄉᄎ",
+ "ퟰ": "ᄉᄎ",
+ "ᄸ": "ᄉᄏ",
+ "ᄹ": "ᄉᄐ",
+ "ퟱ": "ᄉᄐ",
+ "ᄺ": "ᄉᄑ",
+ "ퟮ": "ᄉᅀ",
+ "ㅇ": "ᄋ",
+ "ᆼ": "ᄋ",
+ "ᅁ": "ᄋᄀ",
+ "ᇬ": "ᄋᄀ",
+ "ᇭ": "ᄋᄀᄀ",
+ "ᅂ": "ᄋᄃ",
+ "ꥶ": "ᄋᄅ",
+ "ᅃ": "ᄋᄆ",
+ "ᅄ": "ᄋᄇ",
+ "ᅅ": "ᄋᄉ",
+ "ᇱ": "ᄋᄉ",
+ "ㆂ": "ᄋᄉ",
+ "ᅇ": "ᄋᄋ",
+ "ㆀ": "ᄋᄋ",
+ "ᇮ": "ᄋᄋ",
+ "ᅈ": "ᄋᄌ",
+ "ᅉ": "ᄋᄎ",
+ "ᇯ": "ᄋᄏ",
+ "ᅊ": "ᄋᄐ",
+ "ᅋ": "ᄋᄑ",
+ "ꥷ": "ᄋᄒ",
+ "ᅆ": "ᄋᅀ",
+ "ᇲ": "ᄋᅀ",
+ "ㆃ": "ᄋᅀ",
+ "ㅈ": "ᄌ",
+ "ᆽ": "ᄌ",
+ "ퟷ": "ᄌᄇ",
+ "ퟸ": "ᄌᄇᄇ",
+ "ᅍ": "ᄌᄋ",
+ "ᄍ": "ᄌᄌ",
+ "ㅉ": "ᄌᄌ",
+ "ퟹ": "ᄌᄌ",
+ "ꥸ": "ᄌᄌᄒ",
+ "ㅊ": "ᄎ",
+ "ᆾ": "ᄎ",
+ "ᅒ": "ᄎᄏ",
+ "ᅓ": "ᄎᄒ",
+ "ㅋ": "ᄏ",
+ "ᆿ": "ᄏ",
+ "ㅌ": "ᄐ",
+ "ᇀ": "ᄐ",
+ "ꥹ": "ᄐᄐ",
+ "ㅍ": "ᄑ",
+ "ᇁ": "ᄑ",
+ "ᅖ": "ᄑᄇ",
+ "ᇳ": "ᄑᄇ",
+ "ퟺ": "ᄑᄉ",
+ "ᅗ": "ᄑᄋ",
+ "ㆄ": "ᄑᄋ",
+ "ᇴ": "ᄑᄋ",
+ "ퟻ": "ᄑᄐ",
+ "ꥺ": "ᄑᄒ",
+ "ㅎ": "ᄒ",
+ "ᇂ": "ᄒ",
+ "ᇵ": "ᄒᄂ",
+ "ᇶ": "ᄒᄅ",
+ "ᇷ": "ᄒᄆ",
+ "ᇸ": "ᄒᄇ",
+ "ꥻ": "ᄒᄉ",
+ "ᅘ": "ᄒᄒ",
+ "ㆅ": "ᄒᄒ",
+ "ᄽ": "ᄼᄼ",
+ "ᄿ": "ᄾᄾ",
+ "ㅿ": "ᅀ",
+ "ᇫ": "ᅀ",
+ "ퟳ": "ᅀᄇ",
+ "ퟴ": "ᅀᄇᄋ",
+ "ㆁ": "ᅌ",
+ "ᇰ": "ᅌ",
+ "ퟵ": "ᅌᄆ",
+ "ퟶ": "ᅌᄒ",
+ "ᅏ": "ᅎᅎ",
+ "ᅑ": "ᅐᅐ",
+ "ㆆ": "ᅙ",
+ "ᇹ": "ᅙ",
+ "ꥼ": "ᅙᅙ",
+ "ㅤ": "ᅠ",
+ "ㅏ": "ᅡ",
+ "ᆣ": "ᅡー",
+ "ᅶ": "ᅡᅩ",
+ "ᅷ": "ᅡᅮ",
+ "ᅢ": "ᅡ丨",
+ "ㅐ": "ᅡ丨",
+ "ㅑ": "ᅣ",
+ "ᅸ": "ᅣᅩ",
+ "ᅹ": "ᅣᅭ",
+ "ᆤ": "ᅣᅮ",
+ "ᅤ": "ᅣ丨",
+ "ㅒ": "ᅣ丨",
+ "ㅓ": "ᅥ",
+ "ᅼ": "ᅥー",
+ "ᅺ": "ᅥᅩ",
+ "ᅻ": "ᅥᅮ",
+ "ᅦ": "ᅥ丨",
+ "ㅔ": "ᅥ丨",
+ "ㅕ": "ᅧ",
+ "ᆥ": "ᅧᅣ",
+ "ᅽ": "ᅧᅩ",
+ "ᅾ": "ᅧᅮ",
+ "ᅨ": "ᅧ丨",
+ "ㅖ": "ᅧ丨",
+ "ㅗ": "ᅩ",
+ "ᅪ": "ᅩᅡ",
+ "ㅘ": "ᅩᅡ",
+ "ᅫ": "ᅩᅡ丨",
+ "ㅙ": "ᅩᅡ丨",
+ "ᆦ": "ᅩᅣ",
+ "ᆧ": "ᅩᅣ丨",
+ "ᅿ": "ᅩᅥ",
+ "ᆀ": "ᅩᅥ丨",
+ "ힰ": "ᅩᅧ",
+ "ᆁ": "ᅩᅧ丨",
+ "ᆂ": "ᅩᅩ",
+ "ힱ": "ᅩᅩ丨",
+ "ᆃ": "ᅩᅮ",
+ "ᅬ": "ᅩ丨",
+ "ㅚ": "ᅩ丨",
+ "ㅛ": "ᅭ",
+ "ힲ": "ᅭᅡ",
+ "ힳ": "ᅭᅡ丨",
+ "ᆄ": "ᅭᅣ",
+ "ㆇ": "ᅭᅣ",
+ "ᆆ": "ᅭᅣ",
+ "ᆅ": "ᅭᅣ丨",
+ "ㆈ": "ᅭᅣ丨",
+ "ힴ": "ᅭᅥ",
+ "ᆇ": "ᅭᅩ",
+ "ᆈ": "ᅭ丨",
+ "ㆉ": "ᅭ丨",
+ "ㅜ": "ᅮ",
+ "ᆉ": "ᅮᅡ",
+ "ᆊ": "ᅮᅡ丨",
+ "ᅯ": "ᅮᅥ",
+ "ㅝ": "ᅮᅥ",
+ "ᆋ": "ᅮᅥー",
+ "ᅰ": "ᅮᅥ丨",
+ "ㅞ": "ᅮᅥ丨",
+ "ힵ": "ᅮᅧ",
+ "ᆌ": "ᅮᅧ丨",
+ "ᆍ": "ᅮᅮ",
+ "ᅱ": "ᅮ丨",
+ "ㅟ": "ᅮ丨",
+ "ힶ": "ᅮ丨丨",
+ "ㅠ": "ᅲ",
+ "ᆎ": "ᅲᅡ",
+ "ힷ": "ᅲᅡ丨",
+ "ᆏ": "ᅲᅥ",
+ "ᆐ": "ᅲᅥ丨",
+ "ᆑ": "ᅲᅧ",
+ "ㆊ": "ᅲᅧ",
+ "ᆒ": "ᅲᅧ丨",
+ "ㆋ": "ᅲᅧ丨",
+ "ힸ": "ᅲᅩ",
+ "ᆓ": "ᅲᅮ",
+ "ᆔ": "ᅲ丨",
+ "ㆌ": "ᅲ丨",
+ "ㆍ": "ᆞ",
+ "ퟅ": "ᆞᅡ",
+ "ᆟ": "ᆞᅥ",
+ "ퟆ": "ᆞᅥ丨",
+ "ᆠ": "ᆞᅮ",
+ "ᆢ": "ᆞᆞ",
+ "ᆡ": "ᆞ丨",
+ "ㆎ": "ᆞ丨",
+ "ヘ": "へ",
+ "⍁": "〼",
+ "⧄": "〼",
+ "꒞": "ꁊ",
+ "꒬": "ꁐ",
+ "꒜": "ꃀ",
+ "꒨": "ꄲ",
+ "꒿": "ꉙ",
+ "꒾": "ꊱ",
+ "꒔": "ꋍ",
+ "꓀": "ꎫ",
+ "꓂": "ꎵ",
+ "꒺": "ꎿ",
+ "꒰": "ꏂ",
+ "꒧": "ꑘ",
+ "⊥": "ꓕ",
+ "⟂": "ꓕ",
+ "𝈜": "ꓕ",
+ "Ʇ": "ꓕ",
+ "Ꞟ": "ꓤ",
+ "⅁": "ꓨ",
+ "⅂": "ꓶ",
+ "𝈕": "ꓶ",
+ "𝈫": "ꓶ",
+ "𖼦": "ꓶ",
+ "𐐑": "ꓶ",
+ "⅃": "𖼀",
+ "𑫦": "𑫥𑫯",
+ "𑫨": "𑫥𑫥",
+ "𑫩": "𑫥𑫥𑫯",
+ "𑫪": "𑫥𑫥𑫰",
+ "𑫧": "𑫥𑫰",
+ "𑫴": "𑫳𑫯",
+ "𑫶": "𑫳𑫳",
+ "𑫷": "𑫳𑫳𑫯",
+ "𑫸": "𑫳𑫳𑫰",
+ "𑫵": "𑫳𑫰",
+ "𑫬": "𑫫𑫯",
+ "𑫭": "𑫫𑫫",
+ "𑫮": "𑫫𑫫𑫯",
+ "⊕": "𐊨",
+ "⨁": "𐊨",
+ "🜨": "𐊨",
+ "Ꚛ": "𐊨",
+ "▽": "𐊼",
+ "𝈔": "𐊼",
+ "🜄": "𐊼",
+ "⧖": "𐋀",
+ "ꞛ": "𐐺",
+ "Ꞛ": "𐐒",
+ "𐒠": "𐒆",
+ "𐏑": "𐎂",
+ "𐏓": "𐎓",
+ "𒀸": "𐎚",
+ "☥": "𐦞",
+ "𓋹": "𐦞",
+ "〹": "卄",
+ "不": "不",
+ "丽": "丽",
+ "並": "並",
+ "⎜": "丨",
+ "⎟": "丨",
+ "⎢": "丨",
+ "⎥": "丨",
+ "⎪": "丨",
+ "⎮": "丨",
+ "㇑": "丨",
+ "ᅵ": "丨",
+ "ㅣ": "丨",
+ "⼁": "丨",
+ "ᆜ": "丨ー",
+ "ᆘ": "丨ᅡ",
+ "ᆙ": "丨ᅣ",
+ "ힽ": "丨ᅣᅩ",
+ "ힾ": "丨ᅣ丨",
+ "ힿ": "丨ᅧ",
+ "ퟀ": "丨ᅧ丨",
+ "ᆚ": "丨ᅩ",
+ "ퟁ": "丨ᅩ丨",
+ "ퟂ": "丨ᅭ",
+ "ᆛ": "丨ᅮ",
+ "ퟃ": "丨ᅲ",
+ "ᆝ": "丨ᆞ",
+ "ퟄ": "丨丨",
+ "串": "串",
+ "丸": "丸",
+ "丹": "丹",
+ "乁": "乁",
+ "㇠": "乙",
+ "⼄": "乙",
+ "㇟": "乚",
+ "⺃": "乚",
+ "㇖": "乛",
+ "⺂": "乛",
+ "⻲": "亀",
+ "亂": "亂",
+ "㇚": "亅",
+ "⼅": "亅",
+ "了": "了",
+ "ニ": "二",
+ "⼆": "二",
+ "𠄢": "𠄢",
+ "⼇": "亠",
+ "亮": "亮",
+ "⼈": "人",
+ "イ": "亻",
+ "⺅": "亻",
+ "什": "什",
+ "仌": "仌",
+ "令": "令",
+ "你": "你",
+ "倂": "併",
+ "倂": "併",
+ "侀": "侀",
+ "來": "來",
+ "例": "例",
+ "侮": "侮",
+ "侮": "侮",
+ "侻": "侻",
+ "便": "便",
+ "值": "値",
+ "倫": "倫",
+ "偺": "偺",
+ "備": "備",
+ "像": "像",
+ "僚": "僚",
+ "僧": "僧",
+ "僧": "僧",
+ "㒞": "㒞",
+ "⼉": "儿",
+ "兀": "兀",
+ "⺎": "兀",
+ "充": "充",
+ "免": "免",
+ "免": "免",
+ "兔": "兔",
+ "兤": "兤",
+ "⼊": "入",
+ "內": "內",
+ "全": "全",
+ "兩": "兩",
+ "ハ": "八",
+ "⼋": "八",
+ "六": "六",
+ "具": "具",
+ "𠔜": "𠔜",
+ "𠔥": "𠔥",
+ "冀": "冀",
+ "㒹": "㒹",
+ "⼌": "冂",
+ "再": "再",
+ "𠕋": "𠕋",
+ "冒": "冒",
+ "冕": "冕",
+ "㒻": "㒻",
+ "最": "最",
+ "⼍": "冖",
+ "冗": "冗",
+ "冤": "冤",
+ "⼎": "冫",
+ "冬": "冬",
+ "况": "况",
+ "况": "况",
+ "冷": "冷",
+ "凉": "凉",
+ "凌": "凌",
+ "凜": "凜",
+ "凞": "凞",
+ "⼏": "几",
+ "𠘺": "𠘺",
+ "凵": "凵",
+ "⼐": "凵",
+ "⼑": "刀",
+ "⺉": "刂",
+ "刃": "刃",
+ "切": "切",
+ "切": "切",
+ "列": "列",
+ "利": "利",
+ "㓟": "㓟",
+ "刺": "刺",
+ "刻": "刻",
+ "剆": "剆",
+ "割": "割",
+ "剷": "剷",
+ "劉": "劉",
+ "𠠄": "𠠄",
+ "カ": "力",
+ "力": "力",
+ "⼒": "力",
+ "劣": "劣",
+ "㔕": "㔕",
+ "劳": "劳",
+ "勇": "勇",
+ "勇": "勇",
+ "勉": "勉",
+ "勉": "勉",
+ "勒": "勒",
+ "勞": "勞",
+ "勤": "勤",
+ "勤": "勤",
+ "勵": "勵",
+ "⼓": "勹",
+ "勺": "勺",
+ "勺": "勺",
+ "包": "包",
+ "匆": "匆",
+ "𠣞": "𠣞",
+ "⼔": "匕",
+ "北": "北",
+ "北": "北",
+ "⼕": "匚",
+ "⼖": "匸",
+ "匿": "匿",
+ "⼗": "十",
+ "〸": "十",
+ "〺": "卅",
+ "卉": "卉",
+ "࿖": "卍",
+ "࿕": "卐",
+ "卑": "卑",
+ "卑": "卑",
+ "博": "博",
+ "ト": "卜",
+ "⼘": "卜",
+ "⼙": "卩",
+ "⺋": "㔾",
+ "即": "即",
+ "卵": "卵",
+ "卽": "卽",
+ "卿": "卿",
+ "卿": "卿",
+ "卿": "卿",
+ "⼚": "厂",
+ "𠨬": "𠨬",
+ "⼛": "厶",
+ "參": "參",
+ "⼜": "又",
+ "及": "及",
+ "叟": "叟",
+ "𠭣": "𠭣",
+ "ロ": "口",
+ "⼝": "口",
+ "囗": "口",
+ "⼞": "口",
+ "句": "句",
+ "叫": "叫",
+ "叱": "叱",
+ "吆": "吆",
+ "吏": "吏",
+ "吝": "吝",
+ "吸": "吸",
+ "呂": "呂",
+ "呈": "呈",
+ "周": "周",
+ "咞": "咞",
+ "咢": "咢",
+ "咽": "咽",
+ "䎛": "㖈",
+ "哶": "哶",
+ "唐": "唐",
+ "啓": "啓",
+ "啟": "啓",
+ "啕": "啕",
+ "啣": "啣",
+ "善": "善",
+ "善": "善",
+ "喇": "喇",
+ "喙": "喙",
+ "喙": "喙",
+ "喝": "喝",
+ "喝": "喝",
+ "喫": "喫",
+ "喳": "喳",
+ "嗀": "嗀",
+ "嗂": "嗂",
+ "嗢": "嗢",
+ "嘆": "嘆",
+ "嘆": "嘆",
+ "噑": "噑",
+ "噴": "噴",
+ "器": "器",
+ "囹": "囹",
+ "圖": "圖",
+ "圗": "圗",
+ "⼟": "土",
+ "士": "土",
+ "⼠": "土",
+ "型": "型",
+ "城": "城",
+ "㦳": "㘽",
+ "埴": "埴",
+ "堍": "堍",
+ "報": "報",
+ "堲": "堲",
+ "塀": "塀",
+ "塚": "塚",
+ "塚": "塚",
+ "塞": "塞",
+ "填": "塡",
+ "壿": "墫",
+ "墬": "墬",
+ "墳": "墳",
+ "壘": "壘",
+ "壟": "壟",
+ "𡓤": "𡓤",
+ "壮": "壮",
+ "売": "売",
+ "壷": "壷",
+ "⼡": "夂",
+ "夆": "夆",
+ "⼢": "夊",
+ "タ": "夕",
+ "⼣": "夕",
+ "多": "多",
+ "夢": "夢",
+ "⼤": "大",
+ "奄": "奄",
+ "奈": "奈",
+ "契": "契",
+ "奔": "奔",
+ "奢": "奢",
+ "女": "女",
+ "⼥": "女",
+ "𡚨": "𡚨",
+ "𡛪": "𡛪",
+ "姘": "姘",
+ "姬": "姬",
+ "娛": "娛",
+ "娧": "娧",
+ "婢": "婢",
+ "婦": "婦",
+ "嬀": "媯",
+ "㛮": "㛮",
+ "㛼": "㛼",
+ "媵": "媵",
+ "嬈": "嬈",
+ "嬨": "嬨",
+ "嬾": "嬾",
+ "嬾": "嬾",
+ "⼦": "子",
+ "⼧": "宀",
+ "宅": "宅",
+ "𡧈": "𡧈",
+ "寃": "寃",
+ "寘": "寘",
+ "寧": "寧",
+ "寧": "寧",
+ "寧": "寧",
+ "寮": "寮",
+ "寳": "寳",
+ "𡬘": "𡬘",
+ "⼨": "寸",
+ "寿": "寿",
+ "将": "将",
+ "⼩": "小",
+ "尢": "尢",
+ "⺐": "尢",
+ "⼪": "尢",
+ "⺏": "尣",
+ "㞁": "㞁",
+ "⼫": "尸",
+ "尿": "尿",
+ "屠": "屠",
+ "屢": "屢",
+ "層": "層",
+ "履": "履",
+ "屮": "屮",
+ "屮": "屮",
+ "⼬": "屮",
+ "𡴋": "𡴋",
+ "⼭": "山",
+ "峀": "峀",
+ "岍": "岍",
+ "𡷤": "𡷤",
+ "𡷦": "𡷦",
+ "崙": "崙",
+ "嵃": "嵃",
+ "嵐": "嵐",
+ "嵫": "嵫",
+ "嵮": "嵮",
+ "嵼": "嵼",
+ "嶲": "嶲",
+ "嶺": "嶺",
+ "⼮": "巛",
+ "巢": "巢",
+ "エ": "工",
+ "⼯": "工",
+ "⼰": "己",
+ "⺒": "巳",
+ "㠯": "㠯",
+ "巽": "巽",
+ "⼱": "巾",
+ "帲": "帡",
+ "帨": "帨",
+ "帽": "帽",
+ "幩": "幩",
+ "㡢": "㡢",
+ "𢆃": "𢆃",
+ "⼲": "干",
+ "年": "年",
+ "𢆟": "𢆟",
+ "⺓": "幺",
+ "⼳": "幺",
+ "⼴": "广",
+ "度": "度",
+ "㡼": "㡼",
+ "庰": "庰",
+ "庳": "庳",
+ "庶": "庶",
+ "廊": "廊",
+ "廊": "廊",
+ "廉": "廉",
+ "廒": "廒",
+ "廓": "廓",
+ "廙": "廙",
+ "廬": "廬",
+ "⼵": "廴",
+ "廾": "廾",
+ "⼶": "廾",
+ "𢌱": "𢌱",
+ "𢌱": "𢌱",
+ "弄": "弄",
+ "⼷": "弋",
+ "⼸": "弓",
+ "弢": "弢",
+ "弢": "弢",
+ "⼹": "彐",
+ "⺔": "彑",
+ "当": "当",
+ "㣇": "㣇",
+ "⼺": "彡",
+ "形": "形",
+ "彩": "彩",
+ "彫": "彫",
+ "⼻": "彳",
+ "律": "律",
+ "㣣": "㣣",
+ "徚": "徚",
+ "復": "復",
+ "徭": "徭",
+ "⼼": "心",
+ "⺖": "忄",
+ "⺗": "㣺",
+ "忍": "忍",
+ "志": "志",
+ "念": "念",
+ "忹": "忹",
+ "怒": "怒",
+ "怜": "怜",
+ "恵": "恵",
+ "㤜": "㤜",
+ "㤺": "㤺",
+ "悁": "悁",
+ "悔": "悔",
+ "悔": "悔",
+ "惇": "惇",
+ "惘": "惘",
+ "惡": "惡",
+ "𢛔": "𢛔",
+ "愈": "愈",
+ "慨": "慨",
+ "慄": "慄",
+ "慈": "慈",
+ "慌": "慌",
+ "慌": "慌",
+ "慎": "慎",
+ "慎": "慎",
+ "慠": "慠",
+ "慺": "慺",
+ "憎": "憎",
+ "憎": "憎",
+ "憎": "憎",
+ "憐": "憐",
+ "憤": "憤",
+ "憯": "憯",
+ "憲": "憲",
+ "𢡄": "𢡄",
+ "𢡊": "𢡊",
+ "懞": "懞",
+ "懲": "懲",
+ "懲": "懲",
+ "懲": "懲",
+ "懶": "懶",
+ "懶": "懶",
+ "戀": "戀",
+ "⼽": "戈",
+ "成": "成",
+ "戛": "戛",
+ "戮": "戮",
+ "戴": "戴",
+ "⼾": "戶",
+ "戸": "戶",
+ "⼿": "手",
+ "⺘": "扌",
+ "扝": "扝",
+ "抱": "抱",
+ "拉": "拉",
+ "拏": "拏",
+ "拓": "拓",
+ "拔": "拔",
+ "拼": "拼",
+ "拾": "拾",
+ "𢬌": "𢬌",
+ "挽": "挽",
+ "捐": "捐",
+ "捨": "捨",
+ "捻": "捻",
+ "掃": "掃",
+ "掠": "掠",
+ "掩": "掩",
+ "揄": "揄",
+ "揤": "揤",
+ "摒": "摒",
+ "𢯱": "𢯱",
+ "搜": "搜",
+ "搢": "搢",
+ "揅": "揅",
+ "摩": "摩",
+ "摷": "摷",
+ "摾": "摾",
+ "㨮": "㨮",
+ "搉": "㩁",
+ "撚": "撚",
+ "撝": "撝",
+ "擄": "擄",
+ "㩬": "㩬",
+ "⽀": "支",
+ "⽁": "攴",
+ "⺙": "攵",
+ "敏": "敏",
+ "敏": "敏",
+ "敖": "敖",
+ "敬": "敬",
+ "數": "數",
+ "𣀊": "𣀊",
+ "⽂": "文",
+ "⻫": "斉",
+ "⽃": "斗",
+ "料": "料",
+ "⽄": "斤",
+ "⽅": "方",
+ "旅": "旅",
+ "⽆": "无",
+ "⺛": "旡",
+ "既": "既",
+ "旣": "旣",
+ "⽇": "日",
+ "易": "易",
+ "曶": "㫚",
+ "㫤": "㫤",
+ "晉": "晉",
+ "晩": "晚",
+ "晴": "晴",
+ "晴": "晴",
+ "暑": "暑",
+ "暑": "暑",
+ "暈": "暈",
+ "㬈": "㬈",
+ "暜": "暜",
+ "暴": "暴",
+ "曆": "曆",
+ "㬙": "㬙",
+ "𣊸": "𣊸",
+ "⽈": "曰",
+ "更": "更",
+ "書": "書",
+ "⽉": "月",
+ "𣍟": "𣍟",
+ "肦": "朌",
+ "胐": "朏",
+ "胊": "朐",
+ "脁": "朓",
+ "胶": "㬵",
+ "朗": "朗",
+ "朗": "朗",
+ "朗": "朗",
+ "脧": "朘",
+ "望": "望",
+ "望": "望",
+ "幐": "㬺",
+ "䐠": "㬻",
+ "𣎓": "𣎓",
+ "膧": "朣",
+ "𣎜": "𣎜",
+ "⽊": "木",
+ "李": "李",
+ "杓": "杓",
+ "杖": "杖",
+ "杞": "杞",
+ "𣏃": "𣏃",
+ "柿": "杮",
+ "杻": "杻",
+ "枅": "枅",
+ "林": "林",
+ "㭉": "㭉",
+ "𣏕": "𣏕",
+ "柳": "柳",
+ "柺": "柺",
+ "栗": "栗",
+ "栟": "栟",
+ "桒": "桒",
+ "𣑭": "𣑭",
+ "梁": "梁",
+ "梅": "梅",
+ "梅": "梅",
+ "梎": "梎",
+ "梨": "梨",
+ "椔": "椔",
+ "楂": "楂",
+ "㮝": "㮝",
+ "㮝": "㮝",
+ "槩": "㮣",
+ "樧": "榝",
+ "榣": "榣",
+ "槪": "槪",
+ "樂": "樂",
+ "樂": "樂",
+ "樂": "樂",
+ "樓": "樓",
+ "𣚣": "𣚣",
+ "檨": "檨",
+ "櫓": "櫓",
+ "櫛": "櫛",
+ "欄": "欄",
+ "㰘": "㰘",
+ "⽋": "欠",
+ "次": "次",
+ "𣢧": "𣢧",
+ "歔": "歔",
+ "㱎": "㱎",
+ "⽌": "止",
+ "⻭": "歯",
+ "歲": "歲",
+ "歷": "歷",
+ "歹": "歹",
+ "⽍": "歹",
+ "⺞": "歺",
+ "殟": "殟",
+ "殮": "殮",
+ "⽎": "殳",
+ "殺": "殺",
+ "殺": "殺",
+ "殺": "殺",
+ "殻": "殻",
+ "𣪍": "𣪍",
+ "⽏": "毋",
+ "⺟": "母",
+ "𣫺": "𣫺",
+ "⽐": "比",
+ "⽑": "毛",
+ "⽒": "氏",
+ "⺠": "民",
+ "⽓": "气",
+ "⽔": "水",
+ "⺡": "氵",
+ "⺢": "氺",
+ "汎": "汎",
+ "汧": "汧",
+ "沈": "沈",
+ "沿": "沿",
+ "泌": "泌",
+ "泍": "泍",
+ "泥": "泥",
+ "𣲼": "𣲼",
+ "洛": "洛",
+ "洞": "洞",
+ "洴": "洴",
+ "派": "派",
+ "流": "流",
+ "流": "流",
+ "流": "流",
+ "洖": "洖",
+ "浩": "浩",
+ "浪": "浪",
+ "海": "海",
+ "海": "海",
+ "浸": "浸",
+ "涅": "涅",
+ "𣴞": "𣴞",
+ "淋": "淋",
+ "淚": "淚",
+ "淪": "淪",
+ "淹": "淹",
+ "渚": "渚",
+ "港": "港",
+ "湮": "湮",
+ "潙": "溈",
+ "滋": "滋",
+ "滋": "滋",
+ "溜": "溜",
+ "溺": "溺",
+ "滇": "滇",
+ "滑": "滑",
+ "滛": "滛",
+ "㴳": "㴳",
+ "漏": "漏",
+ "漢": "漢",
+ "漢": "漢",
+ "漣": "漣",
+ "𣻑": "𣻑",
+ "潮": "潮",
+ "𣽞": "𣽞",
+ "𣾎": "𣾎",
+ "濆": "濆",
+ "濫": "濫",
+ "濾": "濾",
+ "瀛": "瀛",
+ "瀞": "瀞",
+ "瀞": "瀞",
+ "瀹": "瀹",
+ "灊": "灊",
+ "㶖": "㶖",
+ "⽕": "火",
+ "⺣": "灬",
+ "灰": "灰",
+ "灷": "灷",
+ "災": "災",
+ "炙": "炙",
+ "炭": "炭",
+ "烈": "烈",
+ "烙": "烙",
+ "煮": "煮",
+ "煮": "煮",
+ "𤉣": "𤉣",
+ "煅": "煅",
+ "煉": "煉",
+ "𤋮": "𤋮",
+ "熜": "熜",
+ "燎": "燎",
+ "燐": "燐",
+ "𤎫": "𤎫",
+ "爐": "爐",
+ "爛": "爛",
+ "爨": "爨",
+ "⽖": "爪",
+ "爫": "爫",
+ "⺤": "爫",
+ "爵": "爵",
+ "爵": "爵",
+ "⽗": "父",
+ "⽘": "爻",
+ "⺦": "丬",
+ "⽙": "爿",
+ "⽚": "片",
+ "牐": "牐",
+ "⽛": "牙",
+ "𤘈": "𤘈",
+ "⽜": "牛",
+ "牢": "牢",
+ "犀": "犀",
+ "犕": "犕",
+ "⽝": "犬",
+ "⺨": "犭",
+ "犯": "犯",
+ "狀": "狀",
+ "𤜵": "𤜵",
+ "狼": "狼",
+ "猪": "猪",
+ "猪": "猪",
+ "𤠔": "𤠔",
+ "獵": "獵",
+ "獺": "獺",
+ "⽞": "玄",
+ "率": "率",
+ "率": "率",
+ "⽟": "玉",
+ "王": "王",
+ "㺬": "㺬",
+ "玥": "玥",
+ "玲": "玲",
+ "㺸": "㺸",
+ "㺸": "㺸",
+ "珞": "珞",
+ "琉": "琉",
+ "理": "理",
+ "琢": "琢",
+ "瑇": "瑇",
+ "瑜": "瑜",
+ "瑩": "瑩",
+ "瑱": "瑱",
+ "瑱": "瑱",
+ "璅": "璅",
+ "璉": "璉",
+ "璘": "璘",
+ "瓊": "瓊",
+ "⽠": "瓜",
+ "⽡": "瓦",
+ "㼛": "㼛",
+ "甆": "甆",
+ "⽢": "甘",
+ "⽣": "生",
+ "甤": "甤",
+ "⽤": "用",
+ "⽥": "田",
+ "画": "画",
+ "甾": "甾",
+ "𤰶": "𤰶",
+ "留": "留",
+ "略": "略",
+ "異": "異",
+ "異": "異",
+ "𤲒": "𤲒",
+ "⽦": "疋",
+ "⽧": "疒",
+ "痢": "痢",
+ "瘐": "瘐",
+ "瘟": "瘟",
+ "瘝": "瘝",
+ "療": "療",
+ "癩": "癩",
+ "⽨": "癶",
+ "⽩": "白",
+ "𤾡": "𤾡",
+ "𤾸": "𤾸",
+ "⽪": "皮",
+ "⽫": "皿",
+ "𥁄": "𥁄",
+ "㿼": "㿼",
+ "益": "益",
+ "益": "益",
+ "盛": "盛",
+ "盧": "盧",
+ "䀈": "䀈",
+ "⽬": "目",
+ "直": "直",
+ "直": "直",
+ "𥃲": "𥃲",
+ "𥃳": "𥃳",
+ "省": "省",
+ "䀘": "䀘",
+ "𥄙": "𥄙",
+ "眞": "眞",
+ "真": "真",
+ "真": "真",
+ "𥄳": "𥄳",
+ "着": "着",
+ "睊": "睊",
+ "睊": "睊",
+ "鿃": "䀹",
+ "䀹": "䀹",
+ "䀹": "䀹",
+ "晣": "䀿",
+ "䁆": "䁆",
+ "瞋": "瞋",
+ "𥉉": "𥉉",
+ "瞧": "瞧",
+ "⽭": "矛",
+ "⽮": "矢",
+ "⽯": "石",
+ "䂖": "䂖",
+ "𥐝": "𥐝",
+ "硏": "研",
+ "硎": "硎",
+ "硫": "硫",
+ "碌": "碌",
+ "碌": "碌",
+ "碑": "碑",
+ "磊": "磊",
+ "磌": "磌",
+ "磌": "磌",
+ "磻": "磻",
+ "䃣": "䃣",
+ "礪": "礪",
+ "⽰": "示",
+ "⺭": "礻",
+ "礼": "礼",
+ "社": "社",
+ "祈": "祈",
+ "祉": "祉",
+ "𥘦": "𥘦",
+ "祐": "祐",
+ "祖": "祖",
+ "祖": "祖",
+ "祝": "祝",
+ "神": "神",
+ "祥": "祥",
+ "視": "視",
+ "視": "視",
+ "祿": "祿",
+ "𥚚": "𥚚",
+ "禍": "禍",
+ "禎": "禎",
+ "福": "福",
+ "福": "福",
+ "𥛅": "𥛅",
+ "禮": "禮",
+ "⽱": "禸",
+ "⽲": "禾",
+ "秊": "秊",
+ "䄯": "䄯",
+ "秫": "秫",
+ "稜": "稜",
+ "穊": "穊",
+ "穀": "穀",
+ "穀": "穀",
+ "穏": "穏",
+ "⽳": "穴",
+ "突": "突",
+ "𥥼": "𥥼",
+ "窱": "窱",
+ "立": "立",
+ "⽴": "立",
+ "⻯": "竜",
+ "𥪧": "𥪧",
+ "𥪧": "𥪧",
+ "竮": "竮",
+ "⽵": "竹",
+ "笠": "笠",
+ "節": "節",
+ "節": "節",
+ "䈂": "䈂",
+ "𥮫": "𥮫",
+ "篆": "篆",
+ "䈧": "䈧",
+ "築": "築",
+ "𥲀": "𥲀",
+ "𥳐": "𥳐",
+ "簾": "簾",
+ "籠": "籠",
+ "⽶": "米",
+ "类": "类",
+ "粒": "粒",
+ "精": "精",
+ "糒": "糒",
+ "糖": "糖",
+ "糨": "糨",
+ "䊠": "䊠",
+ "糣": "糣",
+ "糧": "糧",
+ "⽷": "糸",
+ "⺯": "糹",
+ "𥾆": "𥾆",
+ "紀": "紀",
+ "紐": "紐",
+ "索": "索",
+ "累": "累",
+ "絶": "絕",
+ "絣": "絣",
+ "絛": "絛",
+ "綠": "綠",
+ "綾": "綾",
+ "緇": "緇",
+ "練": "練",
+ "練": "練",
+ "練": "練",
+ "縂": "縂",
+ "䌁": "䌁",
+ "縉": "縉",
+ "縷": "縷",
+ "繁": "繁",
+ "繅": "繅",
+ "𦇚": "𦇚",
+ "䌴": "䌴",
+ "⽸": "缶",
+ "𦈨": "𦈨",
+ "缾": "缾",
+ "𦉇": "𦉇",
+ "⽹": "网",
+ "⺫": "罒",
+ "⺲": "罒",
+ "⺱": "罓",
+ "䍙": "䍙",
+ "署": "署",
+ "𦋙": "𦋙",
+ "罹": "罹",
+ "罺": "罺",
+ "羅": "羅",
+ "𦌾": "𦌾",
+ "⽺": "羊",
+ "羕": "羕",
+ "羚": "羚",
+ "羽": "羽",
+ "⽻": "羽",
+ "翺": "翺",
+ "老": "老",
+ "⽼": "老",
+ "⺹": "耂",
+ "者": "者",
+ "者": "者",
+ "者": "者",
+ "⽽": "而",
+ "𦓚": "𦓚",
+ "⽾": "耒",
+ "𦔣": "𦔣",
+ "⽿": "耳",
+ "聆": "聆",
+ "聠": "聠",
+ "𦖨": "𦖨",
+ "聯": "聯",
+ "聰": "聰",
+ "聾": "聾",
+ "⾀": "聿",
+ "⺺": "肀",
+ "⾁": "肉",
+ "肋": "肋",
+ "肭": "肭",
+ "育": "育",
+ "䏕": "䏕",
+ "䏙": "䏙",
+ "腁": "胼",
+ "脃": "脃",
+ "脾": "脾",
+ "䐋": "䐋",
+ "朡": "朡",
+ "𦞧": "𦞧",
+ "𦞵": "𦞵",
+ "朦": "䑃",
+ "臘": "臘",
+ "⾂": "臣",
+ "臨": "臨",
+ "⾃": "自",
+ "臭": "臭",
+ "⾄": "至",
+ "⾅": "臼",
+ "舁": "舁",
+ "舁": "舁",
+ "舄": "舄",
+ "⾆": "舌",
+ "舘": "舘",
+ "⾇": "舛",
+ "⾈": "舟",
+ "䑫": "䑫",
+ "⾉": "艮",
+ "良": "良",
+ "⾊": "色",
+ "⾋": "艸",
+ "艹": "艹",
+ "艹": "艹",
+ "⺾": "艹",
+ "⺿": "艹",
+ "⻀": "艹",
+ "芋": "芋",
+ "芑": "芑",
+ "芝": "芝",
+ "花": "花",
+ "芳": "芳",
+ "芽": "芽",
+ "若": "若",
+ "若": "若",
+ "苦": "苦",
+ "𦬼": "𦬼",
+ "茶": "茶",
+ "荒": "荒",
+ "荣": "荣",
+ "茝": "茝",
+ "茣": "茣",
+ "莽": "莽",
+ "荓": "荓",
+ "菉": "菉",
+ "菊": "菊",
+ "菌": "菌",
+ "菜": "菜",
+ "菧": "菧",
+ "華": "華",
+ "菱": "菱",
+ "著": "著",
+ "著": "著",
+ "𦰶": "𦰶",
+ "莭": "莭",
+ "落": "落",
+ "葉": "葉",
+ "蔿": "蒍",
+ "𦳕": "𦳕",
+ "𦵫": "𦵫",
+ "蓮": "蓮",
+ "蓱": "蓱",
+ "蓳": "蓳",
+ "蓼": "蓼",
+ "蔖": "蔖",
+ "䔫": "䔫",
+ "蕤": "蕤",
+ "𦼬": "𦼬",
+ "藍": "藍",
+ "䕝": "䕝",
+ "𦾱": "𦾱",
+ "䕡": "䕡",
+ "藺": "藺",
+ "蘆": "蘆",
+ "䕫": "䕫",
+ "蘒": "蘒",
+ "蘭": "蘭",
+ "𧃒": "𧃒",
+ "虁": "蘷",
+ "蘿": "蘿",
+ "⾌": "虍",
+ "⻁": "虎",
+ "虐": "虐",
+ "虜": "虜",
+ "虜": "虜",
+ "虧": "虧",
+ "虩": "虩",
+ "⾍": "虫",
+ "蚩": "蚩",
+ "蚈": "蚈",
+ "蛢": "蛢",
+ "蜎": "蜎",
+ "蜨": "蜨",
+ "蝫": "蝫",
+ "蟡": "蟡",
+ "蝹": "蝹",
+ "蝹": "蝹",
+ "螆": "螆",
+ "䗗": "䗗",
+ "𧏊": "𧏊",
+ "螺": "螺",
+ "蠁": "蠁",
+ "䗹": "䗹",
+ "蠟": "蠟",
+ "⾎": "血",
+ "行": "行",
+ "⾏": "行",
+ "衠": "衠",
+ "衣": "衣",
+ "⾐": "衣",
+ "⻂": "衤",
+ "裂": "裂",
+ "𧙧": "𧙧",
+ "裏": "裏",
+ "裗": "裗",
+ "裞": "裞",
+ "裡": "裡",
+ "裸": "裸",
+ "裺": "裺",
+ "䘵": "䘵",
+ "褐": "褐",
+ "襁": "襁",
+ "襤": "襤",
+ "⾑": "襾",
+ "⻄": "西",
+ "⻃": "覀",
+ "覆": "覆",
+ "見": "見",
+ "⾒": "見",
+ "𧢮": "𧢮",
+ "⻅": "见",
+ "⾓": "角",
+ "⾔": "言",
+ "𧥦": "𧥦",
+ "詽": "訮",
+ "訞": "䚶",
+ "䚾": "䚾",
+ "䛇": "䛇",
+ "誠": "誠",
+ "說": "說",
+ "說": "說",
+ "調": "調",
+ "請": "請",
+ "諒": "諒",
+ "論": "論",
+ "諭": "諭",
+ "諭": "諭",
+ "諸": "諸",
+ "諸": "諸",
+ "諾": "諾",
+ "諾": "諾",
+ "謁": "謁",
+ "謁": "謁",
+ "謹": "謹",
+ "謹": "謹",
+ "識": "識",
+ "讀": "讀",
+ "讏": "讆",
+ "變": "變",
+ "變": "變",
+ "⻈": "讠",
+ "⾕": "谷",
+ "⾖": "豆",
+ "豈": "豈",
+ "豕": "豕",
+ "⾗": "豕",
+ "豣": "豜",
+ "⾘": "豸",
+ "𧲨": "𧲨",
+ "⾙": "貝",
+ "貫": "貫",
+ "賁": "賁",
+ "賂": "賂",
+ "賈": "賈",
+ "賓": "賓",
+ "贈": "贈",
+ "贈": "贈",
+ "贛": "贛",
+ "⻉": "贝",
+ "⾚": "赤",
+ "⾛": "走",
+ "起": "起",
+ "趆": "赿",
+ "𧻓": "𧻓",
+ "𧼯": "𧼯",
+ "⾜": "足",
+ "跋": "跋",
+ "趼": "趼",
+ "跺": "跥",
+ "路": "路",
+ "跰": "跰",
+ "躛": "躗",
+ "⾝": "身",
+ "車": "車",
+ "⾞": "車",
+ "軔": "軔",
+ "輧": "軿",
+ "輦": "輦",
+ "輪": "輪",
+ "輸": "輸",
+ "輸": "輸",
+ "輻": "輻",
+ "轢": "轢",
+ "⻋": "车",
+ "⾟": "辛",
+ "辞": "辞",
+ "辰": "辰",
+ "⾠": "辰",
+ "⾡": "辵",
+ "辶": "辶",
+ "⻌": "辶",
+ "⻍": "辶",
+ "巡": "巡",
+ "連": "連",
+ "逸": "逸",
+ "逸": "逸",
+ "遲": "遲",
+ "遼": "遼",
+ "𨗒": "𨗒",
+ "𨗭": "𨗭",
+ "邏": "邏",
+ "⾢": "邑",
+ "邔": "邔",
+ "郎": "郎",
+ "郞": "郎",
+ "郞": "郎",
+ "郱": "郱",
+ "都": "都",
+ "𨜮": "𨜮",
+ "鄑": "鄑",
+ "鄛": "鄛",
+ "⾣": "酉",
+ "酪": "酪",
+ "醙": "醙",
+ "醴": "醴",
+ "⾤": "釆",
+ "里": "里",
+ "⾥": "里",
+ "量": "量",
+ "金": "金",
+ "⾦": "金",
+ "鈴": "鈴",
+ "鈸": "鈸",
+ "鉶": "鉶",
+ "鋗": "鋗",
+ "鋘": "鋘",
+ "鉼": "鉼",
+ "錄": "錄",
+ "鍊": "鍊",
+ "鎮": "鎭",
+ "鏹": "鏹",
+ "鐕": "鐕",
+ "𨯺": "𨯺",
+ "⻐": "钅",
+ "⻑": "長",
+ "⾧": "長",
+ "⻒": "镸",
+ "⻓": "长",
+ "⾨": "門",
+ "開": "開",
+ "䦕": "䦕",
+ "閭": "閭",
+ "閷": "閷",
+ "𨵷": "𨵷",
+ "⻔": "门",
+ "⾩": "阜",
+ "⻏": "阝",
+ "⻖": "阝",
+ "阮": "阮",
+ "陋": "陋",
+ "降": "降",
+ "陵": "陵",
+ "陸": "陸",
+ "陼": "陼",
+ "隆": "隆",
+ "隣": "隣",
+ "䧦": "䧦",
+ "⾪": "隶",
+ "隷": "隷",
+ "隸": "隷",
+ "隸": "隷",
+ "⾫": "隹",
+ "雃": "雃",
+ "離": "離",
+ "難": "難",
+ "難": "難",
+ "⾬": "雨",
+ "零": "零",
+ "雷": "雷",
+ "霣": "霣",
+ "𩅅": "𩅅",
+ "露": "露",
+ "靈": "靈",
+ "⾭": "靑",
+ "⻘": "青",
+ "靖": "靖",
+ "靖": "靖",
+ "𩇟": "𩇟",
+ "⾮": "非",
+ "⾯": "面",
+ "𩈚": "𩈚",
+ "⾰": "革",
+ "䩮": "䩮",
+ "䩶": "䩶",
+ "⾱": "韋",
+ "韛": "韛",
+ "韠": "韠",
+ "⻙": "韦",
+ "⾲": "韭",
+ "𩐊": "𩐊",
+ "⾳": "音",
+ "響": "響",
+ "響": "響",
+ "⾴": "頁",
+ "䪲": "䪲",
+ "頋": "頋",
+ "頋": "頋",
+ "頋": "頋",
+ "領": "領",
+ "頩": "頩",
+ "𩒖": "𩒖",
+ "頻": "頻",
+ "頻": "頻",
+ "類": "類",
+ "⻚": "页",
+ "⾵": "風",
+ "𩖶": "𩖶",
+ "⻛": "风",
+ "⾶": "飛",
+ "⻜": "飞",
+ "⻝": "食",
+ "⾷": "食",
+ "⻟": "飠",
+ "飢": "飢",
+ "飯": "飯",
+ "飼": "飼",
+ "䬳": "䬳",
+ "館": "館",
+ "餩": "餩",
+ "⻠": "饣",
+ "⾸": "首",
+ "⾹": "香",
+ "馧": "馧",
+ "⾺": "馬",
+ "駂": "駂",
+ "駱": "駱",
+ "駾": "駾",
+ "驪": "驪",
+ "⻢": "马",
+ "⾻": "骨",
+ "䯎": "䯎",
+ "⾼": "高",
+ "⾽": "髟",
+ "𩬰": "𩬰",
+ "鬒": "鬒",
+ "鬒": "鬒",
+ "⾾": "鬥",
+ "⾿": "鬯",
+ "⿀": "鬲",
+ "⿁": "鬼",
+ "⻤": "鬼",
+ "⿂": "魚",
+ "魯": "魯",
+ "鱀": "鱀",
+ "鱗": "鱗",
+ "⻥": "鱼",
+ "⿃": "鳥",
+ "鳽": "鳽",
+ "䳎": "䳎",
+ "鵧": "鵧",
+ "䳭": "䳭",
+ "𪃎": "𪃎",
+ "鶴": "鶴",
+ "𪄅": "𪄅",
+ "䳸": "䳸",
+ "鷺": "鷺",
+ "𪈎": "𪈎",
+ "鸞": "鸞",
+ "鹃": "鹂",
+ "⿄": "鹵",
+ "鹿": "鹿",
+ "⿅": "鹿",
+ "𪊑": "𪊑",
+ "麗": "麗",
+ "麟": "麟",
+ "⿆": "麥",
+ "⻨": "麦",
+ "麻": "麻",
+ "⿇": "麻",
+ "𪎒": "𪎒",
+ "⿈": "黃",
+ "⻩": "黄",
+ "⿉": "黍",
+ "黎": "黎",
+ "䵖": "䵖",
+ "⿊": "黑",
+ "黒": "黑",
+ "墨": "墨",
+ "黹": "黹",
+ "⿋": "黹",
+ "⿌": "黽",
+ "鼅": "鼅",
+ "黾": "黾",
+ "⿍": "鼎",
+ "鼏": "鼏",
+ "⿎": "鼓",
+ "鼖": "鼖",
+ "⿏": "鼠",
+ "鼻": "鼻",
+ "⿐": "鼻",
+ "齃": "齃",
+ "⿑": "齊",
+ "⻬": "齐",
+ "⿒": "齒",
+ "𪘀": "𪘀",
+ "⻮": "齿",
+ "龍": "龍",
+ "⿓": "龍",
+ "龎": "龎",
+ "⻰": "龙",
+ "龜": "龜",
+ "龜": "龜",
+ "龜": "龜",
+ "⿔": "龜",
+ "⻳": "龟",
+ "⿕": "龠"
+} \ No newline at end of file
diff --git a/comm/chat/protocols/matrix/lib/unhomoglyph/index.js b/comm/chat/protocols/matrix/lib/unhomoglyph/index.js
new file mode 100644
index 0000000000..39150ea2ed
--- /dev/null
+++ b/comm/chat/protocols/matrix/lib/unhomoglyph/index.js
@@ -0,0 +1,20 @@
+'use strict';
+
+
+var data = require('./data.json');
+
+function escapeRegexp(str) {
+ return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
+}
+
+var REPLACE_RE = RegExp(Object.keys(data).map(escapeRegexp).join('|'), 'g');
+
+function replace_fn(match) {
+ return data[match];
+}
+
+function unhomoglyph(str) {
+ return str.replace(REPLACE_RE, replace_fn);
+}
+
+module.exports = unhomoglyph;
diff --git a/comm/chat/protocols/matrix/matrix-sdk.sys.mjs b/comm/chat/protocols/matrix/matrix-sdk.sys.mjs
new file mode 100644
index 0000000000..fd691dbc1b
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrix-sdk.sys.mjs
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { console } from "resource://gre/modules/Console.sys.mjs";
+import {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setTimeout,
+} from "resource://gre/modules/Timer.sys.mjs";
+import { scriptError } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ Loader,
+ Require,
+} from "resource://devtools/shared/loader/base-loader.sys.mjs";
+
+/**
+ * Set of packages that have a top level index.js. This makes it so we don't
+ * even try to require them as a js file directly and just fall through to the
+ * index.js logic. These are paths without matrixPath in front.
+ *
+ * @type {Set<string>}
+ */
+const KNOWN_INDEX_JS = new Set([
+ "matrix_events_sdk",
+ "p_retry",
+ "retry",
+ "sdp_transform",
+ "unhomoglyph",
+ "matrix_sdk/crypto",
+ "matrix_sdk/crypto/algorithms",
+ "matrix_sdk/http_api",
+ "matrix_sdk/rendezvous",
+ "matrix_sdk/rendezvous/channels",
+ "matrix_sdk/rendezvous/transports",
+ "matrix_widget_api",
+]);
+
+// Set-up loading so require works properly in CommonJS modules.
+
+let matrixPath = "resource:///modules/matrix/";
+
+let globals = {
+ atob,
+ btoa,
+ crypto,
+ console,
+ fetch,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ TextEncoder,
+ TextDecoder,
+ URL,
+ URLSearchParams,
+ IDBKeyRange,
+ get window() {
+ return globals;
+ },
+
+ // Necessary for interacting with the logging framework.
+ scriptError,
+ imIDebugMessage: Ci.imIDebugMessage,
+};
+let loaderGlobal = {
+ get window() {
+ return globals;
+ },
+ get global() {
+ return globals;
+ },
+ ...globals,
+};
+let loader = Loader({
+ paths: {
+ // Matrix SDK files.
+ "matrix-sdk": matrixPath + "matrix_sdk",
+ "matrix-sdk/@types": matrixPath + "matrix_sdk/types",
+ "matrix-sdk/@types/requests": matrixPath + "empty.js",
+ // The entire directory can't be mapped from crypto-api to crypto_api since
+ // there's also a matrix-sdk/crypto-api.js.
+ "matrix-sdk/crypto-api/verification":
+ matrixPath + "matrix_sdk/crypto_api/verification.js",
+ "matrix-sdk/http-api": matrixPath + "matrix_sdk/http_api",
+ "matrix-sdk/rust-crypto": matrixPath + "matrix_sdk/rust_crypto",
+
+ // Simple (one-file) dependencies.
+ "another-json": matrixPath + "another-json.js",
+ "base-x": matrixPath + "base_x/index.js",
+ bs58: matrixPath + "bs58/index.js",
+ "content-type": matrixPath + "content_type/index.js",
+
+ // unhomoglyph
+ unhomoglyph: matrixPath + "unhomoglyph",
+
+ // p-retry
+ "p-retry": matrixPath + "p_retry",
+ retry: matrixPath + "retry",
+
+ // matrix-events-sdk
+ "matrix-events-sdk": matrixPath + "matrix_events_sdk",
+ "matrix-events-sdk/IPartialEvent": matrixPath + "empty.js",
+
+ // matrix-widget-api
+ "matrix-widget-api": matrixPath + "matrix_widget_api",
+ "matrix-widget-api/interfaces/CapabilitiesAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/ContentLoadedAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/ICustomWidgetData": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IJitsiWidgetData": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IRoomEvent": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IStickerpickerWidgetData":
+ matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IWidget": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IWidgetApiRequest": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/IWidgetApiResponse": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/NavigateAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/OpenIDCredentialsAction":
+ matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/ReadEventAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/ReadRelationsAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/ScreenshotAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/SetModalButtonEnabledAction":
+ matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/SendAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/SendEventAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/SendToDeviceAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/StickerAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/StickyAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/SupportedVersionsAction":
+ matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/TurnServerActions": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/VisibilityAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/WidgetAction": matrixPath + "empty.js",
+ "matrix-widget-api/interfaces/WidgetConfigAction": matrixPath + "empty.js",
+ "matrix-widget-api/transport/ITransport": matrixPath + "empty.js",
+
+ // sdp-transform
+ "sdp-transform": matrixPath + "sdp_transform",
+
+ // Packages that are not included, but an alternate implementation is given.
+ events: matrixPath + "events.js",
+ loglevel: matrixPath + "loglevel.js",
+ "safe-buffer": matrixPath + "safe-buffer.js",
+ uuid: matrixPath + "uuid.js",
+ },
+ globals: loaderGlobal,
+ sandboxName: "Matrix SDK",
+ // Custom require hook to support loading */index.js without explicitly
+ // including it in the require path.
+ requireHook: (id, require) => {
+ try {
+ // Get resolved path without matrixPath prefix and .js extension.
+ const resolved = require.resolve(id).slice(matrixPath.length, -3);
+ if (KNOWN_INDEX_JS.has(resolved)) {
+ throw new Error("Must require index.js for module " + id);
+ }
+ return require(id);
+ } catch (error) {
+ // Make sure we only try to look for index.js on the initial failure and
+ // not in requires earlier in the tree.
+ if (!error.rethrown && !id.endsWith("/index.js")) {
+ try {
+ return require(id + "/index.js");
+ } catch (indexError) {
+ indexError.rethrown = true;
+ throw indexError;
+ }
+ }
+ error.rethrown = true;
+ throw error;
+ }
+ },
+});
+
+// Load olm library in a browser-like environment. This allows it to load its
+// wasm module, do crypto operations and log errors.
+// Create the global in the commonJS loader context, so they share the same
+// Uint8Array constructor.
+let olmScope = Cu.createObjectIn(loader.sharedGlobal);
+Object.assign(olmScope, {
+ crypto,
+ fetch,
+ XMLHttpRequest,
+ console,
+ location: {
+ href: matrixPath + "olm",
+ },
+ document: {
+ currentScript: {
+ src: matrixPath + "olm/olm.js",
+ },
+ },
+});
+Object.defineProperty(olmScope, "window", {
+ get() {
+ return olmScope;
+ },
+});
+Services.scriptloader.loadSubScript(matrixPath + "olm/olm.js", olmScope);
+olmScope.Olm.init().catch(console.error);
+loader.globals.Olm = olmScope.Olm;
+globals.Olm = olmScope.Olm;
+
+let require = Require(loader, { id: "matrix-module" });
+
+// Load the buffer shim into the global commonJS scope
+loader.globals.Buffer = require("safe-buffer").Buffer;
+
+globals.Buffer = loader.globals.Buffer;
+
+// The main entry point into the Matrix client.
+export let MatrixSDK = require("matrix-sdk/browser-index.js");
+
+// Helper enums not exposed on MatrixSDK.
+export let MatrixCrypto = require("matrix-sdk/crypto");
+export let { SyncState } = require("matrix-sdk/sync");
+export let OlmLib = require("matrix-sdk/crypto/olmlib");
+export let { ReceiptType } = require("matrix-sdk/@types/read_receipts");
diff --git a/comm/chat/protocols/matrix/matrix.sys.mjs b/comm/chat/protocols/matrix/matrix.sys.mjs
new file mode 100644
index 0000000000..36c72d3002
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrix.sys.mjs
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "brandShortName", () =>
+ Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName")
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ MatrixAccount: "resource:///modules/matrixAccount.sys.mjs",
+});
+
+export function MatrixProtocol() {
+ this.commands = ChromeUtils.importESModule(
+ "resource:///modules/matrixCommands.sys.mjs"
+ ).commands;
+ this.registerCommands();
+}
+
+MatrixProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "matrix";
+ },
+ get name() {
+ return "Matrix";
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-matrix/skin/";
+ },
+ getAccount(aImAccount) {
+ return new lazy.MatrixAccount(this, aImAccount);
+ },
+
+ get usernameEmptyText() {
+ return lazy._("matrix.usernameHint");
+ },
+ usernamePrefix: "@",
+ usernameSplits: [
+ {
+ get label() {
+ return lazy._("options.homeserver");
+ },
+ separator: ":",
+ },
+ ],
+
+ options: {
+ saveToken: {
+ get label() {
+ return lazy._("options.saveToken");
+ },
+ default: true,
+ },
+ deviceDisplayName: {
+ get label() {
+ return lazy._("options.deviceDisplayName");
+ },
+ get default() {
+ return lazy.brandShortName;
+ },
+ },
+ backupPassphrase: {
+ get label() {
+ return lazy._("options.backupPassphrase");
+ },
+ default: "",
+ masked: true,
+ },
+ },
+
+ get chatHasTopic() {
+ return true;
+ },
+ //TODO this should depend on the server (i.e. if it offers SSO). Should also have noPassword true if there is no password login flow available.
+ get passwordOptional() {
+ return true;
+ },
+ get canEncrypt() {
+ return true;
+ },
+};
diff --git a/comm/chat/protocols/matrix/matrixAccount.sys.mjs b/comm/chat/protocols/matrix/matrixAccount.sys.mjs
new file mode 100644
index 0000000000..f6ae807b53
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrixAccount.sys.mjs
@@ -0,0 +1,3495 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ nsSimpleEnumerator,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericConvChatPrototype,
+ GenericConvChatBuddyPrototype,
+ GenericConversationPrototype,
+ GenericConvIMPrototype,
+ GenericAccountBuddyPrototype,
+ GenericMessagePrototype,
+ GenericSessionPrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["chat/matrix.ftl"], true)
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ InteractiveBrowser: "resource:///modules/InteractiveBrowser.sys.mjs",
+ MatrixCrypto: "resource:///modules/matrix-sdk.sys.mjs",
+ MatrixMessageContent: "resource:///modules/matrixMessageContent.sys.mjs",
+ MatrixPowerLevels: "resource:///modules/matrixPowerLevels.sys.mjs",
+ MatrixSDK: "resource:///modules/matrix-sdk.sys.mjs",
+ OlmLib: "resource:///modules/matrix-sdk.sys.mjs",
+ ReceiptType: "resource:///modules/matrix-sdk.sys.mjs",
+ SyncState: "resource:///modules/matrix-sdk.sys.mjs",
+});
+
+/**
+ * Homeserver information in client .well-known payload.
+ *
+ * @constant {string}
+ */
+const HOMESERVER_WELL_KNOWN = "m.homeserver";
+
+// This matches the configuration of the .userIcon class in chat.css, which
+// expects square icons.
+const USER_ICON_SIZE = 48;
+const SERVER_NOTICE_TAG = "m.server_notice";
+
+const MAX_CATCHUP_EVENTS = 300;
+// Should always be smaller or equal to MAX_CATCHUP_EVENTS
+const CATCHUP_PAGE_SIZE = 25;
+
+/**
+ * @param {string} who - Message sender ID.
+ * @param {string} text - Message text.
+ * @param {object} properties - Message properties, should also have an event
+ * property containing the corresponding MatrixEvent instance.
+ * @param {MatrixRoom} conversation - The conversation the Message belongs to.
+ */
+export function MatrixMessage(who, text, properties, conversation) {
+ this._init(who, text, properties, conversation);
+}
+
+MatrixMessage.prototype = {
+ __proto__: GenericMessagePrototype,
+
+ /**
+ * @type {MatrixEvent}
+ */
+ event: null,
+
+ /**
+ * @type {{msg: string, action: boolean, notice: boolean}}
+ */
+ retryInfo: null,
+
+ get hideReadReceipts() {
+ // Cache pref value. If this pref gets exposed in UI we need cache busting.
+ if (this._hideReadReceipts === undefined) {
+ this._hideReadReceipts = !Services.prefs.getBoolPref(
+ "purple.conversations.im.send_read"
+ );
+ }
+ return this._hideReadReceipts;
+ },
+
+ _displayed: false,
+ _read: false,
+
+ whenDisplayed() {
+ if (
+ this._displayed ||
+ !this.event ||
+ (this.event.status &&
+ this.event.status !== lazy.MatrixSDK.EventStatus.SENT)
+ ) {
+ return;
+ }
+ this._displayed = true;
+ this.conversation._account._client
+ .sendReadReceipt(
+ this.event,
+ this.hideReadReceipts
+ ? lazy.ReceiptType.ReadPrivate
+ : lazy.ReceiptType.Read
+ )
+ .catch(error => this.conversation.ERROR(error));
+ },
+
+ whenRead() {
+ // whenRead is also called when the conversation is closed.
+ if (
+ this._read ||
+ !this.event ||
+ !this.conversation._account ||
+ this.conversation._account.noFullyRead ||
+ (this.event.status &&
+ this.event.status !== lazy.MatrixSDK.EventStatus.SENT)
+ ) {
+ return;
+ }
+ this._read = true;
+ this.conversation._account._client
+ .setRoomReadMarkers(this.conversation._roomId, this.event.getId())
+ .catch(error => {
+ if (error.errcode === "M_UNRECOGNIZED") {
+ // Server does not support setting the fully read marker.
+ this.conversation._account.noFullyRead = true;
+ } else {
+ this.conversation.ERROR(error);
+ }
+ });
+ },
+
+ getActions() {
+ const actions = [];
+ if (this.event?.isDecryptionFailure()) {
+ actions.push({
+ label: lazy._("message.action.requestKey"),
+ run: () => {
+ if (this.event) {
+ this.conversation?._account?._client
+ ?.cancelAndResendEventRoomKeyRequest(this.event)
+ .catch(error => this.conversation._account.ERROR(error));
+ }
+ },
+ });
+ }
+ if (
+ this.event &&
+ this.conversation?.roomState.maySendRedactionForEvent(
+ this.event,
+ this.conversation._account?.userId
+ )
+ ) {
+ actions.push({
+ label: lazy._("message.action.redact"),
+ run: () => {
+ this.conversation?._account?._client
+ ?.redactEvent(
+ this.event.getRoomId(),
+ this.event.threadRootId,
+ this.event.getId()
+ )
+ .catch(error => this.conversation._account.ERROR(error));
+ },
+ });
+ }
+ if (this.incoming && this.event) {
+ actions.push({
+ label: lazy._("message.action.report"),
+ run: () => {
+ this.conversation?._account?._client
+ ?.reportEvent(this.event.getRoomId(), this.event.getId(), -100, "")
+ .catch(error => this.conversation._account.ERROR(error));
+ },
+ });
+ }
+ if (this.event?.status === lazy.MatrixSDK.EventStatus.NOT_SENT) {
+ actions.push({
+ label: lazy._("message.action.retry"),
+ run: () => {
+ this.conversation?._account?._client?.resendEvent(
+ this.event,
+ this.conversation.room
+ );
+ },
+ });
+ }
+ if (
+ [
+ lazy.MatrixSDK.EventStatus.NOT_SENT,
+ lazy.MatrixSDK.EventStatus.QUEUED,
+ lazy.MatrixSDK.EventStatus.ENCRYPTING,
+ ].includes(this.event?.status)
+ ) {
+ actions.push({
+ label: lazy._("message.action.cancel"),
+ run: () => {
+ this.conversation?._account?._client?.cancelPendingEvent(this.event);
+ },
+ });
+ }
+ return actions;
+ },
+};
+
+/**
+ * Check if a user has unverified devices.
+ *
+ * @param {string} userId - User to check.
+ * @param {MatrixClient} client - Matrix SDK client instance to use.
+ * @returns {boolean}
+ */
+function checkUserHasUnverifiedDevices(userId, client) {
+ const devices = client.getStoredDevicesForUser(userId);
+ return devices.some(
+ ({ deviceId }) => !client.checkDeviceTrust(userId, deviceId).isVerified()
+ );
+}
+
+/**
+ * Shared implementation for canVerifyIdentity between MatrixParticipant and
+ * MatrixBuddy.
+ *
+ * @param {string} userId - Matrix ID of the user.
+ * @param {MatrixClient} client - Matrix SDK client instance.
+ * @returns {boolean}
+ */
+function canVerifyUserIdentity(userId, client) {
+ client.downloadKeys([userId]);
+ return Boolean(client.getStoredDevicesForUser(userId)?.length);
+}
+
+/**
+ * Checks if we consider the identity of a user as verified.
+ *
+ * @param {string} userId - Matrix ID of the user to check.
+ * @param {MatrixClient} client - Matrix SDK client instance to use.
+ * @returns {boolean}
+ */
+function userIdentityVerified(userId, client) {
+ return (
+ client.checkUserTrust(userId).isCrossSigningVerified() &&
+ !checkUserHasUnverifiedDevices(userId, client)
+ );
+}
+
+function MatrixParticipant(roomMember, account) {
+ this._id = roomMember.userId;
+ this._roomMember = roomMember;
+ this._account = account;
+}
+MatrixParticipant.prototype = {
+ __proto__: GenericConvChatBuddyPrototype,
+ get alias() {
+ return this._roomMember.name;
+ },
+ get name() {
+ return this._id;
+ },
+
+ get buddyIconFilename() {
+ return (
+ this._roomMember.getAvatarUrl(
+ this._account._client.getHomeserverUrl(),
+ USER_ICON_SIZE,
+ USER_ICON_SIZE,
+ "scale",
+ false
+ ) || ""
+ );
+ },
+
+ get voiced() {
+ // If the default power level doesn't let you send messages, set voiced if
+ // the user can send messages
+ const room = this._account?._client?.getRoom(this._roomMember.roomId);
+ if (room) {
+ const powerLevels = room.currentState
+ .getStateEvents(lazy.MatrixSDK.EventType.RoomPowerLevels, "")
+ ?.getContent();
+ const defaultLevel =
+ lazy.MatrixPowerLevels.getUserDefaultLevel(powerLevels);
+ const messageLevel = lazy.MatrixPowerLevels.getEventLevel(
+ powerLevels,
+ this._account._client.isRoomEncrypted(room.roomId)
+ ? lazy.MatrixSDK.EventType.RoomMessageEncrypted
+ : lazy.MatrixSDK.EventType.RoomMessage
+ );
+ if (defaultLevel < messageLevel) {
+ return room.currentState.maySendMessage(this._id);
+ }
+ }
+ // Else use a synthetic power level for the voiced flag
+ return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.voice;
+ },
+ get moderator() {
+ return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.moderator;
+ },
+ get admin() {
+ return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.admin;
+ },
+
+ get canVerifyIdentity() {
+ return canVerifyUserIdentity(this.name, this._account._client);
+ },
+
+ get _identityVerified() {
+ return userIdentityVerified(this.name, this._account._client);
+ },
+
+ _startVerification() {
+ return this._account.startVerificationDM(this.name);
+ },
+};
+
+const kPresenceToStatusEnum = {
+ online: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ offline: Ci.imIStatusInfo.STATUS_OFFLINE,
+ unavailable: Ci.imIStatusInfo.STATUS_IDLE,
+};
+const kSetIdleStatusAfterSeconds = 300;
+
+/**
+ * Map matrix presence information to a Ci.imIStatusInfo statusType.
+ *
+ * @param {User} user - Matrix JS SDK User instance to get the status for.
+ * @returns {number} Status enum value for the user.
+ */
+function getStatusFromPresence(user) {
+ let status = kPresenceToStatusEnum[user.presence];
+ // If the user hasn't been seen in a long time, consider them idle.
+ if (
+ user.presence === "online" &&
+ !user.currentlyActive &&
+ user.lastActiveAgo > kSetIdleStatusAfterSeconds
+ ) {
+ status = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+ if (!status) {
+ status = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ }
+ return status;
+}
+
+/**
+ * Matrix buddies only exist in association with at least one direct
+ * conversation. They serve primarily to provide metadata to the
+ * direct conversation rooms.
+ *
+ * @param {imIAccount} account
+ * @param {imIBuddy|null} buddy
+ * @param {imITag|null} tag
+ * @param {string} [userId] - Matrix user ID, only required if no buddy is provided.
+ */
+function MatrixBuddy(account, buddy, tag, userId) {
+ this._init(account, buddy, tag, userId);
+}
+
+MatrixBuddy.prototype = {
+ __proto__: GenericAccountBuddyPrototype,
+
+ get buddyIconFilename() {
+ return (
+ (this._user &&
+ this._account._client.mxcUrlToHttp(this._user.avatarUrl)) ||
+ ""
+ );
+ },
+
+ get canSendMessage() {
+ return true;
+ },
+
+ /**
+ * Initialize the buddy with a user.
+ *
+ * @param {User} user - Matrix user.
+ */
+ setUser(user) {
+ this._user = user;
+ this._serverAlias = user.displayName;
+ // The contacts service might not have had a chance to add an imIBuddy yet,
+ // since it also wants the serverAlias to be set if possible.
+ if (this.buddy) {
+ this.setStatus(getStatusFromPresence(user), user.presenceStatusMsg ?? "");
+ }
+ },
+
+ /**
+ * Updates the buddy's status based on its JS SDK user's presence.
+ */
+ setStatusFromPresence() {
+ this.setStatus(
+ getStatusFromPresence(this._user),
+ this._user.presenceStatusMsg ?? ""
+ );
+ },
+
+ remove() {
+ const otherDMRooms = this._account._userToRoom[this.userName];
+ for (const roomId of otherDMRooms) {
+ if (this._account.roomList.has(roomId)) {
+ const conversation = this._account.roomList.get(roomId);
+ if (!conversation.isChat) {
+ // Prevent the conversation from doing buddy cleanup
+ delete conversation.buddy;
+ conversation.close();
+ }
+ }
+ }
+ this._account.buddies.delete(this.userName);
+ GenericAccountBuddyPrototype.remove.call(this);
+ },
+
+ getTooltipInfo() {
+ return this._account.getBuddyInfo(this.userName);
+ },
+
+ createConversation() {
+ return this._account.getDirectConversation(this.userName);
+ },
+
+ get canVerifyIdentity() {
+ return canVerifyUserIdentity(this.userName, this._account._client);
+ },
+
+ get _identityVerified() {
+ return userIdentityVerified(this.userName, this._account._client);
+ },
+
+ _startVerification() {
+ return this._account.startVerificationDM(this.userName);
+ },
+};
+
+/**
+ * Determine if the event will likely have text content composed by a user to
+ * display in a conversation based on its type.
+ *
+ * @param {MatrixEvent} event - Event to check the type of
+ * @returns {boolean} True if the event would typically be shown as text content
+ * sent by a user in a conversation.
+ */
+function isContentEvent(event) {
+ return [
+ lazy.MatrixSDK.EventType.RoomMessage,
+ lazy.MatrixSDK.EventType.RoomMessageEncrypted,
+ lazy.MatrixSDK.EventType.Sticker,
+ ].includes(event.getType());
+}
+
+/**
+ * Matrix rooms are androgynous. Sometimes they are DM conversations, other
+ * times they are MUCs.
+ * This class implements both conversations state and transition between the
+ * two. Methods are grouped by shared/MUC/DM.
+ * The type is only changed on explicit request.
+ *
+ * @param {MatrixAccount} account - Account this room belongs to.
+ * @param {boolean} isMUC - True if this is a group conversation.
+ * @param {string} name - Name of the room.
+ */
+export function MatrixRoom(account, isMUC, name) {
+ this._isChat = isMUC;
+ this._init(account, name, account.userId);
+ this._initialized = new Promise(resolve => {
+ this._resolveInitializer = resolve;
+ });
+ this._eventsWaitingForDecryption = new Set();
+ this._joiningLocks = new Set();
+ this._addJoiningLock("roomInit");
+}
+
+MatrixRoom.prototype = {
+ __proto__: GenericConvChatPrototype,
+ /**
+ * This conversation implements both the IM and the Chat prototype.
+ */
+ _interfaces: [Ci.prplIConversation, Ci.prplIConvIM, Ci.prplIConvChat],
+
+ get isChat() {
+ return this._isChat;
+ },
+
+ /**
+ * ID of the most recent event written to the conversation.
+ *
+ * @type {string}
+ */
+ _mostRecentEventId: null,
+
+ /**
+ * Event IDs that we showed a decryption error in the conversation for.
+ *
+ * @type {Set<string>}
+ */
+ _eventsWaitingForDecryption: null,
+
+ /**
+ * A set of operations that are pending that want the room to show as joining.
+ *
+ * @type {Set<string>}
+ */
+ _joiningLocks: null,
+
+ /**
+ * Add a lock on the joining state during an operation.
+ *
+ * @param {string} lockName - Name of the operation that wants to lock joining
+ * state.
+ */
+ _addJoiningLock(lockName) {
+ this._joiningLocks.add(lockName);
+ if (!this.joining) {
+ this.joining = true;
+ }
+ },
+
+ /**
+ * Release a joining state lock by an operation.
+ *
+ * @param {string} lockName - Name of the operation that completed.
+ */
+ _releaseJoiningLock(lockName) {
+ this._joiningLocks.delete(lockName);
+ if (this.joining && this._joiningLocks.size === 0) {
+ this.joining = false;
+ }
+ },
+
+ /**
+ * Leave the room if we close the conversation.
+ */
+ close() {
+ // Clean up any outgoing verification request by us.
+ if (!this.isChat) {
+ this.cleanUpOutgoingVerificationRequests();
+ }
+ this._account._client.leave(this._roomId);
+ this.left = true;
+ this.forget();
+ },
+
+ /**
+ * Forget about this conversation instance. This closes the conversation in
+ * the UI, but doesn't update the user's membership in the room.
+ */
+ forget() {
+ if (!this.isChat) {
+ this.closeDm();
+ }
+ this._account?.roomList.delete(this._roomId);
+ this._releaseJoiningLock("roomInit");
+ if (this._account) {
+ GenericConversationPrototype.close.call(this);
+ }
+ },
+
+ /**
+ * Sends the given message as a text message to the Matrix room. Does not
+ * create the local copy, that is handled by the local echo of the SDK.
+ *
+ * @param {string} msg - Message to send.
+ * @param {boolean} [action=false] - If the message is an emote.
+ * @param {boolean} [notice=false]
+ */
+ dispatchMessage(msg, action = false, notice = false) {
+ const handleSendError = type => error => {
+ this._account.ERROR(
+ `Failed to send ${type} to ${this._roomId}: ${error.message}`
+ );
+ };
+ this.sendTyping("");
+ if (action) {
+ this._account._client
+ .sendEmoteMessage(this._roomId, null, msg)
+ .catch(handleSendError("emote"));
+ } else if (notice) {
+ this._account._client
+ .sendNotice(this._roomId, null, msg)
+ .catch(handleSendError("notice"));
+ } else {
+ this._account._client
+ .sendTextMessage(this._roomId, null, msg)
+ .catch(handleSendError("message"));
+ }
+ },
+
+ /**
+ * Shared init function between conversation types
+ *
+ * @param {Room} room - associated room with the conversation.
+ */
+ async initRoom(room) {
+ if (!room) {
+ return;
+ }
+ if (room.isSpaceRoom()) {
+ this.writeMessage(
+ this._account.userId,
+ lazy._("message.spaceNotSupported"),
+ {
+ system: true,
+ incoming: true,
+ error: true,
+ }
+ );
+ this._setInitialized();
+ this.left = true;
+ return;
+ }
+ // Store the ID of the room to look up information in the future.
+ this._roomId = room.roomId;
+
+ // Update the title to the human readable version.
+ if (room.name && this._name != room.name && room.name !== room.roomId) {
+ this._name = room.name;
+ this.notifyObservers(null, "update-conv-title");
+ }
+
+ this.updateConvIcon();
+
+ if (this.isChat) {
+ await this.initRoomMuc(room);
+ } else {
+ this.initRoomDm(room);
+ await this.searchForVerificationRequests().catch(error =>
+ this._account.WARN(error)
+ );
+ }
+
+ // Room may have been disposed in the mean time.
+ if (this._replacedBy || !this._account) {
+ return;
+ }
+
+ await this.updateUnverifiedDevices();
+ this._setInitialized();
+ },
+
+ /**
+ * Mark conversation as initialized, meaning it has an associated room in the
+ * state of the SDK. Sets the joining state to false and resolves
+ * _initialized.
+ */
+ _setInitialized() {
+ this._releaseJoiningLock("roomInit");
+ this._resolveInitializer();
+ },
+
+ /**
+ * Function to mark this room instance superseded by another one.
+ * Useful when converting between DM and MUC or possibly room version
+ * upgrades.
+ *
+ * @param {MatrixRoom} newRoom - Room that replaces this room.
+ */
+ replaceRoom(newRoom) {
+ this._replacedBy = newRoom;
+ newRoom._mostRecentEventId = this._mostRecentEventId;
+ this._setInitialized();
+ },
+
+ /**
+ * Wait until the conversation is fully initialized. Handles replacements of
+ * the conversation in the meantime.
+ *
+ * @returns {MatrixRoom} The most recent instance of this room
+ * that is fully initialized.
+ */
+ async waitForRoom() {
+ await this._initialized;
+ if (this._replacedBy) {
+ return this._replacedBy.waitForRoom();
+ }
+ return this;
+ },
+
+ /**
+ * Write all missing events to the conversation. Should be called once the
+ * client is in a stable sync state again.
+ *
+ * @returns {Promise}
+ */
+ async catchup() {
+ this._addJoiningLock("catchup");
+ await this.waitForRoom();
+ if (this.isChat) {
+ await this.room.loadMembersIfNeeded();
+ const members = this.room.getJoinedMembers();
+ const memberUserIds = members.map(member => member.userId);
+ for (const userId of this._participants.keys()) {
+ if (!memberUserIds.includes(userId)) {
+ this.removeParticipant(userId);
+ }
+ }
+ for (const member of members) {
+ this.addParticipant(member);
+ }
+
+ this._name = this.room.name;
+ this.notifyObservers(null, "update-conv-title");
+ }
+
+ // Find the newest event id the user has already seen
+ let latestOldEvent;
+ if (this._mostRecentEventId) {
+ latestOldEvent = this._mostRecentEventId;
+ } else {
+ // Last message the user has read with high certainty.
+ const fullyRead = this.room.getAccountData(
+ lazy.MatrixSDK.EventType.FullyRead
+ );
+ if (fullyRead) {
+ latestOldEvent = fullyRead.getContent().event_id;
+ }
+ }
+ // Get the timeline for the event, or just the current live timeline of the room
+ let timelineWindow = new lazy.MatrixSDK.TimelineWindow(
+ this._account._client,
+ this.room.getUnfilteredTimelineSet(),
+ {
+ windowLimit: MAX_CATCHUP_EVENTS,
+ }
+ );
+ const newestEvent = this.room.getLiveTimeline().getEvents().at(-1);
+ // Start the window at the newest event.
+ await timelineWindow.load(newestEvent.getId(), CATCHUP_PAGE_SIZE);
+ // Check if the oldest event we want to see is already in the window
+ let checkEvent = event =>
+ event.getId() === latestOldEvent ||
+ (event.getSender() === this._account.userId && isContentEvent(event));
+ let endIndex = -1;
+ if (latestOldEvent) {
+ const events = timelineWindow.getEvents();
+ endIndex = events.slice().reverse().findIndex(checkEvent);
+ if (endIndex >= 0) {
+ endIndex = events.length - endIndex - 1;
+ }
+ }
+ // Paginate backward until we either find our oldest event or we reach the max backscroll length.
+ while (
+ endIndex === -1 &&
+ timelineWindow.getEvents().length < MAX_CATCHUP_EVENTS &&
+ timelineWindow.canPaginate(lazy.MatrixSDK.EventTimeline.BACKWARDS)
+ ) {
+ const baseSize = timelineWindow.getEvents().length;
+ const windowSize = Math.min(
+ MAX_CATCHUP_EVENTS - baseSize,
+ CATCHUP_PAGE_SIZE
+ );
+ const didLoadEvents = await timelineWindow.paginate(
+ lazy.MatrixSDK.EventTimeline.BACKWARDS,
+ windowSize
+ );
+ // Only search in the newly added events
+ const events = timelineWindow.getEvents();
+ endIndex = events.slice(0, -baseSize).reverse().findIndex(checkEvent);
+ if (endIndex >= 0) {
+ endIndex = events.length - baseSize - endIndex - 1;
+ }
+ if (!didLoadEvents) {
+ break;
+ }
+ }
+ // Remove the old event from the window.
+ if (endIndex !== -1) {
+ timelineWindow.unpaginate(endIndex + 1, true);
+ }
+ const newEvents = timelineWindow.getEvents();
+ for (const event of newEvents) {
+ this.addEvent(event, true);
+ }
+ this._releaseJoiningLock("catchup");
+ },
+
+ /**
+ * Add a matrix event to the conversation's logs.
+ *
+ * @param {MatrixEvent} event
+ * @param {boolean} [delayed=false] - Event is added while catching up to a live state.
+ * @param {boolean} [replace=false] - Event replaces an existing message.
+ */
+ addEvent(event, delayed = false, replace = false) {
+ if (event.isRedaction()) {
+ // Handled by the SDK.
+ return;
+ }
+ // If the event we got isn't actually a new event in the conversation,
+ // change this to the appropriate value.
+ let newestEventId = event.getId();
+ // Contents of the message to write/update
+ let message = lazy.MatrixMessageContent.getIncomingPlain(
+ event,
+ this._account._client.getHomeserverUrl(),
+ eventId => this.room.findEventById(eventId)
+ );
+ // Options for the message. Many options derived from event are set in
+ // createMessage.
+ let opts = {
+ event,
+ delayed,
+ };
+ if (
+ event.isEncrypted() &&
+ (event.shouldAttemptDecryption() ||
+ event.isBeingDecrypted() ||
+ event.isDecryptionFailure())
+ ) {
+ // Wait for the decryption event for this message.
+ event.once(lazy.MatrixSDK.MatrixEventEvent.Decrypted, event => {
+ this.addEvent(event, false, true);
+ });
+ }
+ const eventType = event.getType();
+ if (event.isRedacted()) {
+ newestEventId = event.getRedactionEvent()?.event_id;
+ replace = true;
+ opts.system = !isContentEvent(event);
+ opts.deleted = true;
+ } else if (isContentEvent(event)) {
+ const eventContent = event.getContent();
+ // Only print server notices when we're in a server notice room.
+ if (
+ eventContent.msgtype === "m.server_notice" &&
+ !this?.room.tags[SERVER_NOTICE_TAG]
+ ) {
+ return;
+ }
+ opts.system = [
+ "m.server_notice",
+ lazy.MatrixSDK.MsgType.KeyVerificationRequest,
+ ].includes(eventContent.msgtype);
+ opts.error = event.isDecryptionFailure();
+ opts.notification =
+ eventContent.msgtype === lazy.MatrixSDK.MsgType.Notice;
+ opts.action = eventContent.msgtype === lazy.MatrixSDK.MsgType.Emote;
+ } else if (eventType === lazy.MatrixSDK.EventType.RoomEncryption) {
+ this.notifyObservers(this, "update-conv-encryption");
+ opts.system = true;
+ this.updateUnverifiedDevices();
+ } else if (eventType == lazy.MatrixSDK.EventType.RoomTopic) {
+ this.setTopic(event.getContent().topic, event.getSender());
+ } else if (eventType == lazy.MatrixSDK.EventType.RoomTombstone) {
+ // Room version update
+ this.writeMessage(event.getSender(), event.getContent().body, {
+ system: true,
+ event,
+ });
+ // Don't write the body using the normal message handling because that
+ // will be too late.
+ message = "";
+ let newConversation = this._account.getGroupConversation(
+ event.getContent().replacement_room,
+ this.name
+ );
+ // Make sure the new room gets the correct conversation type.
+ newConversation.checkForUpdate();
+ this.replaceRoom(newConversation);
+ this.forget();
+ //TODO link to the old logs based on the |predecessor| field of m.room.create
+ } else if (eventType == lazy.MatrixSDK.EventType.RoomAvatar) {
+ // Update the icon of this room.
+ this.updateConvIcon();
+ } else {
+ opts.system = true;
+ // We don't think we should show a notice for this event.
+ if (!message) {
+ this.LOG("Unhandled event: " + JSON.stringify(event.toJSON()));
+ }
+ }
+ if (message) {
+ if (replace) {
+ this.updateMessage(event.getSender(), message, opts);
+ } else {
+ this.writeMessage(event.getSender(), message, opts);
+ }
+ }
+ this._mostRecentEventId = newestEventId;
+ },
+
+ _typingTimer: null,
+ _typingDebounce: null,
+
+ /**
+ * Sets up the composing end timeout and sets the typing state based on the
+ * draft message if typing notifications should be sent.
+ *
+ * @param {string} string - Current draft message.
+ * @returns {number} Amount of remaining characters.
+ */
+ sendTyping(string) {
+ if (!this.shouldSendTypingNotifications) {
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ }
+
+ const isTyping = string.length > 0;
+
+ this._cancelTypingTimer();
+ if (isTyping) {
+ this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000);
+ }
+
+ this._setTypingState(isTyping);
+
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ },
+
+ /**
+ * Set the typing status to false if typing notifications are sent.
+ *
+ * @returns {undefined}
+ */
+ finishedComposing() {
+ if (!this.shouldSendTypingNotifications) {
+ return;
+ }
+
+ this._setTypingState(false);
+ },
+
+ /**
+ * Send the given typing state if it is not typing or alternatively not been
+ * sent in the last second.
+ *
+ * @param {boolean} isTyping - If the user is currently composing a message.
+ * @returns {undefined}
+ */
+ _setTypingState(isTyping) {
+ if (isTyping) {
+ if (this._typingDebounce) {
+ return;
+ }
+ this._typingDebounce = setTimeout(() => {
+ delete this._typingDebounce;
+ }, 1000);
+ } else if (this._typingDebounce) {
+ clearTimeout(this._typingDebounce);
+ delete this._typingDebounce;
+ }
+ this._account._client
+ .sendTyping(this._roomId, isTyping, 10000)
+ .catch(error => this._account.ERROR(error));
+ },
+ /**
+ * Cancel the typing end timer.
+ */
+ _cancelTypingTimer() {
+ if (this._typingTimer) {
+ clearTimeout(this._typingTimer);
+ delete this._typingTimer;
+ }
+ },
+
+ _cleanUpTimers() {
+ this._cancelTypingTimer();
+ if (this._typingDebounce) {
+ clearTimeout(this._typingDebounce);
+ delete this._typingDebounce;
+ }
+ },
+
+ /**
+ * Sets the containsNick flag on the message if appropriate. If an event is
+ * provided in properties, many of the message properties are set based on
+ * it here.
+ *
+ * @param {string} who - MXID that composed the message.
+ * @param {string} text - Message text.
+ * @param {object} properties - Extra attributes for the MatrixMessage.
+ */
+ createMessage(who, text, properties) {
+ if (properties.event) {
+ const actions = this._account._client.getPushActionsForEvent(
+ properties.event
+ );
+ const isOutgoing = properties.event.getSender() == this._account.userId;
+ properties.incoming = !isOutgoing;
+ properties.outgoing = isOutgoing;
+ properties._alias = properties.event.sender?.name;
+ properties.isEncrypted = properties.event.isEncrypted();
+ properties.containsNick =
+ !isOutgoing &&
+ Boolean((this.isChat && actions?.notify) || actions?.tweaks?.highlight);
+ properties.time = Math.floor(properties.event.getDate() / 1000);
+ properties._iconURL =
+ properties.event.sender?.getAvatarUrl(
+ this._account._client.getHomeserverUrl(),
+ USER_ICON_SIZE,
+ USER_ICON_SIZE,
+ "scale",
+ false
+ ) || "";
+ properties.remoteId = properties.event.getId();
+ }
+ if (this.isChat && !properties.containsNick) {
+ properties.containsNick =
+ properties.incoming && this._pingRegexp.test(text);
+ }
+ const message = new MatrixMessage(who, text, properties, this);
+ return message;
+ },
+
+ /**
+ * @param {imIMessage} msg
+ */
+ prepareForDisplaying(msg) {
+ const formattedHTML = lazy.MatrixMessageContent.getIncomingHTML(
+ msg.wrappedJSObject.prplMessage.wrappedJSObject.event,
+ this._account._client.getHomeserverUrl(),
+ eventId => this.room.findEventById(eventId)
+ );
+ if (formattedHTML) {
+ msg.displayMessage = formattedHTML;
+ }
+ GenericConversationPrototype.prepareForDisplaying.apply(this, arguments);
+ },
+
+ /**
+ * @type {Room|null}
+ */
+ get room() {
+ return this._account?._client.getRoom(this._roomId);
+ },
+ get roomState() {
+ return this.room
+ ?.getLiveTimeline()
+ .getState(lazy.MatrixSDK.EventTimeline.FORWARDS);
+ },
+ /**
+ * If we should send typing notifications to the remote server.
+ *
+ * @type {boolean}
+ */
+ get shouldSendTypingNotifications() {
+ return Services.prefs.getBoolPref("purple.conversations.im.send_typing");
+ },
+ /**
+ * The ID of the room.
+ *
+ * @type {string}
+ */
+ get normalizedName() {
+ return this._roomId;
+ },
+
+ /**
+ * Check if the type of the conversation (MUC or DM) needs to be changed and
+ * if it needs to change, update it. If the conv was replaced this will
+ * check for an update on the new conversation.
+ *
+ * @returns {Promise<void>}
+ */
+ async checkForUpdate() {
+ if (this._waitingForUpdate || this.left) {
+ return;
+ }
+ this._waitingForUpdate = true;
+ const conv = await this.waitForRoom();
+ if (conv !== this) {
+ await conv.checkForUpdate();
+ return;
+ }
+ this._waitingForUpdate = false;
+ if (this.left) {
+ return;
+ }
+ const shouldBeMuc = this.expectedToBeMuc();
+ if (shouldBeMuc === this.isChat) {
+ return;
+ }
+ this._addJoiningLock("checkForUpdate");
+ this._isChat = shouldBeMuc;
+ this.notifyObservers(null, "chat-update-type");
+ if (shouldBeMuc) {
+ await this.makeMuc();
+ } else {
+ await this.makeDm();
+ }
+ this.updateConvIcon();
+ this._releaseJoiningLock("checkForUpdate");
+ },
+
+ /**
+ * Check if the current conversation should be a MUC.
+ *
+ * @returns {boolean} If this conversation should be a MUC.
+ */
+ expectedToBeMuc() {
+ return !this._account.isDirectRoom(this._roomId);
+ },
+
+ /**
+ * Change the data in this conversation to match what we expect for a DM.
+ * This means setting a buddy and no participants.
+ */
+ async makeDm() {
+ this._participants.clear();
+ this.initRoomDm(this.room);
+ await this.updateUnverifiedDevices();
+ },
+
+ /**
+ * Change the data in this conversation to match what we expect for a MUC.
+ * This means removing the associated buddy, initializing the participants
+ * list and updating the topic.
+ */
+ async makeMuc() {
+ // Cancel any pending outgoing verification request we sent.
+ this.cleanUpOutgoingVerificationRequests();
+ this.closeDm();
+ await this.initRoomMuc(this.room);
+ },
+
+ /**
+ * Set the convIconFilename field for the conversation. Only writes to the
+ * field when the value changes.
+ */
+ updateConvIcon() {
+ const avatarUrl = this.room?.getAvatarUrl(
+ this._account._client.getHomeserverUrl(),
+ USER_ICON_SIZE,
+ USER_ICON_SIZE,
+ "scale",
+ false
+ );
+ if (avatarUrl && this.convIconFilename !== avatarUrl) {
+ this.convIconFilename = avatarUrl;
+ } else if (!avatarUrl && this.convIconFilename) {
+ this.convIconFilename = "";
+ }
+ },
+
+ // mostly copied from jsProtoHelper but made type independent
+ _convIconFilename: "",
+ get convIconFilename() {
+ // By default, pass through information from the buddy for IM conversations
+ // that don't have their own icon.
+ const convIconFilename = this._convIconFilename;
+ if (convIconFilename || this.isChat) {
+ return convIconFilename;
+ }
+ return this.buddy?.buddyIconFilename;
+ },
+ set convIconFilename(aNewFilename) {
+ this._convIconFilename = aNewFilename;
+ this.notifyObservers(this, "update-conv-icon");
+ },
+
+ /* MUC */
+
+ addParticipant(roomMember) {
+ if (this._participants.has(roomMember.userId)) {
+ return;
+ }
+
+ let participant = new MatrixParticipant(roomMember, this._account);
+ this._participants.set(roomMember.userId, participant);
+ this.notifyObservers(
+ new nsSimpleEnumerator([participant]),
+ "chat-buddy-add"
+ );
+ this.updateUnverifiedDevices();
+ },
+
+ removeParticipant(userId) {
+ if (!this._participants.has(userId)) {
+ return;
+ }
+ GenericConvChatPrototype.removeParticipant.call(this, userId);
+ this.updateUnverifiedDevices();
+ },
+
+ /**
+ * Initialize the room after the response from the Matrix client.
+ *
+ * @param {object} room - associated room with the conversation.
+ */
+ async initRoomMuc(room) {
+ let roomState = this.roomState;
+ if (roomState.getStateEvents(lazy.MatrixSDK.EventType.RoomTopic).length) {
+ let event = roomState.getStateEvents(
+ lazy.MatrixSDK.EventType.RoomTopic
+ )[0];
+ this.setTopic(event.getContent().topic, event.getSender(), true);
+ }
+
+ await room.loadMembersIfNeeded();
+ // If there are any participants, create them.
+ let participants = [];
+ room.getJoinedMembers().forEach(roomMember => {
+ if (!this._participants.has(roomMember.userId)) {
+ let participant = new MatrixParticipant(roomMember, this._account);
+ participants.push(participant);
+ this._participants.set(roomMember.userId, participant);
+ }
+ });
+ if (participants.length) {
+ this.notifyObservers(
+ new nsSimpleEnumerator(participants),
+ "chat-buddy-add"
+ );
+ }
+ },
+
+ get topic() {
+ return this._topic;
+ },
+
+ set topic(aTopic) {
+ // Check if our user has the permissions to set the topic.
+ if (this.topicSettable && aTopic !== this.topic) {
+ this._account._client.setRoomTopic(this._roomId, aTopic);
+ }
+ },
+
+ get topicSettable() {
+ if (this.room) {
+ return this.roomState.maySendEvent(
+ lazy.MatrixSDK.EventType.RoomTopic,
+ this._account.userId
+ );
+ }
+ return false;
+ },
+
+ /* DM */
+
+ /**
+ * Initialize the room after the response from the Matrix client.
+ *
+ * @param {Room} room - associated room with the conversation.
+ */
+ initRoomDm(room) {
+ const dmUserId = room.guessDMUserId();
+ if (dmUserId === this._account.userId) {
+ // We are the only member of the room that we know of.
+ // This can sometimes happen when we get a room before all membership
+ // events got synced in.
+ return;
+ }
+ if (!this.buddy) {
+ this.initBuddy(dmUserId);
+ }
+ },
+
+ /**
+ * Initialize the buddy for this conversation.
+ *
+ * @param {string} dmUserId - MXID of the user on the other side of this DM.
+ */
+ initBuddy(dmUserId) {
+ if (this._account.buddies.has(dmUserId)) {
+ this.buddy = this._account.buddies.get(dmUserId);
+ if (!this.buddy._user) {
+ const user = this._account._client.getUser(dmUserId);
+ this.buddy.setUser(user);
+ }
+ return;
+ }
+ const user = this._account._client.getUser(dmUserId);
+ this.buddy = new MatrixBuddy(
+ this._account,
+ null,
+ IMServices.tags.defaultTag,
+ user.userId
+ );
+ this.buddy.setUser(user);
+ IMServices.contacts.accountBuddyAdded(this.buddy);
+ // We can only set the status after the contacts service set the imIBuddy.
+ this.buddy.setStatusFromPresence();
+ this._account.buddies.set(dmUserId, this.buddy);
+ },
+
+ /**
+ * Searches for recent verification requests in the room history.
+ * Optimally we would instead handle verification requests with natural event
+ * backfill for the room. Until then, we search the last three days of events
+ * for verification requests.
+ */
+ async searchForVerificationRequests() {
+ // Wait for us to join the room.
+ let myMembership = this.room.getMyMembership();
+ if (myMembership === "invite") {
+ let listener;
+ try {
+ await new Promise((resolve, reject) => {
+ listener = (event, member) => {
+ if (member.userId === this._account.userId) {
+ if (member.membership === "join") {
+ resolve();
+ } else if (member.membership === "leave") {
+ reject(new Error("Not in room"));
+ }
+ }
+ };
+ this._account._client.on("RoomMember.membership", listener);
+ });
+ } catch (error) {
+ return;
+ } finally {
+ this._account._client.removeListener("RoomMember.membership", listener);
+ }
+ } else if (myMembership === "leave") {
+ return;
+ }
+ let timelineWindow = new lazy.MatrixSDK.TimelineWindow(
+ this._account._client,
+ this.room.getUnfilteredTimelineSet()
+ );
+ // Limit how far back we search. Three days seems like it would catch most
+ // relevant verification requests. We might get even older events in the
+ // initial load of 25 events.
+ const windowChunkSize = 25;
+ const threeDaysMs = 1000 * 60 * 60 * 24 * 3;
+ const newerThanMs = Date.now() - threeDaysMs;
+ await timelineWindow.load(undefined, windowChunkSize);
+ while (
+ timelineWindow.canPaginate(lazy.MatrixSDK.EventTimeline.BACKWARDS) &&
+ timelineWindow.getEvents()[0].getTs() >= newerThanMs
+ ) {
+ if (
+ !(await timelineWindow.paginate(
+ lazy.MatrixSDK.EventTimeline.BACKWARDS,
+ windowChunkSize
+ ))
+ ) {
+ // Pagination was unable to add any more events
+ break;
+ }
+ }
+ let events = timelineWindow.getEvents();
+ for (const event of events) {
+ // Find verification requests that are still in the requested state that
+ // were sent by the other user.
+ if (
+ event.getType() === lazy.MatrixSDK.EventType.RoomMessage &&
+ event.getContent().msgtype ===
+ lazy.MatrixSDK.EventType.KeyVerificationRequest &&
+ event.getSender() !== this._account.userId &&
+ event.verificationRequest?.requested
+ ) {
+ this._account.handleIncomingVerificationRequest(
+ event.verificationRequest
+ );
+ }
+ }
+ },
+
+ /**
+ * Cancel any pending outgoing verification requests. Used when we leave a
+ * DM room, when the other party leaves or when the room can no longer be
+ * considered a DM room.
+ */
+ cleanUpOutgoingVerificationRequests() {
+ const request = this._account._pendingOutgoingVerificationRequests.get(
+ this.buddy?.userName
+ );
+ if (request && request.requestEvent.getRoomId() == this._roomId) {
+ request.cancel();
+ this._account._pendingOutgoingVerificationRequests.delete(
+ this.buddy.userName
+ );
+ }
+ },
+
+ /**
+ * Clean up the buddy associated with this DM conversation if it is the last
+ * conversation associated with it.
+ */
+ closeDm() {
+ if (this.buddy) {
+ const dmUserId = this.buddy.userName;
+ const otherDMRooms = Array.from(this._account.roomList.values()).filter(
+ conv => conv.buddy && conv.buddy === this.buddy && conv !== this
+ );
+ if (otherDMRooms.length == 0) {
+ IMServices.contacts.accountBuddyRemoved(this.buddy);
+ this._account.buddies.delete(dmUserId);
+ delete this.buddy;
+ }
+ }
+ },
+
+ updateTyping: GenericConvIMPrototype.updateTyping,
+ typingState: Ci.prplIConvIM.NOT_TYPING,
+
+ _hasUnverifiedDevices: true,
+ /**
+ * Update the cached value for device trust and fire an
+ * update-conv-encryption if the value changed. We cache the unverified
+ * devices state, since the encryption state getter is sync. Does nothing if
+ * the room is not encrypted.
+ */
+ async updateUnverifiedDevices() {
+ let account = this._account;
+ if (
+ !account._client.isCryptoEnabled() ||
+ !account._client.isRoomEncrypted(this._roomId)
+ ) {
+ return;
+ }
+ const members = await this.room.getEncryptionTargetMembers();
+ // Check for participants that we haven't verified via cross signing, or
+ // of which we don't trust a device, and if everyone seems fine, check our
+ // own device verification state.
+ let newValue =
+ members.some(({ userId }) => {
+ return !userIdentityVerified(userId, account._client);
+ }) || checkUserHasUnverifiedDevices(account.userId, account._client);
+ if (this._hasUnverifiedDevices !== newValue) {
+ this._hasUnverifiedDevices = newValue;
+ this.notifyObservers(this, "update-conv-encryption");
+ }
+ },
+ get encryptionState() {
+ if (
+ !this._account._client.isCryptoEnabled() ||
+ (!this._account._client.isRoomEncrypted(this._roomId) &&
+ !this.room?.currentState.mayClientSendStateEvent(
+ lazy.MatrixSDK.EventType.RoomEncryption,
+ this._account._client
+ ))
+ ) {
+ return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED;
+ }
+ if (!this._account._client.isRoomEncrypted(this._roomId)) {
+ return Ci.prplIConversation.ENCRYPTION_AVAILABLE;
+ }
+ if (this._hasUnverifiedDevices) {
+ return Ci.prplIConversation.ENCRYPTION_ENABLED;
+ }
+ return Ci.prplIConversation.ENCRYPTION_TRUSTED;
+ },
+ initializeEncryption() {
+ if (this._account._client.isRoomEncrypted(this._roomId)) {
+ return;
+ }
+ this._account._client.sendStateEvent(
+ this._roomId,
+ lazy.MatrixSDK.EventType.RoomEncryption,
+ {
+ algorithm: lazy.OlmLib.MEGOLM_ALGORITHM,
+ }
+ );
+ },
+};
+
+/**
+ * Initialize the verification, choosing the challenge method and calculating
+ * the challenge string and description.
+ *
+ * @param {VerificationRequest} request - Matrix SDK verification request.
+ * @returns {Promise<{ challenge: string, challengeDescription: string?, handleResult: (boolean) => {}, cancel: () => {}, cancelPromise: Promise}}
+ */
+async function startVerification(request) {
+ if (!request.verifier) {
+ if (!request.initiatedByMe) {
+ await request.accept();
+ if (request.cancelled) {
+ throw new Error("verification aborted");
+ }
+ // Auto chose method as the only one we both support.
+ await request.beginKeyVerification(
+ request.methods[0],
+ request.targetDevice
+ );
+ } else {
+ await request.waitFor(() => request.started || request.cancelled);
+ }
+ if (request.cancelled) {
+ throw new Error("verification aborted");
+ }
+ }
+ const sasEventPromise = new Promise(resolve =>
+ request.verifier.once(lazy.MatrixSDK.Crypto.VerifierEvent.ShowSas, resolve)
+ );
+ request.verifier.verify();
+ const sasEvent = await sasEventPromise;
+ if (request.cancelled) {
+ throw new Error("verification aborted");
+ }
+ let challenge = "";
+ let challengeDescription;
+ if (sasEvent.sas.emoji) {
+ challenge = sasEvent.sas.emoji.map(emoji => emoji[0]).join(" ");
+ challengeDescription = sasEvent.sas.emoji.map(emoji => emoji[1]).join(" ");
+ } else if (sasEvent.sas.decimal) {
+ challenge = sasEvent.sas.decimal.join(" ");
+ } else {
+ sasEvent.cancel();
+ throw new Error("unknown verification method");
+ }
+ return {
+ challenge,
+ challengeDescription,
+ handleResult(challengeMatches) {
+ if (!challengeMatches) {
+ sasEvent.mismatch();
+ } else {
+ sasEvent.confirm();
+ }
+ },
+ cancel() {
+ if (!request.cancelled) {
+ sasEvent.cancel();
+ }
+ },
+ cancelPromise: request.waitFor(() => request.cancelled),
+ };
+}
+
+/**
+ * @param {prplIAccount} account - Matrix account this session is associated with.
+ * @param {string} ownerId - Matrix ID that this session is from.
+ * @param {DeviceInfo} deviceInfo - Session device info.
+ */
+function MatrixSession(account, ownerId, deviceInfo) {
+ this._deviceInfo = deviceInfo;
+ this._ownerId = ownerId;
+ let id = deviceInfo.deviceId;
+ if (deviceInfo.getDisplayName()) {
+ id = lazy._("options.encryption.session", id, deviceInfo.getDisplayName());
+ }
+ const deviceTrust = account._client.checkDeviceTrust(
+ ownerId,
+ deviceInfo.deviceId
+ );
+ const isCurrentDevice = deviceInfo.deviceId === account._client.getDeviceId();
+
+ this._init(
+ account,
+ id,
+ deviceTrust.isCrossSigningVerified(),
+ isCurrentDevice
+ );
+}
+MatrixSession.prototype = {
+ __proto__: GenericSessionPrototype,
+ _deviceInfo: null,
+ async _startVerification() {
+ let request;
+ const requestKey = this.currentSession
+ ? this._ownerId
+ : this._deviceInfo.deviceId;
+ if (this._account._pendingOutgoingVerificationRequests.has(requestKey)) {
+ throw new Error(
+ "Already have a pending verification request for " + requestKey
+ );
+ }
+ if (this.currentSession) {
+ request = await this._account._client.requestVerification(this._ownerId);
+ } else {
+ request = await this._account._client.requestVerification(this._ownerId, [
+ this._deviceInfo.deviceId,
+ ]);
+ }
+ this._account.trackOutgoingVerificationRequest(request, requestKey);
+ return startVerification(request);
+ },
+};
+
+function getStatusString(status) {
+ return status
+ ? lazy._("options.encryption.statusOk")
+ : lazy._("options.encryption.statusNotOk");
+}
+
+/**
+ * Get the conversation name to display for a room.
+ *
+ * @param {string} roomId - ID of the room.
+ * @param {RoomNameState} state - State of the room name from the SDK.
+ * @returns {string?} Name to show for the room. If nothing is returned, the SDK
+ * uses its built in naming logic.
+ */
+function getRoomName(roomId, state) {
+ switch (state.type) {
+ case lazy.MatrixSDK.RoomNameType.Actual:
+ return state.name;
+ case lazy.MatrixSDK.RoomNameType.Generated: {
+ if (!state.names) {
+ return lazy.l10n.formatValueSync("room-name-empty");
+ }
+ if (state.names.length === 1 && state.count <= 2) {
+ return state.names[0];
+ }
+ if (state.names.length === 2 && state.count <= 3) {
+ return new Intl.ListFormat(undefined, {
+ style: "long",
+ type: "conjunction",
+ }).format(state.names);
+ }
+ return lazy.l10n.formatValueSync("room-name-others2", {
+ participant: state.names[0],
+ otherParticipantCount: state.names.length - 1,
+ });
+ }
+ case lazy.MatrixSDK.RoomNameType.EmptyRoom:
+ if (state.oldName) {
+ return lazy.l10n.formatValueSync("room-name-empty-had-name", {
+ oldName: state.oldName,
+ });
+ }
+ return lazy.l10n.formatValueSync("room-name-empty");
+ }
+ // Else fall through to default SDK room naming logic.
+ return null;
+}
+
+/*
+ * TODO Other random functionality from MatrixClient that will be useful:
+ * getRooms / getUsers / publicRooms
+ * invite
+ * ban / kick
+ * redactEvent
+ * scrollback
+ * setAvatarUrl
+ * setPassword
+ */
+export function MatrixAccount(aProtocol, aImAccount) {
+ this._init(aProtocol, aImAccount);
+ this.roomList = new Map();
+ this._userToRoom = {};
+ this.buddies = new Map();
+ this._pendingDirectChats = new Map();
+ this._pendingRoomAliases = new Map();
+ this._pendingRoomInvites = new Set();
+ this._pendingOutgoingVerificationRequests = new Map();
+ this._failedEvents = new Set();
+ this._verificationRequestTimeouts = new Set();
+}
+
+MatrixAccount.prototype = {
+ __proto__: GenericAccountPrototype,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic === "status-changed") {
+ this.setPresence(aSubject);
+ } else if (aTopic === "user-display-name-changed") {
+ this._client.setDisplayName(aData);
+ }
+ },
+ remove() {
+ for (let conv of this.roomList.values()) {
+ // We want to remove all the conversations. We are not using conv.close
+ // function call because we don't want user to leave all the matrix rooms.
+ // User just want to remove the account so we need to remove the listed
+ // conversations.
+ conv.forget();
+ conv._cleanUpTimers();
+ }
+ delete this.roomList;
+ for (let timeout of this._verificationRequestTimeouts) {
+ clearTimeout(timeout);
+ }
+ this._verificationRequestTimeouts.clear();
+ // Cancel all pending outgoing verification requests, as we can no longer handle them.
+ let pendingClientOperations = Promise.all(
+ Array.from(
+ this._pendingOutgoingVerificationRequests.values(),
+ request => {
+ return request.cancel().catch(error => this.ERROR(error));
+ }
+ )
+ ).then(() => {
+ this._pendingOutgoingVerificationRequests.clear();
+ });
+ // We want to clear data stored for syncing in indexedDB so when
+ // user logins again, one gets the fresh start.
+ if (this._client) {
+ if (this._client.isLoggedIn()) {
+ pendingClientOperations = pendingClientOperations.then(() =>
+ this._client.logout()
+ );
+ }
+ pendingClientOperations.finally(() => {
+ this._client.clearStores();
+ });
+ } else {
+ // Without client we can still clear the stores at least.
+ pendingClientOperations.finally(async () => {
+ // getClientOptions wipes the session storage.
+ const opts = await this.getClientOptions();
+ opts.store.deleteAllData();
+ opts.cryptoStore.deleteAllData();
+ });
+ }
+ },
+ unInit() {
+ if (this.roomList) {
+ for (let conv of this.roomList.values()) {
+ conv._cleanUpTimers();
+ }
+ }
+ for (let timeout of this._verificationRequestTimeouts) {
+ clearTimeout(timeout);
+ }
+ // Cancel all pending outgoing verification requests, as we can no longer handle them.
+ let pendingClientOperations = Promise.all(
+ Array.from(
+ this._pendingOutgoingVerificationRequests.values(),
+ request => {
+ return request.cancel().catch(error => this.ERROR(error));
+ }
+ )
+ );
+ if (this._client) {
+ pendingClientOperations.finally(() => {
+ // Avoid sending connection status changes.
+ this._client.removeAllListeners(lazy.MatrixSDK.ClientEvent.Sync);
+ this._client.stopClient();
+ });
+ }
+ },
+ connect() {
+ this.reportConnecting();
+ this.connectClient().catch(error => {
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ error.message
+ );
+ this.reportDisconnected();
+ });
+ },
+ async connectClient() {
+ this._baseURL = await this.getServer();
+
+ let deviceId = this.prefs.getStringPref("deviceId", "") || undefined;
+ let accessToken = this.prefs.getStringPref("accessToken", "") || undefined;
+ // Make sure accessToken saved as deviceId is disposed of.
+ if (deviceId && deviceId === accessToken) {
+ // Revoke accessToken stored in deviceId
+ const tempClient = lazy.MatrixSDK.createClient({
+ useAuthorizationHeader: true,
+ baseUrl: this._baseURL,
+ accessToken: deviceId,
+ });
+ if (tempClient.isLoggedIn()) {
+ tempClient.logout();
+ }
+ this.prefs.clearUserPref("deviceId");
+ this.prefs.clearUserPref("accessToken");
+ deviceId = undefined;
+ accessToken = undefined;
+ }
+
+ // Ensure any existing client will no longer interact with the network and
+ // this account instance. A client will already exist whenever the account
+ // is reconnected via the chat account connection management, or when we
+ // have to create a new client to handle a new indexedDB schema.
+ if (this._client) {
+ this._client.stopClient();
+ this._client.removeAllListeners();
+ }
+
+ const opts = await this.getClientOptions();
+ this._client = lazy.MatrixSDK.createClient(opts);
+ if (this._client.isLoggedIn()) {
+ this.startClient();
+ return;
+ }
+ const { flows } = await this._client.loginFlows();
+ const usePasswordFlow = Boolean(this.imAccount.password);
+ let wantedFlows = [];
+ if (usePasswordFlow) {
+ wantedFlows.push("m.login.password");
+ } else {
+ wantedFlows.push("m.login.sso", "m.login.token");
+ }
+ if (
+ wantedFlows.every(flowType => flows.some(flow => flow.type === flowType))
+ ) {
+ if (usePasswordFlow) {
+ let user = this.name;
+ // extract user localpart in case server is not the canonical one for the matrix ID.
+ if (this.nameIsMXID) {
+ user = this.protocol.splitUsername(user)[0];
+ }
+ await this.loginToClient("m.login.password", {
+ identifier: {
+ type: "m.id.user",
+ user,
+ },
+ password: this.imAccount.password,
+ });
+ } else {
+ this.requestAuthorization();
+ }
+ } else {
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.noSupportedFlow")
+ );
+ this.reportDisconnected();
+ }
+ },
+
+ /**
+ * Run autodiscovery to find the matrix server base URL for the account.
+ * For accounts created before the username split was implemented, we will
+ * most likely use the server preference that was set during setup.
+ * All other accounts that have a full MXID as identifier will use the host
+ * from the MXID as start for the auto discovery.
+ *
+ * @returns {string} Matrix server base URL.
+ * @throws {Error} When the autodiscovery failed.
+ */
+ async getServer() {
+ let domain = "matrix.org";
+ if (this.nameIsMXID) {
+ domain = this.protocol.splitUsername(this.name)[1];
+ } else if (this.prefs.prefHasUserValue("server")) {
+ // Use legacy server field
+ return (
+ this.prefs.getStringPref("server") +
+ ":" +
+ this.prefs.getIntPref("port", 443)
+ );
+ }
+ let discoveredInfo = await lazy.MatrixSDK.AutoDiscovery.findClientConfig(
+ domain
+ );
+ let homeserverResult = discoveredInfo[HOMESERVER_WELL_KNOWN];
+
+ // If the well-known lookup fails, pretend the domain has a well-known for
+ // itself.
+ if (homeserverResult.state !== lazy.MatrixSDK.AutoDiscovery.SUCCESS) {
+ discoveredInfo = await lazy.MatrixSDK.AutoDiscovery.fromDiscoveryConfig({
+ [HOMESERVER_WELL_KNOWN]: {
+ base_url: `https://${domain}`,
+ },
+ });
+ homeserverResult = discoveredInfo[HOMESERVER_WELL_KNOWN];
+ }
+ if (homeserverResult.state === lazy.MatrixSDK.AutoDiscovery.PROMPT) {
+ throw new Error(lazy._("connection.error.serverNotFound"));
+ }
+ if (homeserverResult.state !== lazy.MatrixSDK.AutoDiscovery.SUCCESS) {
+ //TODO these are English strings generated by the SDK.
+ throw new Error(homeserverResult.error);
+ }
+ return homeserverResult.base_url;
+ },
+
+ /**
+ * If the |name| property of this account looks like a valid Matrix ID.
+ *
+ * @type {boolean}
+ */
+ get nameIsMXID() {
+ return (
+ this.name[0] === this.protocol.usernamePrefix &&
+ this.name.includes(this.protocol.usernameSplits[0].separator)
+ );
+ },
+
+ /**
+ * Error displayed to the user if there is some user-action required for the
+ * encryption setup.
+ */
+ _encryptionError: "",
+
+ /**
+ * Builds the options for the |createClient| call to the SDK including all
+ * stores.
+ *
+ * @returns {Promise<object>}
+ */
+ async getClientOptions() {
+ let dbName = "chat:matrix:" + this.imAccount.id;
+
+ const opts = {
+ useAuthorizationHeader: true,
+ baseUrl: this._baseURL,
+ store: new lazy.MatrixSDK.IndexedDBStore({
+ indexedDB,
+ dbName,
+ }),
+ cryptoStore: new lazy.MatrixSDK.IndexedDBCryptoStore(
+ indexedDB,
+ dbName + ":crypto"
+ ),
+ deviceId: this.prefs.getStringPref("deviceId", "") || undefined,
+ accessToken: this.prefs.getStringPref("accessToken", "") || undefined,
+ userId: this.prefs.getStringPref("userId", "") || undefined,
+ timelineSupport: true,
+ cryptoCallbacks: {
+ getSecretStorageKey: async ({ keys }) => {
+ const backupPassphrase = this.getString("backupPassphrase");
+ if (!backupPassphrase) {
+ this.WARN("Missing secret storage key");
+ this._encryptionError = lazy._(
+ "options.encryption.needBackupPassphrase"
+ );
+ await this.updateEncryptionStatus();
+ return null;
+ }
+ let keyId = await this._client.getDefaultSecretStorageKeyId();
+ if (keyId && !keys[keyId]) {
+ keyId = undefined;
+ }
+ if (!keyId) {
+ keyId = keys[0][0];
+ }
+ const backupInfo = await this._client.getKeyBackupVersion();
+ const key = await this._client.keyBackupKeyFromPassword(
+ backupPassphrase,
+ backupInfo
+ );
+ return [keyId, key];
+ },
+ },
+ verificationMethods: [lazy.MatrixCrypto.verificationMethods.SAS],
+ roomNameGenerator: getRoomName,
+ };
+ await Promise.all([opts.store.startup(), opts.cryptoStore.startup()]);
+ return opts;
+ },
+
+ /**
+ * Log the client in. Sets the session device display name if configured and
+ * stores the session information on successful login.
+ *
+ * @param {string} loginType - The m.login.* flow to use.
+ * @param {object} loginInfo - Params for the login flow.
+ * @param {boolean} [retry=false] - If we should retry SSO if the error isn't failed auth.
+ */
+ async loginToClient(loginType, loginInfo, retry = false) {
+ try {
+ if (this.getString("deviceDisplayName")) {
+ loginInfo.initial_device_display_name =
+ this.getString("deviceDisplayName");
+ }
+ const data = await this._client.login(loginType, loginInfo);
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ if (data.well_known?.[HOMESERVER_WELL_KNOWN]?.base_url) {
+ this._baseURL = data.well_known[HOMESERVER_WELL_KNOWN].base_url;
+ }
+ this.storeSessionInformation(data);
+ // Need to create a new client with the device ID set.
+ const opts = await this.getClientOptions();
+ this._client.stopClient();
+ this._client = lazy.MatrixSDK.createClient(opts);
+ if (!this._client.isLoggedIn()) {
+ throw new Error("Client has no access token after login");
+ }
+ this.startClient();
+ } catch (error) {
+ let errorType = Ci.prplIAccount.ERROR_OTHER_ERROR;
+ if (error.errcode === "M_FORBIDDEN") {
+ errorType = Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED;
+ }
+ this.reportDisconnecting(errorType, error.message);
+ this.reportDisconnected();
+ if (errorType !== Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED && retry) {
+ this.requestAuthorization();
+ }
+ }
+ },
+
+ /**
+ * Login to the homeserver using m.login.token.
+ *
+ * @param {string} token - The auth token received from the SSO flow.
+ */
+ loginWithToken(token) {
+ return this.loginToClient("m.login.token", { token }, true);
+ },
+
+ /**
+ * Show SSO prompt and handle response token.
+ */
+ requestAuthorization() {
+ this.reportConnecting(lazy._("connection.requestAuth"));
+ let url = this._client.getSsoLoginUrl(
+ lazy.InteractiveBrowser.COMPLETION_URL,
+ "sso"
+ );
+ lazy.InteractiveBrowser.waitForRedirect(
+ url,
+ `${this.name} - ${this._baseURL}`
+ )
+ .then(resultUrl => {
+ let parsedUrl = new URL(resultUrl);
+ let rawUrlData = parsedUrl.searchParams;
+ let urlData = new URLSearchParams(rawUrlData);
+ if (!urlData.has("loginToken")) {
+ throw new Error("No token in redirect");
+ }
+
+ this.reportConnecting(lazy._("connection.requestAccess"));
+ this.loginWithToken(urlData.get("loginToken"));
+ })
+ .catch(() => {
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.authCancelled")
+ );
+ this.reportDisconnected();
+ });
+ },
+
+ /**
+ * Stores the device ID and if enabled the access token in the account preferences, so they can be
+ * re-used in the next Thunderbird session.
+ *
+ * @param {object} data - Response data from a matrix login request.
+ */
+ storeSessionInformation(data) {
+ if (this.getBool("saveToken")) {
+ this.prefs.setStringPref("accessToken", data.access_token);
+ }
+ this.prefs.setStringPref("deviceId", data.device_id);
+ this.prefs.setStringPref("userId", data.user_id);
+ },
+
+ get _catchingUp() {
+ return this._client?.getSyncState() !== lazy.SyncState.Syncing;
+ },
+
+ /**
+ * Set of event IDs for events that have failed to send. Used to avoid
+ * showing an error after resending a message fails again.
+ *
+ * @type {Set<string>}
+ */
+ _failedEvents: null,
+
+ /*
+ * Hook up the Matrix Client to callbacks to handle various events.
+ *
+ * The possible events are documented starting at:
+ * https://matrix-org.github.io/matrix-js-sdk/2.4.1/module-client.html#~event:MatrixClient%22accountData%22
+ */
+ startClient() {
+ this._client.on(
+ lazy.MatrixSDK.ClientEvent.Sync,
+ (state, prevState, data) => {
+ switch (state) {
+ case lazy.SyncState.Prepared:
+ if (prevState !== state) {
+ this.setPresence(this.imAccount.statusInfo);
+ }
+ this.reportConnected();
+ break;
+ case lazy.SyncState.Stopped:
+ this.reportDisconnected();
+ break;
+ case lazy.SyncState.Syncing:
+ if (prevState !== state) {
+ this.reportConnected();
+ this.handleCaughtUp();
+ }
+ break;
+ case lazy.SyncState.Reconnecting:
+ this.reportConnecting();
+ break;
+ case lazy.SyncState.Error:
+ if (
+ data.error.reason ==
+ lazy.MatrixSDK.InvalidStoreError.TOGGLED_LAZY_LOADING
+ ) {
+ this._client.store.deleteAllData().then(() => this.connect());
+ break;
+ }
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ data.error.message
+ );
+ this.reportDisconnected();
+ break;
+ case lazy.SyncState.Catchup:
+ this.reportConnecting();
+ break;
+ }
+ }
+ );
+ this._client.on(
+ lazy.MatrixSDK.RoomMemberEvent.Membership,
+ (event, member, oldMembership) => {
+ if (this._catchingUp) {
+ return;
+ }
+ if (this.roomList.has(member.roomId)) {
+ let conv = this.roomList.get(member.roomId);
+ if (conv.isChat) {
+ if (member.membership === "join") {
+ conv.addParticipant(member);
+ } else if (member.membership === "leave") {
+ conv.removeParticipant(member.userId);
+ }
+ }
+ // If we are leaving the room, remove the conversation. If any user gets
+ // added or removed in the direct chat, update the conversation type. We
+ // are treating the direct chat with two people as a direct conversation
+ // only. Matrix supports multiple users in the direct chat. So we will
+ // treat all the rooms which have 2 users including us and classified as
+ // a DM room by SDK a direct conversation and all other rooms as a group
+ // conversations.
+ if (member.membership === "leave" && member.userId == this.userId) {
+ conv.forget();
+ } else if (
+ member.membership === "join" ||
+ member.membership === "leave"
+ ) {
+ conv.checkForUpdate();
+ }
+ }
+ }
+ );
+
+ /*
+ * Get the map of direct messaging rooms.
+ */
+ this._client.on(lazy.MatrixSDK.ClientEvent.AccountData, event => {
+ if (event.getType() == lazy.MatrixSDK.EventType.Direct) {
+ const oldRooms = Object.values(this._userToRoom ?? {}).flat();
+ this._userToRoom = event.getContent();
+ // Check type for all conversations that were added or removed from the
+ // m.direct state.
+ const newRooms = Object.values(this._userToRoom ?? {}).flat();
+ for (const roomId of oldRooms) {
+ if (!newRooms.includes(roomId)) {
+ this.roomList.get(roomId)?.checkForUpdate();
+ }
+ }
+ for (const roomId of newRooms) {
+ if (!oldRooms.includes(roomId)) {
+ this.roomList.get(roomId)?.checkForUpdate();
+ }
+ }
+ }
+ });
+
+ this._client.on(
+ lazy.MatrixSDK.RoomEvent.Timeline,
+ (event, room, toStartOfTimeline, removed, data) => {
+ if (this._catchingUp || room.isSpaceRoom() || !data?.liveEvent) {
+ return;
+ }
+ let conv = this.roomList.get(room.roomId);
+ if (!conv) {
+ // If our membership changed to join without us knowing about the
+ // room, another client probably accepted an invite.
+ if (
+ event.getType() == lazy.MatrixSDK.EventType.RoomMember &&
+ event.target.userId == this.userId &&
+ event.getContent().membership == "join" &&
+ event.getPrevContent()?.membership == "invite"
+ ) {
+ if (event.getPrevContent()?.is_direct) {
+ let userId = room.getDMInviter();
+ if (this._pendingRoomInvites.has(room.roomId)) {
+ this.cancelBuddyRequest(userId);
+ this._pendingRoomInvites.delete(room.roomId);
+ }
+ conv = this.getDirectConversation(userId, room.roomId, room.name);
+ } else {
+ if (this._pendingRoomInvites.has(room.roomId)) {
+ let alias = room.getCanonicalAlias() ?? room.roomId;
+ this.cancelChatRequest(alias);
+ this._pendingRoomInvites.delete(room.roomId);
+ }
+ conv = this.getGroupConversation(room.roomId, room.name);
+ }
+ } else {
+ return;
+ }
+ }
+ conv.addEvent(event);
+ }
+ );
+ // Queued, sending and failed events
+ this._client.on(
+ lazy.MatrixSDK.RoomEvent.LocalEchoUpdated,
+ (event, room, oldEventId, oldStatus) => {
+ if (
+ this._catchingUp ||
+ room.isSpaceRoom() ||
+ event.getType() !== lazy.MatrixSDK.EventType.RoomMessage
+ ) {
+ return;
+ }
+ const conv = this.roomList.get(room.roomId);
+ if (!conv) {
+ return;
+ }
+ if (event.status === lazy.MatrixSDK.EventStatus.NOT_SENT) {
+ if (this._failedEvents.has(event.getId())) {
+ return;
+ }
+ this._failedEvents.add(event.getId());
+ conv.writeMessage(
+ this._roomId,
+ lazy._("error.sendMessageFailed", event.getContent().body),
+ {
+ error: true,
+ system: true,
+ event,
+ }
+ );
+ } else if (
+ (event.status === lazy.MatrixSDK.EventStatus.SENT ||
+ event.status === null) &&
+ oldEventId
+ ) {
+ this._failedEvents.delete(oldEventId);
+ conv.removeMessage(oldEventId);
+ } else if (event.status === lazy.MatrixSDK.EventStatus.CANCELLED) {
+ this._failedEvents.delete(event.getId());
+ conv.removeMessage(event.getId());
+ }
+ }
+ );
+ // An event that was already in the room timeline was redacted
+ this._client.on(lazy.MatrixSDK.RoomEvent.Redaction, (event, room) => {
+ let conv = this.roomList.get(room.roomId);
+ if (conv) {
+ const redactedEvent = conv.room?.findEventById(event.getAssociatedId());
+ if (redactedEvent) {
+ conv.addEvent(redactedEvent);
+ }
+ }
+ });
+ // Update the chat participant information.
+ this._client.on(
+ lazy.MatrixSDK.RoomMemberEvent.Name,
+ this.updateRoomMember.bind(this)
+ );
+ this._client.on(
+ lazy.MatrixSDK.RoomMemberEvent.PowerLevel,
+ this.updateRoomMember.bind(this)
+ );
+
+ this._client.on(lazy.MatrixSDK.RoomEvent.Name, room => {
+ if (room.isSpaceRoom()) {
+ return;
+ }
+ // Update the title to the human readable version.
+ let conv = this.roomList.get(room.roomId);
+ if (!this._catchingUp && conv && room?.name && conv._name != room.name) {
+ conv._name = room.name;
+ conv.notifyObservers(null, "update-conv-title");
+ }
+ });
+
+ /*
+ * We show invitation notifications for rooms where the membership is
+ * invite. This will also be fired for all the rooms we have joined
+ * earlier when SDK gets connected. We will use that part to to make
+ * conversations, direct or group.
+ */
+ this._client.on(lazy.MatrixSDK.ClientEvent.Room, room => {
+ if (this._catchingUp || room.isSpaceRoom()) {
+ return;
+ }
+ let me = room.getMember(this.userId);
+ if (me?.membership == "invite") {
+ if (me.events.member.getContent().is_direct) {
+ this.invitedToDM(room);
+ } else {
+ this.invitedToChat(room);
+ }
+ } else if (me?.membership == "join") {
+ // To avoid the race condition. Whenever we will create the room,
+ // this will also be fired. So we want to avoid creating duplicate
+ // conversations for the same room.
+ if (
+ this.roomList.has(room.roomId) ||
+ this._pendingRoomAliases.size + this._pendingDirectChats.size > 0
+ ) {
+ return;
+ }
+ // Joined a new room that we don't know about yet.
+ if (this.isDirectRoom(room.roomId)) {
+ let interlocutorId;
+ for (let roomMember of room.getJoinedMembers()) {
+ if (roomMember.userId != this.userId) {
+ interlocutorId = roomMember.userId;
+ break;
+ }
+ }
+ this.getDirectConversation(interlocutorId, room.roomId, room.name);
+ } else {
+ this.getGroupConversation(room.roomId, room.name);
+ }
+ }
+ });
+
+ this._client.on(lazy.MatrixSDK.RoomMemberEvent.Typing, (event, member) => {
+ if (member.userId != this.userId) {
+ let conv = this.roomList.get(member.roomId);
+ if (!conv) {
+ return;
+ }
+ if (!conv.isChat) {
+ let typingState = Ci.prplIConvIM.NOT_TYPING;
+ if (member.typing) {
+ typingState = Ci.prplIConvIM.TYPING;
+ }
+ conv.updateTyping(typingState, member.name);
+ }
+ }
+ });
+
+ this._client.on(
+ lazy.MatrixSDK.RoomStateEvent.Members,
+ (event, state, member) => {
+ if (this.roomList.has(state.roomId)) {
+ const conversation = this.roomList.get(state.roomId);
+ if (conversation.isChat) {
+ const participant = conversation._participants.get(member.userId);
+ if (participant) {
+ conversation.notifyObservers(participant, "chat-buddy-update");
+ }
+ }
+ }
+ }
+ );
+
+ this._client.on(lazy.MatrixSDK.HttpApiEvent.SessionLoggedOut, error => {
+ this.prefs.clearUserPref("accessToken");
+ // https://spec.matrix.org/unstable/client-server-api/#soft-logout
+ if (!error.data.soft_logout) {
+ this.prefs.clearUserPref("deviceId");
+ this.prefs.clearUserPref("userId");
+ }
+ // TODO handle soft logout with an auto reconnect
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("connection.error.sessionEnded")
+ );
+ this.reportDisconnected();
+ });
+
+ this._client.on(
+ lazy.MatrixSDK.UserEvent.AvatarUrl,
+ this.updateBuddy.bind(this)
+ );
+ this._client.on(
+ lazy.MatrixSDK.UserEvent.DisplayName,
+ this.updateBuddy.bind(this)
+ );
+ this._client.on(
+ lazy.MatrixSDK.UserEvent.Presence,
+ this.updateBuddy.bind(this)
+ );
+ this._client.on(
+ lazy.MatrixSDK.UserEvent.CurrentlyActive,
+ this.updateBuddy.bind(this)
+ );
+
+ this._client.on(
+ lazy.MatrixSDK.CryptoEvent.UserTrustStatusChanged,
+ (userId, trustLevel) => {
+ this.updateConvDeviceTrust(
+ conv =>
+ (conv.isChat && conv.getParticipant(userId)) ||
+ (!conv.isChat && conv.buddy?.userName == userId)
+ );
+ }
+ );
+
+ this._client.on(lazy.MatrixSDK.CryptoEvent.DevicesUpdated, users => {
+ if (users.includes(this.userId)) {
+ this.reportSessionsChanged();
+ this.updateEncryptionStatus();
+ this.updateConvDeviceTrust();
+ } else {
+ this.updateConvDeviceTrust(conv =>
+ users.some(
+ userId =>
+ (conv.isChat && conv.getParticipant(userId)) ||
+ (!conv.isChat && conv.buddy?.userName == userId)
+ )
+ );
+ }
+ });
+
+ // From the SDK documentation: Fires when the user's cross-signing keys
+ // have changed or cross-signing has been enabled/disabled
+ this._client.on(lazy.MatrixSDK.CryptoEvent.KeysChanged, () => {
+ this.reportSessionsChanged();
+ this.updateEncryptionStatus();
+ this.updateConvDeviceTrust();
+ });
+ this._client.on(lazy.MatrixSDK.CryptoEvent.KeyBackupStatus, () => {
+ this.bootstrapSSSS();
+ this.updateEncryptionStatus();
+ });
+
+ this._client.on(lazy.MatrixSDK.CryptoEvent.VerificationRequest, request => {
+ this.handleIncomingVerificationRequest(request);
+ });
+
+ // TODO Other events to handle:
+ // Room.localEchoUpdated
+ // Room.tags
+ // crypto.suggestKeyRestore
+ // crypto.warning
+
+ this._client
+ .initCrypto()
+ .then(() =>
+ Promise.all([
+ this._client.startClient({
+ pendingEventOrdering: lazy.MatrixSDK.PendingEventOrdering.Detached,
+ lazyLoadMembers: true,
+ }),
+ this.updateEncryptionStatus(),
+ this.bootstrapSSSS(),
+ this.reportSessionsChanged(),
+ ])
+ )
+ .finally(() => {
+ // We can disable the unknown devices error thanks to cross signing.
+ this._client.setGlobalErrorOnUnknownDevices(false);
+ })
+ .catch(error => this.ERROR(error));
+ },
+
+ /**
+ * Update UI state to reflect the current state of the SDK after a full sync.
+ * This includes adding and removing rooms and catching up their contents.
+ */
+ async handleCaughtUp() {
+ const allRooms = this._client
+ .getVisibleRooms()
+ .filter(room => !room.isSpaceRoom());
+ const joinedRooms = allRooms
+ .filter(room => room.getMyMembership() === "join")
+ .map(room => room.roomId);
+ // Ensure existing conversations are up to date
+ for (const [roomId, conv] of this.roomList.entries()) {
+ if (!joinedRooms.includes(roomId)) {
+ conv.forget();
+ } else {
+ try {
+ await conv.checkForUpdate();
+ await conv.catchup();
+ } catch (error) {
+ this.ERROR(error);
+ }
+ }
+ }
+ // Create new conversations
+ for (const roomId of joinedRooms) {
+ if (!this.roomList.has(roomId)) {
+ let conv;
+ if (this.isDirectRoom(roomId)) {
+ const room = this._client.getRoom(roomId);
+ if (this._pendingRoomInvites.has(roomId)) {
+ let userId = room.getDMInviter();
+ this.cancelBuddyRequest(userId);
+ this._pendingRoomInvites.delete(roomId);
+ }
+ const interlocutorId = room
+ .getJoinedMembers()
+ .find(member => member.userId != this.userId)?.userId;
+ if (!interlocutorId) {
+ this.ERROR(
+ "Could not find opposing party for " +
+ roomId +
+ ". No conversation was created."
+ );
+ continue;
+ }
+ conv = this.getDirectConversation(interlocutorId, roomId, room.name);
+ } else {
+ if (this._pendingRoomInvites.has(roomId)) {
+ const room = this._client.getRoom(roomId);
+ let alias = room.getCanonicalAlias() ?? roomId;
+ this.cancelChatRequest(alias);
+ this._pendingRoomInvites.delete(roomId);
+ }
+ conv = this.getGroupConversation(roomId);
+ }
+ try {
+ await conv.catchup();
+ } catch (error) {
+ this.ERROR(error);
+ }
+ }
+ }
+ // Add pending invites
+ const invites = allRooms.filter(
+ room => room.getMyMembership() === "invite"
+ );
+ for (const room of invites) {
+ const me = room.getMember(this.userId);
+ if (me.events.member.getContent().is_direct) {
+ this.invitedToDM(room);
+ } else {
+ this.invitedToChat(room);
+ }
+ }
+ // Remove orphaned buddies.
+ for (const [userId, buddy] of this.buddies) {
+ // getDMRoomIdsForUserId uses the room list from the client, so we don't
+ // have to wait for the room mutations above to propagate to our internal
+ // state.
+ if (this.getDMRoomIdsForUserId(userId).length === 0) {
+ buddy.remove();
+ }
+ }
+ },
+
+ /**
+ * Update the encryption status message based on the current state.
+ */
+ async updateEncryptionStatus() {
+ const secretStorageReady = await this._client.isSecretStorageReady();
+ const crossSigningReady = await this._client.isCrossSigningReady();
+ const keyBackupReady = this._client.getKeyBackupEnabled();
+ const statuses = [
+ lazy._(
+ "options.encryption.enabled",
+ getStatusString(this._client.isCryptoEnabled())
+ ),
+ lazy._(
+ "options.encryption.secretStorage",
+ getStatusString(secretStorageReady)
+ ),
+ lazy._("options.encryption.keyBackup", getStatusString(keyBackupReady)),
+ lazy._(
+ "options.encryption.crossSigning",
+ getStatusString(crossSigningReady)
+ ),
+ ];
+ if (this._encryptionError) {
+ statuses.push(this._encryptionError);
+ } else if (!secretStorageReady) {
+ statuses.push(lazy._("options.encryption.setUpSecretStorage"));
+ } else if (!keyBackupReady && !crossSigningReady) {
+ statuses.push(lazy._("options.encryption.setUpBackupAndCrossSigning"));
+ }
+ this.encryptionStatus = statuses;
+ },
+
+ /**
+ * Ensures secret storage and cross signing are ready for use. Does not
+ * support initial setup of secret storage. If the backup passphrase is not
+ * set, this is a no-op, else it is cleared once the operation is complete.
+ *
+ * @returns {Promise<void>}
+ */
+ async bootstrapSSSS() {
+ if (!this._client) {
+ // client startup will do bootstrapping
+ return;
+ }
+ const password = this.getString("backupPassphrase");
+ if (!password) {
+ // We do not support setting up secret storage, so we need a passphrase
+ // to bootstrap.
+ return;
+ }
+ const backupInfo = await this._client.getKeyBackupVersion();
+ await this._client.bootstrapSecretStorage({
+ setupNewKeyBackup: false,
+ async getKeyBackupPassphrase() {
+ const key = await this._client.keyBackupKeyFromPassword(
+ password,
+ backupInfo
+ );
+ return key;
+ },
+ });
+ await this._client.bootstrapCrossSigning({
+ authUploadDeviceSigningKeys(makeRequest) {
+ makeRequest();
+ return Promise.resolve();
+ },
+ });
+ await this._client.checkOwnCrossSigningTrust();
+ if (backupInfo) {
+ await this._client.restoreKeyBackupWithSecretStorage(backupInfo);
+ }
+ // Clear passphrase once bootstrap was successful
+ this.imAccount.setString("backupPassphrase", "");
+ this.imAccount.save();
+ this._encryptionError = "";
+ await this.updateEncryptionStatus();
+ },
+
+ setString(name, value) {
+ if (!this._client) {
+ return;
+ }
+ if (name === "backupPassphrase" && value) {
+ this.bootstrapSSSS().catch(this.WARN);
+ } else if (name === "deviceDisplayName") {
+ this._client
+ .setDeviceDetails(this._client.getDeviceId(), {
+ display_name: value,
+ })
+ .catch(this.WARN);
+ }
+ },
+
+ /**
+ * Update the untrusted/unverified devices state for all encrypted
+ * conversations. Can limit the conversations by supplying a callback that
+ * only returns true if the conversation should update the state.
+ *
+ * @param {(prplIConversation) => boolean} [shouldUpdateConv] - Condition to
+ * evaluate if a conversation should have the device trust recalculated.
+ */
+ updateConvDeviceTrust(shouldUpdateConv) {
+ for (const conv of this.roomList.values()) {
+ const encryptionStatus = conv.encryptionStatus;
+ if (
+ encryptionStatus !== Ci.prplIConversation.ENCRYPTION_AVAILABLE &&
+ encryptionStatus !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED &&
+ (!shouldUpdateConv || shouldUpdateConv(conv))
+ ) {
+ conv.updateUnverifiedDevices();
+ }
+ }
+ },
+
+ /**
+ * Handle an incoming verification request.
+ *
+ * @param {VerificationRequest} request - Verification request from another
+ * user that is still pending and not handled by another session.
+ */
+ handleIncomingVerificationRequest(request) {
+ const abort = new AbortController();
+ request
+ .waitFor(
+ () => request.cancelled || (!request.requested && request.observeOnly)
+ )
+ .then(() => abort.abort());
+ let displayName = request.otherUserId;
+ if (request.isSelfVerification) {
+ const deviceInfo = this._client.getStoredDevice(
+ this.userId,
+ request.targetDevice.deviceId
+ );
+ if (deviceInfo?.getDisplayName()) {
+ displayName = lazy._(
+ "options.encryption.session",
+ request.targetDevice.deviceId,
+ deviceInfo.getDisplayName()
+ );
+ } else {
+ displayName = request.targetDevice.deviceId;
+ }
+ }
+ let _handleResult;
+ let _cancel;
+ const uiRequest = this.addVerificationRequest(
+ displayName,
+ async () => {
+ const { challenge, challengeDescription, handleResult, cancel } =
+ await startVerification(request);
+ _handleResult = handleResult;
+ _cancel = cancel;
+ return { challenge, challengeDescription };
+ },
+ abort.signal
+ );
+ uiRequest.then(
+ result => {
+ if (!_handleResult) {
+ this.ERROR(
+ "Can not handle the result for verification request with " +
+ request.otherUserId +
+ " because the verification was never started."
+ );
+ request.cancel();
+ }
+ _handleResult(result);
+ },
+ () => {
+ if (_cancel) {
+ _cancel();
+ } else {
+ request.cancel();
+ }
+ }
+ );
+ },
+
+ /**
+ * Set of currently pending timeouts for verification DM starts.
+ *
+ * @type {Set<TimeoutHandle>}
+ */
+ _verificationRequestTimeouts: null,
+
+ /**
+ * Shared implementation to initiate a verification with a MatrixParticipant or
+ * MatrixBuddy.
+ *
+ * @param {string} userId - Matrix ID of the user to verify.
+ * @returns {Promise} Same payload as startVerification.
+ */
+ async startVerificationDM(userId) {
+ let request;
+ if (this._pendingOutgoingVerificationRequests.has(userId)) {
+ throw new Error("Already have a pending request for user " + userId);
+ }
+ if (userId == this.userId) {
+ request = await this._client.requestVerification(userId);
+ } else {
+ let conv = this.getDirectConversation(userId);
+ conv = await conv.waitForRoom();
+ // Wait for the user to become part of the room (so being invited) for two
+ // seconds before sending verification request.
+ if (conv.isChat || !conv.room.getMember(userId)) {
+ let waitForMember;
+ let timeout;
+ try {
+ await new Promise(resolve => {
+ waitForMember = (event, state, member) => {
+ if (member.roomId == conv._roomId && member.userId == userId) {
+ resolve();
+ }
+ };
+ this._client.on(
+ lazy.MatrixSDK.RoomStateEvent.NewMember,
+ waitForMember
+ );
+ timeout = setTimeout(resolve, 2000);
+ this._verificationRequestTimeouts.add(timeout);
+ });
+ } finally {
+ this._verificationRequestTimeouts.delete(timeout);
+ clearTimeout(timeout);
+ this._client.removeListener(
+ lazy.MatrixSDK.RoomStateEvent.NewMember,
+ waitForMember
+ );
+ }
+ }
+ request = await this._client.requestVerificationDM(userId, conv._roomId);
+ }
+ this.trackOutgoingVerificationRequest(request, userId);
+ return startVerification(request);
+ },
+
+ /**
+ * Tracks a verification throughout its lifecycle, adding and removing it
+ * from the |_pendingOutgoingVerificationRequests| map.
+ *
+ * @param {VerificationRequest} request - Outgoing verification request.
+ * @param {string} requestKey - Key to identify this request.
+ */
+ async trackOutgoingVerificationRequest(request, requestKey) {
+ if (request.cancelled || request.done) {
+ return;
+ }
+ this._pendingOutgoingVerificationRequests.set(requestKey, request);
+ request
+ .waitFor(() => request.done || request.cancelled)
+ .then(() => {
+ this._pendingOutgoingVerificationRequests.delete(requestKey);
+ });
+ },
+
+ /**
+ * Set of room IDs that have pending invites that are being displayed to the
+ * user this session.
+ *
+ * @type {Set<string>}
+ */
+ _pendingRoomInvites: null,
+ /**
+ * A user invited this user to a DM room.
+ *
+ * @param {Room} room - Room we're invited to.
+ */
+ invitedToDM(room) {
+ if (this._pendingRoomInvites.has(room.roomId)) {
+ return;
+ }
+ let userId = room.getDMInviter();
+ this.addBuddyRequest(
+ userId,
+ () => {
+ this._pendingRoomInvites.delete(room.roomId);
+ this.setDirectRoom(userId, room.roomId);
+ // For the invited rooms, we will not get the summary info from
+ // the room object created after the joining. So we need to use
+ // the name from the room object here.
+ const conversation = this.getDirectConversation(
+ userId,
+ room.roomId,
+ room.name
+ );
+ if (room.getInvitedAndJoinedMemberCount() !== 2) {
+ conversation.checkForUpdate();
+ }
+ },
+ () => {
+ this._pendingRoomInvites.delete(room.roomId);
+ this._client.leave(room.roomId);
+ }
+ );
+ this._pendingRoomInvites.add(room.roomId);
+ },
+
+ /**
+ * The account has been invited to a group chat.
+ *
+ * @param {Room} room - Room we're invited to.
+ */
+ invitedToChat(room) {
+ if (this._pendingRoomInvites.has(room.roomId)) {
+ return;
+ }
+ let alias = room.getCanonicalAlias() ?? room.roomId;
+ this.addChatRequest(
+ alias,
+ () => {
+ this._pendingRoomInvites.delete(room.roomId);
+ const conversation = this.getGroupConversation(room.roomId, room.name);
+ if (room.getInvitedAndJoinedMemberCount() === 2) {
+ conversation.checkForUpdate();
+ }
+ },
+ // Server notice room invites can not be rejected.
+ !room.tags[SERVER_NOTICE_TAG] &&
+ (() => {
+ this._pendingRoomInvites.delete(room.roomId);
+ this._client.leave(room.roomId).catch(error => {
+ this.ERROR(error.message);
+ });
+ })
+ );
+ this._pendingRoomInvites.add(room.roomId);
+ },
+
+ /**
+ * Set the matrix user presence based on the given status info.
+ *
+ * @param {imIStatus} statusInfo
+ */
+ setPresence(statusInfo) {
+ const presenceDetails = {
+ presence: "offline",
+ status_msg: statusInfo.statusText,
+ };
+ if (statusInfo.statusType === Ci.imIStatusInfo.STATUS_AVAILABLE) {
+ presenceDetails.presence = "online";
+ } else if (
+ statusInfo.statusType === Ci.imIStatusInfo.STATUS_AWAY ||
+ statusInfo.statusType === Ci.imIStatusInfo.STATUS_IDLE
+ ) {
+ presenceDetails.presence = "unavailable";
+ }
+ this._client.setPresence(presenceDetails);
+ },
+
+ /**
+ * Update the local buddy with the latest information given the changes from
+ * the event.
+ *
+ * @param {MatrixEvent} event
+ * @param {User} user
+ */
+ updateBuddy(event, user) {
+ const buddy = this.buddies.get(user.userId);
+ if (!buddy) {
+ return;
+ }
+ if (!buddy._user) {
+ buddy.setUser(user);
+ } else {
+ buddy._user = user;
+ }
+ if (event.getType() === lazy.MatrixSDK.UserEvent.AvatarUrl) {
+ buddy._notifyObservers("icon-changed");
+ } else if (
+ event.getType() === lazy.MatrixSDK.UserEvent.Presence ||
+ event.getType() === lazy.MatrixSDK.UserEvent.CurrentlyActive
+ ) {
+ buddy.setStatusFromPresence();
+ } else if (event.getType() === lazy.MatrixSDK.UserEvent.DisplayName) {
+ buddy.serverAlias = user.displayName;
+ }
+ },
+
+ /**
+ * Checks if the room is the direct messaging room or not. We also check
+ * if number of joined users are two including us.
+ *
+ * @param {string} checkRoomId - ID of the room to check if it is direct
+ * messaging room or not.
+ * @returns {boolean} - If room is direct direct messaging room or not.
+ */
+ isDirectRoom(checkRoomId) {
+ for (let user of Object.keys(this._userToRoom)) {
+ for (let roomId of this._userToRoom[user]) {
+ if (roomId == checkRoomId) {
+ let room = this._client.getRoom(roomId);
+ if (room && room.getJoinedMembers().length == 2) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Room aliases and their conversation that are currently being created.
+ *
+ * @type {Map<string, MatrixRoom>}
+ */
+ _pendingRoomAliases: null,
+
+ /**
+ * Returns the group conversation according to the room-id.
+ * 1) If we have a group conversation already, we will return that.
+ * 2) If the user is already in the room but we don't have a conversation for
+ * it yet, create one.
+ * 3) Else we try to join the room and create a new conversation for it.
+ * 4) Create a new room if the room does not exist and is local to our server.
+ *
+ * @param {string} roomId - ID of the room.
+ * @param {string} [roomName] - Name of the room.
+ *
+ * @returns {MatrixRoom?} - The resulted conversation.
+ */
+ getGroupConversation(roomId, roomName) {
+ if (!roomId) {
+ return null;
+ }
+
+ const existingConv = this.getConversationByIdOrAlias(roomId);
+ if (existingConv) {
+ return existingConv;
+ }
+
+ const conv = new MatrixRoom(this, true, roomName || roomId);
+
+ // If we are already in the room, just initialize the conversation with it.
+ const existingRoom = this._client.getRoom(roomId);
+ if (existingRoom?.getMyMembership() === "join") {
+ this.roomList.set(existingRoom.roomId, conv);
+ conv.initRoom(existingRoom);
+ return conv;
+ }
+
+ // Try to join the room
+ this._client
+ .joinRoom(roomId)
+ .then(
+ room => {
+ this.roomList.set(room.roomId, conv);
+ conv.initRoom(room);
+ },
+ error => {
+ // If room does not exist and it is local to our server, create it.
+ if (
+ error.errcode === "M_NOT_FOUND" &&
+ roomId.endsWith(":" + this._client.getDomain()) &&
+ roomId[0] !== "!"
+ ) {
+ this.LOG(
+ "Creating room " + roomId + ", since we could not join: " + error
+ );
+ if (this._pendingRoomAliases.has(roomId)) {
+ conv.replaceRoom(this._pendingRoomAliases.get(roomId));
+ conv.forget();
+ return null;
+ }
+ // extract alias from #<alias>:<domain>
+ const alias = roomId.split(":", 1)[0].slice(1);
+ return this.createRoom(this._pendingRoomAliases, roomId, conv, {
+ room_alias_name: alias,
+ name: roomName || alias,
+ visibility: lazy.MatrixSDK.Visibility.Private,
+ preset: lazy.MatrixSDK.Preset.PrivateChat,
+ });
+ }
+ conv.close();
+ throw error;
+ }
+ )
+ .catch(error => {
+ this.ERROR(error);
+ if (!conv.room) {
+ conv.forget();
+ }
+ });
+
+ return conv;
+ },
+
+ /**
+ * Get an existing conversation for a room ID or alias.
+ *
+ * @param {string} roomIdOrAlias - Identifier for the conversation.
+ * @returns {GenericMatrixConversation?}
+ */
+ getConversationByIdOrAlias(roomIdOrAlias) {
+ if (!roomIdOrAlias) {
+ return null;
+ }
+
+ const conv = this.getConversationById(roomIdOrAlias);
+ if (conv) {
+ return conv;
+ }
+ const existingRoom = this._client.getRoom(roomIdOrAlias);
+ if (!existingRoom) {
+ return null;
+ }
+ return this.getConversationById(existingRoom.roomId);
+ },
+
+ /**
+ * Get an existing conversation for a room ID.
+ *
+ * @param {string} roomId - Room ID of the conversation.
+ * @returns {GenericMatrixConversation?}
+ */
+ getConversationById(roomId) {
+ if (!roomId) {
+ return null;
+ }
+
+ // If there is a conversation return it.
+ if (this.roomList.has(roomId)) {
+ return this.roomList.get(roomId);
+ }
+
+ // Are we already creating a room with the ID?
+ if (this._pendingRoomAliases.has(roomId)) {
+ return this._pendingRoomAliases.get(roomId);
+ }
+ return null;
+ },
+
+ /**
+ * Returns the room ID for user ID if exists for direct messaging.
+ *
+ * @param {string} roomId - ID of the user.
+ *
+ * @returns {string} - ID of the room.
+ */
+ getDMRoomIdForUserId(userId) {
+ // Check in the 'other' user's roomList for common m.direct rooms.
+ // Select the most recent room based on the timestamp of the
+ // most recent event in the room's timeline.
+ const rooms = this.getDMRoomIdsForUserId(userId)
+ .map(roomId => {
+ const room = this._client.getRoom(roomId);
+ const mostRecentTimestamp = room.getLastActiveTimestamp();
+ return {
+ roomId,
+ mostRecentTimestamp,
+ };
+ })
+ .sort(
+ (roomA, roomB) => roomB.mostRecentTimestamp - roomA.mostRecentTimestamp
+ );
+ if (rooms.length) {
+ return rooms[0].roomId;
+ }
+ return null;
+ },
+
+ /**
+ * Get all room IDs of active DM rooms with the given user.
+ *
+ * @param {string} userId - User ID to find rooms for.
+ * @returns {string[]} Array of rooom IDs.
+ */
+ getDMRoomIdsForUserId(userId) {
+ if (!Array.isArray(this._userToRoom[userId])) {
+ return [];
+ }
+ return this._userToRoom[userId].filter(roomId => {
+ const room = this._client.getRoom(roomId);
+ if (!room || room.isSpaceRoom()) {
+ return false;
+ }
+ const accountMembership = room.getMyMembership() ?? "leave";
+ // Default to invite, since the invite for the other member may not be in
+ // the room events yet.
+ let userMembership = room.getMember(userId)?.membership ?? "invite";
+ // If either party left the room we shouldn't try to rejoin.
+ return userMembership !== "leave" && accountMembership !== "leave";
+ });
+ },
+
+ /**
+ * Sets the room ID for for corresponding user ID for direct messaging
+ * by setting the "m.direct" event of account data of the SDK client.
+ *
+ * @param {string} roomId - ID of the user.
+ *
+ * @param {string} - ID of the room.
+ */
+ setDirectRoom(userId, roomId) {
+ let dmRoomMap = this._userToRoom;
+ let roomList = dmRoomMap[userId] || [];
+ if (!roomList.includes(roomId)) {
+ roomList.push(roomId);
+ dmRoomMap[userId] = roomList;
+ this._client.setAccountData(lazy.MatrixSDK.EventType.Direct, dmRoomMap);
+ }
+ },
+
+ updateRoomMember(event, member) {
+ if (this.roomList && this.roomList.has(member.roomId)) {
+ let conv = this.roomList.get(member.roomId);
+ if (conv.isChat) {
+ let participant = conv._participants.get(member.userId);
+ // A participant might not exist (for example, this happens if the user
+ // has only been invited, but has not yet joined).
+ if (participant) {
+ participant._roomMember = member;
+ conv.notifyObservers(participant, "chat-buddy-update");
+ conv.notifyObservers(null, "chat-update-topic");
+ }
+ }
+ }
+ },
+
+ disconnect() {
+ this._client.setPresence({ presence: "offline" });
+ this._client.stopClient();
+ this.reportDisconnected();
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ //TODO should split the fields like in account setup, though we would
+ // probably want to keep the type prefix
+ roomIdOrAlias: {
+ get label() {
+ return lazy._("chatRoomField.room");
+ },
+ required: true,
+ },
+ },
+ parseDefaultChatName(aDefaultName) {
+ let chatFields = {
+ roomIdOrAlias: aDefaultName,
+ };
+
+ return chatFields;
+ },
+ joinChat(components) {
+ // For the format of room id and alias, see the matrix documentation:
+ // https://matrix.org/docs/spec/appendices#room-ids-and-event-ids
+ // https://matrix.org/docs/spec/appendices#room-aliases
+ let roomIdOrAlias = components.getValue("roomIdOrAlias").trim();
+
+ // If domain is missing, append the domain from the user's server.
+ if (!roomIdOrAlias.includes(":")) {
+ roomIdOrAlias += ":" + this._client.getDomain();
+ }
+
+ // There will be following types of ids:
+ // !fubIsJzeAcCcjYTQvm:mozilla.org => General room id.
+ // #maildev:mozilla.org => Group Conversation room id.
+ // @clokep:mozilla.org => Direct Conversation room id.
+ if (roomIdOrAlias.startsWith("!")) {
+ // We create the group conversation initially. Then we check if the room
+ // is the direct messaging room or not.
+ //TODO init with correct type from isDirectMessage(roomIdOrAlias)
+ let conv = this.getGroupConversation(roomIdOrAlias);
+ if (!conv) {
+ return null;
+ }
+ // It can be any type of room so update it according to direct conversation
+ // or group conversation.
+ conv.checkForUpdate();
+ return conv;
+ }
+
+ // If the ID does not start with @ or #, assume it is a group conversation and append #.
+ if (!roomIdOrAlias.startsWith("@") && !roomIdOrAlias.startsWith("#")) {
+ roomIdOrAlias = "#" + roomIdOrAlias;
+ }
+ // If the ID starts with a @, it is a direct conversation.
+ if (roomIdOrAlias.startsWith("@")) {
+ return this.getDirectConversation(roomIdOrAlias);
+ }
+ // Otherwise, it is a group conversation.
+ return this.getGroupConversation(roomIdOrAlias);
+ },
+
+ createConversation(userId) {
+ if (userId == this.userId) {
+ return null;
+ }
+ return this.getDirectConversation(userId);
+ },
+
+ /**
+ * User IDs and their DM conversations which are being created.
+ *
+ * @type {Map<string, MatrixRoom>}
+ */
+ _pendingDirectChats: null,
+
+ /**
+ * Returns the direct conversation according to the room-id or user-id.
+ * If the room ID is specified, it is the preferred way of identifying the
+ * conversation to return.
+ *
+ * 1) If we have a direct conversation already, we will return that.
+ * 2) If the room exists on the server, we will join it. It will not do
+ * anything if we are already joined, it will just create the
+ * conversation. This is used mainly when a new room gets added.
+ * 3) Create a new room if the conversation does not exist.
+ *
+ * @param {string} userId - ID of the user for which we want to get the
+ * direct conversation.
+ * @param {string} [roomId] - ID of the room.
+ * @param {string} [roomName] - Name of the room.
+ *
+ * @returns {MatrixRoom} - The resulted conversation.
+ */
+ getDirectConversation(userId, roomID, roomName) {
+ let DMRoomId = this.getDMRoomIdForUserId(userId);
+ if (roomID && DMRoomId !== roomID) {
+ this.setDirectRoom(userId, roomID);
+ DMRoomId = roomID;
+ }
+ if (!DMRoomId && roomID) {
+ DMRoomId = roomID;
+ }
+ if (DMRoomId && this.roomList.has(DMRoomId)) {
+ return this.roomList.get(DMRoomId);
+ }
+
+ // If user is invited to the room then DMRoomId will be null. In such
+ // cases, we will pass roomID so that user will be joined to the room
+ // and we will create corresponding conversation.
+ if (DMRoomId) {
+ let conv = new MatrixRoom(this, false, roomName || DMRoomId);
+ this.roomList.set(DMRoomId, conv);
+ this._client
+ .joinRoom(DMRoomId)
+ .catch(error => {
+ conv.close();
+ throw error;
+ })
+ .then(room => {
+ conv.initRoom(room);
+ // The membership events will sometimes be missing to initialize the
+ // buddy correctly in the normal room init.
+ if (!conv.buddy) {
+ conv.initBuddy(userId);
+ }
+ })
+ .catch(error => {
+ this.ERROR("Error creating conversation " + DMRoomId + ": " + error);
+ if (!conv.room) {
+ conv.forget();
+ }
+ });
+
+ return conv;
+ }
+
+ // Check if we're already creating a DM room with userId
+ if (this._pendingDirectChats.has(userId)) {
+ return this._pendingDirectChats.get(userId);
+ }
+
+ // Create new DM room with userId
+ let conv = new MatrixRoom(this, false, userId);
+ this.createRoom(
+ this._pendingDirectChats,
+ userId,
+ conv,
+ {
+ is_direct: true,
+ invite: [userId],
+ visibility: lazy.MatrixSDK.Visibility.Private,
+ preset: lazy.MatrixSDK.Preset.TrustedPrivateChat,
+ },
+ roomId => {
+ this.setDirectRoom(userId, roomId);
+ }
+ );
+ return conv;
+ },
+
+ /**
+ * Create a new matrix room. Locks room creation handling during the
+ * operation. If there are no more pending rooms on completion, we need to
+ * make sure we didn't miss a join from another room.
+ *
+ * @param {Map<string, MatrixRoom>} pendingMap - One of the lock maps.
+ * @param {string} key - The key to lock with in the set.
+ * @param {MatrixRoom} conversation - Conversation for the room.
+ * @param {object} roomInit - Parameters for room creation.
+ * @param {Function} [onCreated] - Callback to execute before room creation
+ * is finalized.
+ * @returns {Promise} The returned promise should never reject.
+ */
+ async createRoom(pendingMap, key, conversation, roomInit, onCreated) {
+ pendingMap.set(key, conversation);
+ if (roomInit.is_direct && roomInit.invite) {
+ try {
+ const userDeviceMap = await this._client.downloadKeys(roomInit.invite);
+ // Encrypt if there are devices and each user has at least 1 device
+ // capable of encryption.
+ const shouldEncrypt =
+ userDeviceMap.size > 0 &&
+ [...userDeviceMap.values()].every(deviceMap => deviceMap.size > 0);
+ if (shouldEncrypt) {
+ if (!roomInit.initial_state) {
+ roomInit.initial_state = [];
+ }
+ roomInit.initial_state.push({
+ type: lazy.MatrixSDK.EventType.RoomEncryption,
+ state_key: "",
+ content: {
+ algorithm: lazy.OlmLib.MEGOLM_ALGORITHM,
+ },
+ });
+ }
+ } catch (error) {
+ const users = roomInit.invite.join(", ");
+ this.WARN(
+ `Error while checking encryption devices for ${users}: ${error}`
+ );
+ }
+ }
+ try {
+ const res = await this._client.createRoom(roomInit);
+ const newRoomId = res.room_id;
+ if (typeof onCreated === "function") {
+ onCreated(newRoomId);
+ }
+ this.roomList.set(newRoomId, conversation);
+ const room = this._client.getRoom(newRoomId);
+ if (room) {
+ conversation.initRoom(room);
+ }
+ } catch (error) {
+ this.ERROR(error);
+ // Only leave room if it was ever associated with the conversation
+ if (!conversation.room) {
+ conversation.forget();
+ } else {
+ conversation.close();
+ }
+ } finally {
+ pendingMap.delete(key);
+ if (this._pendingDirectChats.size + this._pendingRoomAliases.size === 0) {
+ await this.handleCaughtUp();
+ }
+ }
+ },
+
+ addBuddy(aTag, aName) {
+ if (aName[0] !== this.protocol.usernamePrefix) {
+ this.ERROR("Buddy name must start with @");
+ return;
+ }
+ if (!aName.includes(this.protocol.usernameSplits[0].separator)) {
+ this.ERROR("Buddy name must include :");
+ return;
+ }
+ if (aName == this.userId) {
+ return;
+ }
+ if (this.buddies.has(aName)) {
+ return;
+ }
+ // Prepare buddy for use with the conversation while preserving the tag.
+ const buddy = new MatrixBuddy(this, null, aTag, aName);
+ IMServices.contacts.accountBuddyAdded(buddy);
+ this.buddies.set(aName, buddy);
+
+ this.getDirectConversation(aName);
+ },
+ loadBuddy(aBuddy, aTag) {
+ const buddy = new MatrixBuddy(this, aBuddy, aTag);
+ this.buddies.set(buddy.userName, buddy);
+ return buddy;
+ },
+
+ /**
+ * Get tooltip info for a user.
+ *
+ * @param {string} aUserId - MXID to get tooltip data for.
+ * @returns {Array<prplITooltipInfo>}
+ */
+ getBuddyInfo(aUserId) {
+ if (!this.connected) {
+ return [];
+ }
+ let user = this._client.getUser(aUserId);
+ if (!user) {
+ return [];
+ }
+
+ // Convert timespan in milli-seconds into a human-readable form.
+ let getNormalizedTime = function (aTime) {
+ let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(aTime / 1000);
+ // If the time is exact to the first set of units, trim off
+ // the subsequent zeroes.
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ return lazy._("tooltip.timespan", valuesAndUnits.join(" "));
+ };
+
+ let tooltipInfo = [];
+
+ if (user.displayName) {
+ tooltipInfo.push(
+ new TooltipInfo(lazy._("tooltip.displayName"), user.displayName)
+ );
+ }
+
+ // Add the user's current status.
+ let status = getStatusFromPresence(user);
+ if (status === Ci.imIStatusInfo.STATUS_IDLE) {
+ tooltipInfo.push(
+ new TooltipInfo(
+ lazy._("tooltip.lastActive"),
+ getNormalizedTime(user.lastActiveAgo)
+ )
+ );
+ }
+ tooltipInfo.push(
+ new TooltipInfo(
+ status,
+ user.presenceStatusMsg,
+ Ci.prplITooltipInfo.status
+ )
+ );
+
+ if (user.avatarUrl) {
+ // Convert the MXC URL to an HTTP URL.
+ let realUrl = this._client.mxcUrlToHttp(
+ user.avatarUrl,
+ USER_ICON_SIZE,
+ USER_ICON_SIZE,
+ "scale",
+ false
+ );
+ // TODO Cache the photo URI for this participant.
+ tooltipInfo.push(
+ new TooltipInfo(null, realUrl, Ci.prplITooltipInfo.icon)
+ );
+ }
+
+ return tooltipInfo;
+ },
+
+ requestBuddyInfo(aUserId) {
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(this.getBuddyInfo(aUserId)),
+ "user-info-received",
+ aUserId
+ );
+ },
+
+ getSessions() {
+ if (!this._client || !this._client.isCryptoEnabled()) {
+ return [];
+ }
+ return this._client
+ .getStoredDevicesForUser(this.userId)
+ .map(deviceInfo => new MatrixSession(this, this.userId, deviceInfo));
+ },
+
+ get userId() {
+ return this._client.credentials.userId;
+ },
+ _client: null,
+};
diff --git a/comm/chat/protocols/matrix/matrixCommands.sys.mjs b/comm/chat/protocols/matrix/matrixCommands.sys.mjs
new file mode 100644
index 0000000000..068ef684bb
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrixCommands.sys.mjs
@@ -0,0 +1,490 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MatrixPowerLevels: "resource:///modules/matrixPowerLevels.sys.mjs",
+ MatrixSDK: "resource:///modules/matrix-sdk.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "EVENT_TO_STRING", () => ({
+ ban: "powerLevel.ban",
+ [lazy.MatrixSDK.EventType.RoomAvatar]: "powerLevel.roomAvatar",
+ [lazy.MatrixSDK.EventType.RoomCanonicalAlias]: "powerLevel.mainAddress",
+ [lazy.MatrixSDK.EventType.RoomHistoryVisibility]: "powerLevel.history",
+ [lazy.MatrixSDK.EventType.RoomName]: "powerLevel.roomName",
+ [lazy.MatrixSDK.EventType.RoomPowerLevels]: "powerLevel.changePermissions",
+ [lazy.MatrixSDK.EventType.RoomServerAcl]: "powerLevel.server_acl",
+ [lazy.MatrixSDK.EventType.RoomTombstone]: "powerLevel.upgradeRoom",
+ invite: "powerLevel.inviteUser",
+ kick: "powerLevel.kickUsers",
+ redact: "powerLevel.remove",
+ state_default: "powerLevel.state_default",
+ users_default: "powerLevel.defaultRole",
+ events_default: "powerLevel.events_default",
+ [lazy.MatrixSDK.EventType.RoomEncryption]: "powerLevel.encryption",
+ [lazy.MatrixSDK.EventType.RoomTopic]: "powerLevel.topic",
+}));
+
+// Commands from element that we're not yet supporting (including equivalents):
+// - /nick (no proper display name change support in matrix.jsm yet)
+// - /myroomnick <display_name>
+// - /roomavatar [<mxc_url>]
+// - /myroomavatar [<mxc_url>]
+// - /myavatar [<mxc_url>]
+// - /ignore <user-id> (kind of available, but not matrix level ignores)
+// - /unignore <user-id>
+// - /whois <user-id>
+// - /converttodm
+// - /converttoroom
+// - /upgraderoom
+
+function getConv(conv) {
+ return conv.wrappedJSObject;
+}
+
+function getAccount(conv) {
+ return getConv(conv)._account;
+}
+
+/**
+ * Generates a string representing the required power level to send an event
+ * in a room.
+ *
+ * @param {string} eventType - Matrix event type.
+ * @param {number} userPower - Power level required to send the events.
+ * @returns {string} Human readable representation of the event type and its
+ * required power level.
+ */
+function getEventString(eventType, userPower) {
+ if (lazy.EVENT_TO_STRING.hasOwnProperty(eventType)) {
+ return lazy._(lazy.EVENT_TO_STRING[eventType], userPower);
+ }
+ return null;
+}
+
+/**
+ * Lists out many room details, like aliases and permissions, as notices in
+ * their room.
+ *
+ * @param {imIAccount} account - Account of the room.
+ * @param {prplIConversation} conv - Conversation to list details for.
+ */
+function publishRoomDetails(account, conv) {
+ let roomState = conv.roomState;
+ let powerLevelEvent = roomState.getStateEvents(
+ lazy.MatrixSDK.EventType.RoomPowerLevels,
+ ""
+ );
+ let room = conv.room;
+
+ let name = room.name;
+ let nameString = lazy._("detail.name", name);
+ conv.writeMessage(account.userId, nameString, {
+ system: true,
+ });
+
+ let roomId = room.roomId;
+ let roomIdString = lazy._("detail.roomId", roomId);
+ conv.writeMessage(account.userId, roomIdString, {
+ system: true,
+ });
+
+ let roomVersion = room.getVersion();
+ let versionString = lazy._("detail.version", roomVersion);
+ conv.writeMessage(account.userId, versionString, {
+ system: true,
+ });
+
+ let topic = null;
+ if (roomState.getStateEvents(lazy.MatrixSDK.EventType.RoomTopic)?.length) {
+ topic = roomState
+ .getStateEvents(lazy.MatrixSDK.EventType.RoomTopic)[0]
+ .getContent().topic;
+ }
+ let topicString = lazy._("detail.topic", topic);
+ conv.writeMessage(account.userId, topicString, {
+ system: true,
+ });
+
+ let guestAccess = roomState
+ .getStateEvents(lazy.MatrixSDK.EventType.RoomGuestAccess, "")
+ ?.getContent()?.guest_access;
+ let guestAccessString = lazy._("detail.guest", guestAccess);
+ conv.writeMessage(account.userId, guestAccessString, {
+ system: true,
+ });
+
+ let admins = [];
+ let moderators = [];
+
+ let powerLevel = powerLevelEvent.getContent();
+ for (let [key, value] of Object.entries(powerLevel.users)) {
+ if (value >= lazy.MatrixPowerLevels.admin) {
+ admins.push(key);
+ } else if (value >= lazy.MatrixPowerLevels.moderator) {
+ moderators.push(key);
+ }
+ }
+
+ if (admins.length) {
+ let adminString = lazy._("detail.admin", admins.join(", "));
+ conv.writeMessage(account.userId, adminString, {
+ system: true,
+ });
+ }
+
+ if (moderators.length) {
+ let moderatorString = lazy._("detail.moderator", moderators.join(", "));
+ conv.writeMessage(account.userId, moderatorString, {
+ system: true,
+ });
+ }
+
+ if (
+ roomState.getStateEvents(lazy.MatrixSDK.EventType.RoomCanonicalAlias)
+ ?.length
+ ) {
+ let canonicalAlias = room.getCanonicalAlias();
+ let aliases = room.getAltAliases();
+ if (canonicalAlias && !aliases.includes(canonicalAlias)) {
+ aliases.unshift(canonicalAlias);
+ }
+ if (aliases.length) {
+ let aliasString = lazy._("detail.alias", aliases.join(","));
+ conv.writeMessage(account.userId, aliasString, {
+ system: true,
+ });
+ }
+ }
+
+ conv.writeMessage(account.userId, lazy._("detail.power"), {
+ system: true,
+ });
+
+ const defaultLevel = lazy.MatrixPowerLevels.getUserDefaultLevel(powerLevel);
+ for (let [key, value] of Object.entries(powerLevel)) {
+ if (key == "users") {
+ continue;
+ }
+ if (key == "events") {
+ for (let [userKey, userValue] of Object.entries(powerLevel.events)) {
+ let userPower = lazy.MatrixPowerLevels.toText(userValue, defaultLevel);
+ let powerString = getEventString(userKey, userPower);
+ if (!powerString) {
+ continue;
+ }
+ conv.writeMessage(account.userId, powerString, {
+ system: true,
+ });
+ }
+ continue;
+ }
+ let userPower = lazy.MatrixPowerLevels.toText(value, defaultLevel);
+ let powerString = getEventString(key, userPower);
+ if (!powerString) {
+ continue;
+ }
+ conv.writeMessage(account.userId, powerString, {
+ system: true,
+ });
+ }
+}
+
+/**
+ * Generic command handler for commands with up to 2 params.
+ *
+ * @param {(imIAccount, prplIConversation, string[]) => boolean} commandCallback - Command handler implementation. Returns true when successful.
+ * @param {number} parameterCount - Number of parameters. Maximum 2.
+ * @param {object} [options] - Extra options.
+ * @param {number} [options.requiredCount] - How many of the parameters are required (from the start).
+ * @param {(string[]) => boolean} [options.validateParams] - Validator function for params.
+ * @param {(prplIConversation, string[]) => any[]} [options.formatParams] - Formatting function for params.
+ * @returns {(string, imIConversation) => boolean} Command handler function that returns true when the command was handled.
+ */
+function runCommand(
+ commandCallback,
+ parameterCount,
+ {
+ requiredCount = parameterCount,
+ validateParams = params => true,
+ formatParams = (conv, params) => [conv._roomId, ...params],
+ } = {}
+) {
+ if (parameterCount > 2) {
+ throw new Error("Can not handle more than two parameters");
+ }
+ return (msg, convObj) => {
+ // Parse msg into the given parameter count
+ let params = [];
+ const trimmedMsg = msg.trim();
+ if (parameterCount === 0) {
+ if (trimmedMsg) {
+ return false;
+ }
+ } else if (parameterCount === 1) {
+ if (!trimmedMsg.length && requiredCount > 0) {
+ return false;
+ }
+ params.push(trimmedMsg);
+ } else if (parameterCount === 2) {
+ if (
+ (!trimmedMsg.length && requiredCount > 0) ||
+ (!trimmedMsg.includes(" ") && requiredCount > 1)
+ ) {
+ return false;
+ }
+ const separatorIndex = trimmedMsg.indexOf(" ");
+ if (separatorIndex > 0) {
+ params.push(
+ trimmedMsg.slice(0, separatorIndex),
+ trimmedMsg.slice(separatorIndex + 1)
+ );
+ } else {
+ params.push(trimmedMsg);
+ }
+ }
+
+ if (!validateParams(params)) {
+ return false;
+ }
+
+ const account = getAccount(convObj);
+ const conv = getConv(convObj);
+ params = formatParams(conv, params);
+ return commandCallback(account, conv, params);
+ };
+}
+
+/**
+ * Generic command handler that calls a matrix JS client method. First param
+ * is always the roomId.
+ *
+ * @param {string} clientMethod - Name of the method on the matrix client.
+ * @param {number} parameterCount - Number of parameters. Maximum 2.
+ * @param {object} [options] - Extra options.
+ * @param {number} [options.requiredCount] - How many of the parameters are required (from the start).
+ * @param {(string[]) => boolean} [options.validateParams] - Validator function for params.
+ * @param {(prplIConversation, string[]) => any[]} [options.formatParams] - Formatting function for params.
+ * @returns {(string, imIConversation) => boolean} Command handler function that returns true when the command was handled.
+ */
+function clientCommand(clientMethod, parameterCount, options) {
+ return runCommand(
+ (account, conv, params) => {
+ account._client[clientMethod](...params).catch(error => {
+ conv.writeMessage(account.userId, error.message, {
+ system: true,
+ error: true,
+ });
+ });
+ return true;
+ },
+ parameterCount,
+ options
+ );
+}
+
+export var commands = [
+ {
+ name: "ban",
+ get helpString() {
+ return lazy._("command.ban", "ban");
+ },
+ run: clientCommand("ban", 2, { requiredCount: 1 }),
+ },
+ {
+ name: "unban",
+ get helpString() {
+ return lazy._("command.unban", "unban");
+ },
+ run: clientCommand("unban", 1),
+ },
+ {
+ name: "invite",
+ get helpString() {
+ return lazy._("command.invite", "invite");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: clientCommand("invite", 1),
+ },
+ {
+ name: "kick",
+ get helpString() {
+ return lazy._("command.kick", "kick");
+ },
+ run: clientCommand("kick", 2, { requiredCount: 1 }),
+ },
+ {
+ name: "op",
+ get helpString() {
+ return lazy._("command.op", "op");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: clientCommand("setPowerLevel", 2, {
+ validateParams([userId, powerLevelString]) {
+ const powerLevel = Number.parseInt(powerLevelString);
+ return (
+ Number.isInteger(powerLevel) &&
+ powerLevel >= lazy.MatrixPowerLevels.user
+ );
+ },
+ formatParams(conv, [userId, powerLevelString]) {
+ const powerLevel = Number.parseInt(powerLevelString);
+ let powerLevelEvent = conv.roomState.getStateEvents(
+ lazy.MatrixSDK.EventType.RoomPowerLevels,
+ ""
+ );
+ return [conv._roomId, userId, powerLevel, powerLevelEvent];
+ },
+ }),
+ },
+ {
+ name: "deop",
+ get helpString() {
+ return lazy._("command.deop", "deop");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run: clientCommand("setPowerLevel", 1, {
+ formatParams(conv, [userId]) {
+ const powerLevelEvent = conv.roomState.getStateEvents(
+ lazy.MatrixSDK.EventType.RoomPowerLevels,
+ ""
+ );
+ return [
+ conv._roomId,
+ userId,
+ lazy.MatrixPowerLevels.user,
+ powerLevelEvent,
+ ];
+ },
+ }),
+ },
+ {
+ name: "part",
+ get helpString() {
+ return lazy._("command.leave", "part");
+ },
+ run: clientCommand("leave", 0),
+ },
+ {
+ name: "topic",
+ get helpString() {
+ return lazy._("command.topic", "topic");
+ },
+ run: runCommand((account, conv, [roomId, topic]) => {
+ conv.topic = topic;
+ return true;
+ }, 1),
+ },
+ {
+ name: "visibility",
+ get helpString() {
+ return lazy._("command.visibility", "visibility");
+ },
+ run: clientCommand("setRoomDirectoryVisibility", 1, {
+ formatParams(conv, [visibilityString]) {
+ const visibility =
+ Number.parseInt(visibilityString) === 1
+ ? lazy.MatrixSDK.Visibility.Public
+ : lazy.MatrixSDK.Visibility.Private;
+ return [conv._roomId, visibility];
+ },
+ }),
+ },
+ {
+ name: "roomname",
+ get helpString() {
+ return lazy._("command.roomname", "roomname");
+ },
+ run: clientCommand("setRoomName", 1),
+ },
+ {
+ name: "detail",
+ get helpString() {
+ return lazy._("command.detail", "detail");
+ },
+ run(msg, convObj, returnedConv) {
+ let account = getAccount(convObj);
+ let conv = getConv(convObj);
+ publishRoomDetails(account, conv);
+ return true;
+ },
+ },
+ {
+ name: "addalias",
+ get helpString() {
+ return lazy._("command.addalias", "addalias");
+ },
+ run: clientCommand("createAlias", 1, {
+ formatParams(conv, [alias]) {
+ return [alias, conv._roomId];
+ },
+ }),
+ },
+ {
+ name: "removealias",
+ get helpString() {
+ return lazy._("command.removealias", "removealias");
+ },
+ run: clientCommand("deleteAlias", 1, {
+ formatParams(conv, [alias]) {
+ return [alias];
+ },
+ }),
+ },
+ {
+ name: "me",
+ get helpString() {
+ return lazy._("command.me", "me");
+ },
+ run: runCommand((account, conv, [roomId, message]) => {
+ conv.sendMsg(message, true);
+ return true;
+ }, 1),
+ },
+ {
+ name: "msg",
+ get helpString() {
+ return lazy._("command.msg", "msg");
+ },
+ run: runCommand((account, conv, [roomId, userId, message]) => {
+ const room = account.getDirectConversation(userId);
+ if (room) {
+ room.waitForRoom().then(readyRoom => {
+ readyRoom.sendMsg(message);
+ });
+ } else {
+ account.ERROR("Could not create room for direct message to " + userId);
+ }
+ return true;
+ }, 2),
+ },
+ {
+ name: "join",
+ get helpString() {
+ return lazy._("command.join", "join");
+ },
+ run: runCommand(
+ (account, conv, [currentRoomId, joinRoomId]) => {
+ account.getGroupConversation(joinRoomId);
+ return true;
+ },
+ 1,
+ {
+ validateParams([roomId]) {
+ // TODO support joining rooms without a human readable ID.
+ return roomId.startsWith("#");
+ },
+ }
+ ),
+ },
+];
diff --git a/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs b/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs
new file mode 100644
index 0000000000..27e0ff6680
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrixMessageContent.sys.mjs
@@ -0,0 +1,377 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { MatrixSDK } from "resource:///modules/matrix-sdk.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getMatrixTextForEvent: "resource:///modules/matrixTextForEvent.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(lazy, "domParser", () => new DOMParser());
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTxt => cs.scanTXT(aTxt, cs.kEntities);
+});
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+const kRichBodiedTypes = [
+ MatrixSDK.MsgType.Text,
+ MatrixSDK.MsgType.Notice,
+ MatrixSDK.MsgType.Emote,
+];
+const kHtmlFormat = "org.matrix.custom.html";
+const kAttachmentTypes = [
+ MatrixSDK.MsgType.Image,
+ MatrixSDK.MsgType.File,
+ MatrixSDK.MsgType.Audio,
+ MatrixSDK.MsgType.Video,
+];
+
+/**
+ * Gets the user-consumable URI to an attachment from an mxc URI and
+ * potentially encrypted file.
+ *
+ * @param {IContent} content - Event content to get the attachment URL from.
+ * @param {string} homeserverUrl - Homeserver URL to load the attachment from.
+ * @returns {string} https or data URI to the attachment file.
+ */
+function getAttachmentUrl(content, homeserverUrl) {
+ if (content.file?.v == "v2") {
+ return MatrixSDK.getHttpUriForMxc(homeserverUrl, content.file.url);
+ //TODO Actually handle encrypted file contents.
+ }
+ if (!content.url.startsWith("mxc:")) {
+ // Ignore content not served by the homeserver's media repo
+ return "";
+ }
+ return MatrixSDK.getHttpUriForMxc(homeserverUrl, content.url);
+}
+
+/**
+ * Turn an attachment event into a link to the attached file.
+ *
+ * @param {IContent} content - The event contents.
+ * @param {string} homeserverUrl - The base URL of the homeserver.
+ * @returns {string} HTML string to link to the attachment.
+ */
+function formatMediaAttachment(content, homeserverUrl) {
+ const realUrl = getAttachmentUrl(content, homeserverUrl);
+ if (!realUrl) {
+ return content.body;
+ }
+ return `<a href="${realUrl}">${content.body}</a>`;
+}
+
+/**
+ * Format a user ID so it always gets a user tooltip.
+ *
+ * @param {string} userId - User ID to mention.
+ * @param {DOMDocument} doc - DOM Document the mention will appear in.
+ * @returns {HTMLSpanElement} Element to insert for the mention.
+ */
+function formatMention(userId, doc) {
+ const ibPerson = doc.createElement("span");
+ ibPerson.classList.add("ib-person");
+ ibPerson.textContent = userId;
+ return ibPerson;
+}
+
+/**
+ * Get the raw text content of the reply event.
+ *
+ * @param {MatrixEvent} replyEvent - Event to quote.
+ * @param {string} homeserverUrl - The base URL of the homeserver.
+ * @param {string => MatrixEvent} getEvent - Get the event with the given ID.
+ * Used to fetch the replied to event.
+ * @param {boolean} rich - When true prefers the HTML representation of the
+ * event body.
+ * @returns {string} Formatted text body of the event to quote.
+ */
+function getReplyContent(replyEvent, homeserverUrl, getEvent, rich) {
+ let replyContent =
+ (rich &&
+ MatrixMessageContent.getIncomingHTML(
+ replyEvent,
+ homeserverUrl,
+ getEvent,
+ false
+ )) ||
+ MatrixMessageContent.getIncomingPlain(
+ replyEvent,
+ homeserverUrl,
+ getEvent,
+ false
+ );
+ if (replyEvent.getContent()?.msgtype === MatrixSDK.MsgType.Emote) {
+ replyContent = `* ${replyEvent.getSender()} ${replyContent} *`;
+ }
+ return replyContent;
+}
+
+/**
+ * Adapts the plain text body of an event for display.
+ *
+ * @param {MatrixEvent} event - The event to format the body of.
+ * @param {string} homeserverUrl - The base URL of the homeserver.
+ * @param {(string) => MatrixEvent} getEvent - Get the event with the given ID.
+ * @param {boolean} [includeReply=true] - If the message should contain the message it's replying to.
+ * @returns {string} Plain text message for the event.
+ */
+function formatPlainBody(event, homeserverUrl, getEvent, includeReply = true) {
+ const content = event.getContent();
+ let body = lazy.TXTToHTML(content.body);
+ const eventId = event.replyEventId;
+ if (body.startsWith("&gt;") && eventId) {
+ let nonQuote = Number.MAX_SAFE_INTEGER;
+ const replyEvent = getEvent(eventId);
+ if (!includeReply || replyEvent) {
+ // Strip the fallback quote
+ body = body
+ .split("\n")
+ .filter((line, index) => {
+ const isQuoteLine = line.startsWith("&gt;");
+ if (!isQuoteLine && nonQuote > index) {
+ nonQuote = index;
+ }
+ return nonQuote < index || !isQuoteLine;
+ })
+ .join("\n");
+ }
+ if (
+ includeReply &&
+ replyEvent &&
+ content.msgtype != MatrixSDK.MsgType.Emote
+ ) {
+ let replyContent = getReplyContent(
+ replyEvent,
+ homeserverUrl,
+ getEvent,
+ false
+ );
+ const isEmoteReply =
+ replyEvent.getContent()?.msgtype == MatrixSDK.MsgType.Emote;
+ replyContent = replyContent
+ .split("\n")
+ .map(line => `&gt; ${line}`)
+ .join("\n");
+ if (!isEmoteReply) {
+ replyContent = `${replyEvent.getSender()}:
+${replyContent}`;
+ }
+ body = replyContent + "\n" + body;
+ }
+ }
+ return body;
+}
+
+/**
+ * Adapts the formatted body of an event for display.
+ *
+ * @param {MatrixEvent} event - The event to format the body of.
+ * @param {string} homeserverUrl - The base URL of the homeserver.
+ * @param {(string) => MatrixEvent} getEvent - Get the event with the given ID.
+ * Used to fetch the replied to event.
+ * @param {boolean} [includeReply=true] - If the message should contain the
+ * message it's replying to.
+ * @returns {string} Formatted body of the event.
+ */
+function formatHTMLBody(event, homeserverUrl, getEvent, includeReply = true) {
+ const content = event.getContent();
+ const parsedBody = lazy.domParser.parseFromString(
+ `<!DOCTYPE html><html><body>${content.formatted_body}</body></html>`,
+ "text/html"
+ );
+ const textColors = parsedBody.querySelectorAll(
+ "span[data-mx-color], font[data-mx-color]"
+ );
+ for (const coloredElement of textColors) {
+ coloredElement.style.color = `#${coloredElement.dataset.mxColor}`;
+ delete coloredElement.dataset.mxColor;
+ }
+ //TODO background color
+ const userMentions = parsedBody.querySelectorAll(
+ 'a[href^="https://matrix.to/#/@"],a[href^="https://matrix.to/#/%40"]'
+ );
+ for (const mention of userMentions) {
+ let endIndex = mention.hash.indexOf("?");
+ if (endIndex == -1) {
+ endIndex = undefined;
+ }
+ const userId = decodeURIComponent(mention.hash.slice(2, endIndex));
+ const ibPerson = formatMention(userId, parsedBody);
+ mention.replaceWith(ibPerson);
+ }
+ //TODO handle room mentions but avoid event permalinks
+ const inlineImages = parsedBody.querySelectorAll("img");
+ for (const image of inlineImages) {
+ if (image.alt) {
+ if (image.src.startsWith("mxc:")) {
+ const link = parsedBody.createElement("a");
+ link.href = MatrixSDK.getHttpUriForMxc(homeserverUrl, image.src);
+ link.textContent = image.alt;
+ if (image.title) {
+ link.title = image.title;
+ }
+ image.replaceWith(link);
+ } else {
+ image.replaceWith(image.alt);
+ }
+ }
+ }
+ const reply = parsedBody.querySelector("mx-reply");
+ if (reply) {
+ if (includeReply && content.msgtype != MatrixSDK.MsgType.Emote) {
+ const eventId = event.replyEventId;
+ const replyEvent = getEvent(eventId);
+ if (replyEvent) {
+ let replyContent = getReplyContent(
+ replyEvent,
+ homeserverUrl,
+ getEvent,
+ true
+ );
+ const isEmoteReply =
+ replyEvent.getContent()?.msgtype == MatrixSDK.MsgType.Emote;
+ const newReply = parsedBody.createDocumentFragment();
+ if (!isEmoteReply) {
+ const replyTo = formatMention(replyEvent.getSender(), parsedBody);
+ newReply.append(replyTo, ":");
+ }
+ const quote = parsedBody.createElement("blockquote");
+ newReply.append(quote);
+ // eslint-disable-next-line no-unsanitized/method
+ quote.insertAdjacentHTML("afterbegin", replyContent);
+ reply.replaceWith(newReply);
+ } else {
+ // Strip mx-reply from DOM
+ reply.normalize();
+ reply.replaceWith(...reply.childNodes);
+ }
+ } else {
+ reply.remove();
+ }
+ }
+ //TODO spoilers
+ return parsedBody.body.innerHTML;
+}
+
+export var MatrixMessageContent = {
+ /**
+ * Format the plain text body of an incoming message for display.
+ *
+ * @param {MatrixEvent} event - Event to format the body of.
+ * @param {string} homeserverUrl - The base URL of the homserver used to
+ * resolve mxc URIs.
+ * @param {string => MatrixEvent} getEvent - Get the event with the given ID.
+ * Used to fetch the replied to event.
+ * @param {boolean} [includeReply=true] - If the message should contain the
+ * message it's replying to.
+ * @returns {string} Returns the formatted body ready for display or an empty
+ * string if formatting wasn't possible.
+ */
+ getIncomingPlain(event, homeserverUrl, getEvent, includeReply = true) {
+ if (
+ !event ||
+ (event.status !== null && event.status !== MatrixSDK.EventStatus.SENT)
+ ) {
+ return "";
+ }
+ const type = event.getType();
+ const content = event.getContent();
+ if (event.isRedacted()) {
+ return lazy._("message.redacted");
+ }
+ const textForEvent = lazy.getMatrixTextForEvent(event);
+ if (textForEvent) {
+ return textForEvent;
+ } else if (
+ type == MatrixSDK.EventType.RoomMessage ||
+ type == MatrixSDK.EventType.RoomMessageEncrypted
+ ) {
+ if (kRichBodiedTypes.includes(content?.msgtype)) {
+ return formatPlainBody(event, homeserverUrl, getEvent, includeReply);
+ } else if (kAttachmentTypes.includes(content?.msgtype)) {
+ const attachmentUrl = getAttachmentUrl(content, homeserverUrl);
+ if (attachmentUrl) {
+ return attachmentUrl;
+ }
+ } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
+ return lazy._("message.decrypting");
+ }
+ } else if (type == MatrixSDK.EventType.Sticker) {
+ const attachmentUrl = getAttachmentUrl(content, homeserverUrl);
+ if (attachmentUrl) {
+ return attachmentUrl;
+ }
+ } else if (type == MatrixSDK.EventType.Reaction) {
+ let annotatedEvent = getEvent(content["m.relates_to"]?.event_id);
+ if (annotatedEvent && content["m.relates_to"]?.key) {
+ return lazy._(
+ "message.reaction",
+ event.getSender(),
+ annotatedEvent.getSender(),
+ lazy.TXTToHTML(content["m.relates_to"].key)
+ );
+ }
+ }
+ return lazy.TXTToHTML(content.body ?? "");
+ },
+ /**
+ * Format the HTML body of an incoming message for display.
+ *
+ * @param {MatrixEvent} event - Event to format the body of.
+ * @param {string} homeserverUrl - The base URL of the homserver used to
+ * resolve mxc URIs.
+ * @param {string => MatrixEvent} getEvent - Get the event with the given ID.
+ * @param {boolean} [includeReply=true] - If the message should contain the
+ * message it's replying to.
+ * @returns {string} Returns a formatted body ready for display or an empty
+ * string if formatting wasn't possible.
+ */
+ getIncomingHTML(event, homeserverUrl, getEvent, includeReply = true) {
+ if (
+ !event ||
+ (event.status !== null && event.status !== MatrixSDK.EventStatus.SENT)
+ ) {
+ return "";
+ }
+ const type = event.getType();
+ const content = event.getContent();
+ if (event.isRedacted()) {
+ return lazy._("message.redacted");
+ }
+ if (type == MatrixSDK.EventType.RoomMessage) {
+ if (
+ kRichBodiedTypes.includes(content.msgtype) &&
+ content.format == kHtmlFormat &&
+ content.formatted_body
+ ) {
+ return formatHTMLBody(event, homeserverUrl, getEvent, includeReply);
+ } else if (kAttachmentTypes.includes(content.msgtype)) {
+ return formatMediaAttachment(content, homeserverUrl);
+ }
+ } else if (type == MatrixSDK.EventType.Sticker) {
+ return formatMediaAttachment(content, homeserverUrl);
+ } else if (type == MatrixSDK.EventType.Reaction) {
+ let annotatedEvent = getEvent(content["m.relates_to"]?.event_id);
+ if (annotatedEvent && content["m.relates_to"]?.key) {
+ return lazy._(
+ "message.reaction",
+ `<span class="ib-person">${event.getSender()}</span>`,
+ `<span class="ib-person">${annotatedEvent.getSender()}</span>`,
+ lazy.TXTToHTML(content["m.relates_to"].key)
+ );
+ }
+ }
+ return MatrixMessageContent.getIncomingPlain(
+ event,
+ homeserverUrl,
+ getEvent
+ );
+ },
+};
diff --git a/comm/chat/protocols/matrix/matrixPowerLevels.sys.mjs b/comm/chat/protocols/matrix/matrixPowerLevels.sys.mjs
new file mode 100644
index 0000000000..4ee1027428
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrixPowerLevels.sys.mjs
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+// See https://matrix.org/docs/spec/client_server/r0.5.0#m-room-power-levels
+export var MatrixPowerLevels = {
+ user: 0,
+ voice: 10,
+ moderator: 50,
+ admin: 100,
+ /**
+ * Turns a power level into a human readable string.
+ * Only exactly matching level names are returned, except for restricted
+ * power levels.
+ *
+ * @param {number} powerLevel - Power level to format.
+ * @param {number} [defaultLevel=0] - The default power level in the room.
+ * @returns {string} Representation of the power level including the raw level.
+ */
+ toText(powerLevel, defaultLevel = MatrixPowerLevels.user) {
+ let levelName = lazy._("powerLevel.custom");
+ if (powerLevel == MatrixPowerLevels.admin) {
+ levelName = lazy._("powerLevel.admin");
+ } else if (powerLevel == MatrixPowerLevels.moderator) {
+ levelName = lazy._("powerLevel.moderator");
+ } else if (powerLevel < defaultLevel) {
+ levelName = lazy._("powerLevel.restricted");
+ } else if (powerLevel == defaultLevel) {
+ levelName = lazy._("powerLevel.default");
+ }
+ return lazy._("powerLevel.detailed", levelName, powerLevel);
+ },
+ /**
+ * @param {object} powerLevels - m.room.power_levels event contents.
+ * @param {string} key - Power level key to get.
+ * @returns {number} The power level if given in the event, else 0.
+ */
+ _getDefaultLevel(powerLevels, key) {
+ const fullKey = `${key}_default`;
+ if (Number.isSafeInteger(powerLevels?.[fullKey])) {
+ return powerLevels[fullKey];
+ }
+ return 0;
+ },
+ /**
+ * @param {object} powerLevels - m.room.power_levels event contents.
+ * @returns {number} The default power level of users in the room.
+ */
+ getUserDefaultLevel(powerLevels) {
+ return this._getDefaultLevel(powerLevels, "users");
+ },
+ /**
+ *
+ * @param {object} powerLevels - m.room.power_levels event contents.
+ * @returns {number} The default power level required to send events in the
+ * room.
+ */
+ getEventDefaultLevel(powerLevels) {
+ return this._getDefaultLevel(powerLevels, "events");
+ },
+ /**
+ *
+ * @param {object} powerLevels - m.room.power_levels event contents.
+ * @param {string} event - Event ID to get the required power level for.
+ * @returns {number} The power level required to send this event in the room.
+ */
+ getEventLevel(powerLevels, event) {
+ if (Number.isSafeInteger(powerLevels?.events?.[event])) {
+ return powerLevels.events[event];
+ }
+ return this.getEventDefaultLevel(powerLevels);
+ },
+};
diff --git a/comm/chat/protocols/matrix/matrixTextForEvent.sys.mjs b/comm/chat/protocols/matrix/matrixTextForEvent.sys.mjs
new file mode 100644
index 0000000000..2288f66ea5
--- /dev/null
+++ b/comm/chat/protocols/matrix/matrixTextForEvent.sys.mjs
@@ -0,0 +1,330 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { MatrixSDK } from "resource:///modules/matrix-sdk.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/matrix.properties")
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MatrixPowerLevels: "resource:///modules/matrixPowerLevels.sys.mjs",
+});
+
+/**
+ * Shared handler for verification requests. We need it twice because the
+ * request can be the msgtype of a normal message or its own event.
+ *
+ * @param {MatrixEvent} matrixEvent - Matrix Event this is handling.
+ * @param {{sender: string, content: object}} param1 - handler context.
+ * @returns {string}
+ */
+const keyVerificationRequest = (matrixEvent, { sender, content }) => {
+ return lazy._("message.verification.request2", sender, content.to);
+};
+/**
+ * Shared handler for room messages, since those come in the plain text and
+ * encrypted form.
+ */
+const roomMessage = {
+ pivot: "msgtype",
+ handlers: {
+ [MatrixSDK.MsgType.KeyVerificationRequest]: keyVerificationRequest,
+ "m.bad.encrypted": () => lazy._("message.decryptionError"),
+ },
+};
+
+/**
+ * Functions returning notices to display when matrix events are received.
+ * Top level key is the matrix event type.
+ *
+ * If the object then has a "pivot" key, the value of that key is used to get
+ * the value of the key with that name from the event content. This value from
+ * event content picks the handler from handlers.
+ *
+ * If there is no pivot, the method on the key "handler" is called.
+ *
+ * Handlers are called with the following arguments:
+ * - matrixEvent: MatrixEvent
+ * - context: {
+ * sender: user ID of the sender,
+ * content: event content object,
+ * }
+ * A handler is expected to return a string that is displayed as notice. If
+ * nothing is returned, no notice will be shown. The formatContext function
+ * optionally adds values to the context argument for the handler.
+ */
+const MATRIX_EVENT_HANDLERS = {
+ [MatrixSDK.EventType.RoomMember]: {
+ pivot: "membership",
+ formatContext(matrixEvent, { sender, content }) {
+ return {
+ sender,
+ content,
+ target: matrixEvent.target,
+ prevContent: matrixEvent.getPrevContent(),
+ reason: content.reason,
+ withReasonKey: content.reason ? "WithReason" : "",
+ };
+ },
+ handlers: {
+ ban(matrixEvent, { sender, target, reason, withReasonKey }) {
+ return lazy._(
+ "message.banned" + withReasonKey,
+ sender,
+ target.userId,
+ reason
+ );
+ },
+ invite(matrixEvent, { sender, content, target }) {
+ const thirdPartyInvite = content.third_party_invite;
+ if (thirdPartyInvite) {
+ if (thirdPartyInvite.display_name) {
+ return lazy._(
+ "message.acceptedInviteFor",
+ target.userId,
+ thirdPartyInvite.display_name
+ );
+ }
+ return lazy._("message.acceptedInvite", target.userId);
+ }
+ return lazy._("message.invited", sender, target.userId);
+ },
+ join(matrixEvent, { sender, content, prevContent, target }) {
+ if (prevContent && prevContent.membership == "join") {
+ if (
+ prevContent.displayname &&
+ content.displayname &&
+ prevContent.displayname != content.displayname
+ ) {
+ return lazy._(
+ "message.displayName.changed",
+ sender,
+ prevContent.displayname,
+ content.displayname
+ );
+ } else if (!prevContent.displayname && content.displayname) {
+ return lazy._(
+ "message.displayName.set",
+ sender,
+ content.displayname
+ );
+ } else if (prevContent.displayname && !content.displayname) {
+ return lazy._(
+ "message.displayName.remove",
+ sender,
+ prevContent.displayname
+ );
+ }
+ return null;
+ }
+ return lazy._("message.joined", target.userId);
+ },
+ leave(
+ matrixEvent,
+ { sender, prevContent, target, reason, withReasonKey }
+ ) {
+ // kick and unban just change the membership to "leave".
+ // So we need to look at each transition to what happened to the user.
+ if (matrixEvent.getSender() === target.userId) {
+ if (prevContent.membership === "invite") {
+ return lazy._("message.rejectedInvite", target.userId);
+ }
+ return lazy._("message.left", target.userId);
+ } else if (prevContent.membership === "ban") {
+ return lazy._("message.unbanned", sender, target.userId);
+ } else if (prevContent.membership === "join") {
+ return lazy._(
+ "message.kicked" + withReasonKey,
+ sender,
+ target.userId,
+ reason
+ );
+ } else if (prevContent.membership === "invite") {
+ return lazy._(
+ "message.withdrewInvite" + withReasonKey,
+ sender,
+ target.userId,
+ reason
+ );
+ }
+ // ignore rest of the cases.
+ return null;
+ },
+ },
+ },
+ [MatrixSDK.EventType.RoomPowerLevels]: {
+ handler(matrixEvent, { sender, content }) {
+ const prevContent = matrixEvent.getPrevContent();
+ if (!prevContent?.users) {
+ return null;
+ }
+ const userDefault = content.users_default || lazy.MatrixPowerLevels.user;
+ const prevDefault =
+ prevContent.users_default || lazy.MatrixPowerLevels.user;
+ // Construct set of userIds.
+ let users = new Set(
+ Object.keys(content.users).concat(Object.keys(prevContent.users))
+ );
+ const changes = Array.from(users)
+ .map(userId => {
+ const prevPowerLevel = prevContent.users[userId] ?? prevDefault;
+ const currentPowerLevel = content.users[userId] ?? userDefault;
+ if (prevPowerLevel !== currentPowerLevel) {
+ // Handling the case where there are multiple changes.
+ // Example : "@Mr.B:matrix.org changed the power level of
+ // @Mr.B:matrix.org from Default (0) to Moderator (50)."
+ return lazy._(
+ "message.powerLevel.fromTo",
+ userId,
+ lazy.MatrixPowerLevels.toText(prevPowerLevel, prevDefault),
+ lazy.MatrixPowerLevels.toText(currentPowerLevel, userDefault)
+ );
+ }
+ return null;
+ })
+ .filter(change => Boolean(change));
+ // Since the power levels event also contains role power levels, not
+ // every event update will affect user power levels.
+ if (!changes.length) {
+ return null;
+ }
+ return lazy._("message.powerLevel.changed", sender, changes.join(", "));
+ },
+ },
+ [MatrixSDK.EventType.RoomName]: {
+ handler(matrixEvent, { sender, content }) {
+ let roomName = content.name;
+ if (!roomName) {
+ return lazy._("message.roomName.remove", sender);
+ }
+ return lazy._("message.roomName.changed", sender, roomName);
+ },
+ },
+ [MatrixSDK.EventType.RoomGuestAccess]: {
+ pivot: "guest_access",
+ handlers: {
+ [MatrixSDK.GuestAccess.Forbidden](matrixEvent, { sender }) {
+ return lazy._("message.guest.prevented", sender);
+ },
+ [MatrixSDK.GuestAccess.CanJoin](matrixEvent, { sender }) {
+ return lazy._("message.guest.allowed", sender);
+ },
+ },
+ },
+ [MatrixSDK.EventType.RoomHistoryVisibility]: {
+ pivot: "history_visibility",
+ handlers: {
+ [MatrixSDK.HistoryVisibility.WorldReadable](matrixEvent, { sender }) {
+ return lazy._("message.history.anyone", sender);
+ },
+ [MatrixSDK.HistoryVisibility.Shared](matrixEvent, { sender }) {
+ return lazy._("message.history.shared", sender);
+ },
+ [MatrixSDK.HistoryVisibility.Invited](matrixEvent, { sender }) {
+ return lazy._("message.history.invited", sender);
+ },
+ [MatrixSDK.HistoryVisibility.Joined](matrixEvent, { sender }) {
+ return lazy._("message.history.joined", sender);
+ },
+ },
+ },
+ [MatrixSDK.EventType.RoomCanonicalAlias]: {
+ handler(matrixEvent, { sender, content }) {
+ const prevContent = matrixEvent.getPrevContent();
+ if (content.alias != prevContent.alias) {
+ return lazy._(
+ "message.alias.main",
+ sender,
+ prevContent.alias,
+ content.alias
+ );
+ }
+ const prevAliases = prevContent.alt_aliases || [];
+ const aliases = content.alt_aliases || [];
+ const addedAliases = aliases
+ .filter(alias => !prevAliases.includes(alias))
+ .join(", ");
+ const removedAliases = prevAliases
+ .filter(alias => !aliases.includes(alias))
+ .join(", ");
+ if (addedAliases && removedAliases) {
+ return lazy._(
+ "message.alias.removedAndAdded",
+ sender,
+ removedAliases,
+ addedAliases
+ );
+ } else if (removedAliases) {
+ return lazy._("message.alias.removed", sender, removedAliases);
+ } else if (addedAliases) {
+ return lazy._("message.alias.added", sender, addedAliases);
+ }
+ // No discernible changes to aliases
+ return null;
+ },
+ },
+
+ [MatrixSDK.EventType.RoomMessage]: roomMessage,
+ [MatrixSDK.EventType.RoomMessageEncrypted]: roomMessage,
+ [MatrixSDK.EventType.KeyVerificationRequest]: {
+ handler: keyVerificationRequest,
+ },
+ [MatrixSDK.EventType.KeyVerificationCancel]: {
+ handler(matrixEvent, { sender, content }) {
+ return lazy._("message.verification.cancel2", sender, content.reason);
+ },
+ },
+ [MatrixSDK.EventType.KeyVerificationDone]: {
+ handler(matrixEvent, { sender, content }) {
+ return lazy._("message.verification.done");
+ },
+ },
+ [MatrixSDK.EventType.RoomEncryption]: {
+ handler(matrixEvent, { sender, content }) {
+ return lazy._("message.encryptionStart");
+ },
+ },
+
+ // TODO : Events to be handled:
+ // 'm.call.invite'
+ // 'm.call.answer'
+ // 'm.call.hangup'
+ // 'm.room.third_party_invite'
+
+ // NOTE : No need to add string messages for 'm.room.topic' events,
+ // as setTopic is used which handles the messages too.
+};
+
+/**
+ * Generates a notice string for a matrix event. May return null if no notice
+ * should be shown.
+ *
+ * @param {MatrixEvent} matrixEvent - Matrix event to generate a notice for.
+ * @returns {string?} Text to display as notice for the given event.
+ */
+export function getMatrixTextForEvent(matrixEvent) {
+ const context = {
+ sender: matrixEvent.getSender(),
+ content: matrixEvent.getContent(),
+ };
+ const eventHandlingInformation = MATRIX_EVENT_HANDLERS[matrixEvent.getType()];
+ if (!eventHandlingInformation) {
+ return null;
+ }
+ const details =
+ eventHandlingInformation.formatContext?.(matrixEvent, context) ?? context;
+ if (eventHandlingInformation.pivot) {
+ const pivotValue = context.content[eventHandlingInformation.pivot];
+ return (
+ eventHandlingInformation.handlers[pivotValue]?.(matrixEvent, details) ??
+ null
+ );
+ }
+ return eventHandlingInformation.handler(matrixEvent, details);
+}
diff --git a/comm/chat/protocols/matrix/moz.build b/comm/chat/protocols/matrix/moz.build
new file mode 100644
index 0000000000..697cfc636b
--- /dev/null
+++ b/comm/chat/protocols/matrix/moz.build
@@ -0,0 +1,29 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+DIRS += [
+ "lib",
+ "shims",
+]
+
+EXTRA_JS_MODULES += [
+ "matrix-sdk.sys.mjs",
+ "matrix.sys.mjs",
+ "matrixAccount.sys.mjs",
+ "matrixCommands.sys.mjs",
+ "matrixMessageContent.sys.mjs",
+ "matrixPowerLevels.sys.mjs",
+ "matrixTextForEvent.sys.mjs",
+]
+
+JAR_MANIFESTS += [
+ "jar.mn",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/matrix/shims/empty.js b/comm/chat/protocols/matrix/shims/empty.js
new file mode 100644
index 0000000000..ef18a9a5ac
--- /dev/null
+++ b/comm/chat/protocols/matrix/shims/empty.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals exports */
+
+/*
+ * This module serves as a shim empty module for any empty ts type definition
+ * module one of the libraries might try to require.
+ */
+
+Object.defineProperty(exports, "__esModule", {
+ value: true,
+});
diff --git a/comm/chat/protocols/matrix/shims/loglevel.js b/comm/chat/protocols/matrix/shims/loglevel.js
new file mode 100644
index 0000000000..9998435d40
--- /dev/null
+++ b/comm/chat/protocols/matrix/shims/loglevel.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals scriptError, imIDebugMessage, module */
+
+let _loggers = {};
+
+/*
+ * Implement a custom logger to to hook the Matrix logging up to the chat
+ * logging framework.
+ *
+ * This unfortunately does not enable account specific logging.
+ */
+function getLogger(loggerName) {
+ let moduleName = "prpl-matrix." + loggerName;
+
+ let logger = _loggers[moduleName];
+
+ // If the logger was previously created, return it.
+ if (logger) {
+ return logger;
+ }
+
+ // Otherwise, build a new logger.
+ logger = {};
+ logger.trace = scriptError.bind(
+ logger,
+ moduleName,
+ imIDebugMessage.LEVEL_DEBUG
+ );
+ logger.debug = scriptError.bind(
+ logger,
+ moduleName,
+ imIDebugMessage.LEVEL_DEBUG
+ );
+ logger.log = scriptError.bind(
+ logger,
+ moduleName,
+ imIDebugMessage.LEVEL_DEBUG
+ );
+ logger.info = scriptError.bind(logger, moduleName, imIDebugMessage.LEVEL_LOG);
+ logger.warn = scriptError.bind(
+ logger,
+ moduleName,
+ imIDebugMessage.LEVEL_WARNING
+ );
+ logger.error = scriptError.bind(
+ logger,
+ moduleName,
+ imIDebugMessage.LEVEL_ERROR
+ );
+
+ // This is a no-op since log levels are configured via preferences.
+ logger.setLevel = function (level) {};
+
+ _loggers[moduleName] = logger;
+
+ return logger;
+}
+
+module.exports = {
+ getLogger,
+ levels: {
+ TRACE: imIDebugMessage.LEVEL_DEBUG,
+ DEBUG: imIDebugMessage.LEVEL_DEBUG,
+ INFO: imIDebugMessage.LEVEL_LOG,
+ WARN: imIDebugMessage.LEVEL_WARNING,
+ ERROR: imIDebugMessage.LEVEL_ERROR,
+ },
+};
diff --git a/comm/chat/protocols/matrix/shims/moz.build b/comm/chat/protocols/matrix/shims/moz.build
new file mode 100644
index 0000000000..522fade712
--- /dev/null
+++ b/comm/chat/protocols/matrix/shims/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Shimmed dependencies of the matrix-js-sdk that we can provide through direct
+# implementation instead of through npm packages.
+
+EXTRA_JS_MODULES.matrix += [
+ "empty.js",
+ "loglevel.js",
+ "safe-buffer.js",
+ "uuid.js",
+]
diff --git a/comm/chat/protocols/matrix/shims/safe-buffer.js b/comm/chat/protocols/matrix/shims/safe-buffer.js
new file mode 100644
index 0000000000..1bc806d24e
--- /dev/null
+++ b/comm/chat/protocols/matrix/shims/safe-buffer.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals module */
+
+/*
+ * Per the Node.js documentation, a Buffer is an instance of Uint8Array, but
+ * additional class methods are missing for it.
+ *
+ * https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
+ */
+class Buffer extends Uint8Array {
+ static isBuffer(obj) {
+ return obj instanceof Uint8Array;
+ }
+
+ // Note that this doesn't fully implement allocate, only enough is implemented
+ // for the base-x package to function properly.
+ static alloc(size, fill, encoding) {
+ return new Buffer(size);
+ }
+
+ static allocUnsafe(size) {
+ return new Buffer(size);
+ }
+
+ // Add base 64 conversion support, used to safely transmit encrypted data.
+ static from(...args) {
+ if (args[1] === "base64") {
+ return super.from(atob(args[0]), character => character.charCodeAt(0));
+ }
+ return super.from(...args);
+ }
+
+ toString(target) {
+ if (target === "base64") {
+ return btoa(String.fromCharCode(...this.values()));
+ }
+ return super.toString();
+ }
+}
+
+module.exports = {
+ Buffer,
+};
diff --git a/comm/chat/protocols/matrix/shims/uuid.js b/comm/chat/protocols/matrix/shims/uuid.js
new file mode 100644
index 0000000000..ecdb036591
--- /dev/null
+++ b/comm/chat/protocols/matrix/shims/uuid.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals module */
+
+const v4 = () => crypto.randomUUID();
+
+module.exports = {
+ v4,
+};
diff --git a/comm/chat/protocols/matrix/test/head.js b/comm/chat/protocols/matrix/test/head.js
new file mode 100644
index 0000000000..ecb485ec6d
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/head.js
@@ -0,0 +1,291 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+const { MatrixProtocol } = ChromeUtils.importESModule(
+ "resource:///modules/matrix.sys.mjs"
+);
+const { MatrixRoom, MatrixAccount, MatrixMessage } = ChromeUtils.importESModule(
+ "resource:///modules/matrixAccount.sys.mjs"
+);
+var { MatrixSDK } = ChromeUtils.importESModule(
+ "resource:///modules/matrix-sdk.sys.mjs"
+);
+function loadMatrix() {
+ IMServices.conversations.initConversations();
+}
+
+/**
+ * Get a MatrixRoom instance with a mocked client.
+ *
+ * @param {boolean} isMUC
+ * @param {string} [name="#test:example.com"]
+ * @param {(any, string) => any?|object} [clientHandler]
+ * @returns {MatrixRoom}
+ */
+function getRoom(
+ isMUC,
+ name = "#test:example.com",
+ clientHandler = () => undefined,
+ account
+) {
+ if (!account) {
+ account = getAccount(clientHandler);
+ }
+ const room = getClientRoom(name, clientHandler, account._client);
+ const conversation = new MatrixRoom(account, isMUC, name);
+ conversation.initRoom(room);
+ return conversation;
+}
+
+/**
+ *
+ * @param {string} roomId
+ * @param {(any, string) => any?|object} clientHandler
+ * @param {MatrixClient} client
+ * @returns {Room}
+ */
+function getClientRoom(roomId, clientHandler, client) {
+ const room = new Proxy(
+ {
+ roomId,
+ name: roomId,
+ tags: {},
+ getJoinedMembers() {
+ return [];
+ },
+ getAvatarUrl() {
+ return "";
+ },
+ getLiveTimeline() {
+ return {
+ getState() {
+ return {
+ getStateEvents() {
+ return [];
+ },
+ };
+ },
+ };
+ },
+ isSpaceRoom() {
+ return false;
+ },
+ getLastActiveTimestamp() {
+ return Date.now();
+ },
+ getMyMembership() {
+ return "join";
+ },
+ getAccountData(key) {
+ return null;
+ },
+ getUnfilteredTimelineSet() {
+ return {
+ getLiveTimeline() {
+ return {
+ getEvents() {
+ return [];
+ },
+ getBaseIndex() {
+ return 0;
+ },
+ getNeighbouringTimeline() {
+ return null;
+ },
+ getPaginationToken() {
+ return "";
+ },
+ };
+ },
+ };
+ },
+ guessDMUserId() {
+ return "@other:example.com";
+ },
+ loadMembersIfNeeded() {
+ return Promise.resolve();
+ },
+ getEncryptionTargetMembers() {
+ return Promise.resolve([]);
+ },
+ },
+ makeProxyHandler(clientHandler)
+ );
+ client._rooms.set(roomId, room);
+ return room;
+}
+
+/**
+ *
+ * @param {(any, string) => any?|object} clientHandler
+ * @returns {MatrixAccount}
+ */
+function getAccount(clientHandler) {
+ const account = new MatrixAccount(Object.create(MatrixProtocol.prototype), {
+ logDebugMessage(message) {
+ account._errors.push(message.message);
+ },
+ });
+ account._errors = [];
+ account._client = new Proxy(
+ {
+ _rooms: new Map(),
+ credentials: {
+ userId: "@user:example.com",
+ },
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ getRoom(roomId) {
+ return this._rooms.get(roomId);
+ },
+ async joinRoom(roomId) {
+ if (!this._rooms.has(roomId)) {
+ getClientRoom(roomId, clientHandler, this);
+ }
+ return this._rooms.get(roomId);
+ },
+ setAccountData(field, data) {},
+ async createRoom(spec) {
+ const roomId =
+ "!" + spec.name + ":example.com" || "!newroom:example.com";
+ if (!this._rooms.has(roomId)) {
+ getClientRoom(roomId, clientHandler, this);
+ }
+ return {
+ room_id: roomId,
+ };
+ },
+ getRooms() {
+ return Array.from(this._rooms.values());
+ },
+ getVisibleRooms() {
+ return Array.from(this._rooms.values());
+ },
+ isCryptoEnabled() {
+ return false;
+ },
+ getPushActionsForEvent() {
+ return {};
+ },
+ leave(roomId) {
+ this._rooms.delete(roomId);
+ },
+ downloadKeys() {
+ return Promise.resolve({});
+ },
+ getUser(userId) {
+ return {
+ displayName: userId,
+ userId,
+ };
+ },
+ getStoredDevicesForUser() {
+ return [];
+ },
+ isRoomEncrypted(roomId) {
+ return false;
+ },
+ },
+ makeProxyHandler(clientHandler)
+ );
+ return account;
+}
+
+/**
+ * @param {(any, string) => any?|object} [clientHandler]
+ * @returns {object}
+ */
+function makeProxyHandler(clientHandler) {
+ return {
+ get(target, key, receiver) {
+ if (typeof clientHandler === "function") {
+ const value = clientHandler(target, key);
+ if (value) {
+ return value;
+ }
+ } else if (clientHandler.hasOwnProperty(key)) {
+ return clientHandler[key];
+ }
+ return target[key];
+ },
+ };
+}
+
+/**
+ * Build a MatrixEvent like object from a plain object.
+ *
+ * @param {{ type: MatrixSDK.EventType, content: object, sender: string, id: number, redacted: boolean, time: Date }} eventSpec - Data the event holds.
+ * @returns {MatrixEvent}
+ */
+function makeEvent(eventSpec = {}) {
+ const time = eventSpec.time || new Date();
+ return {
+ isRedacted() {
+ return eventSpec.redacted || false;
+ },
+ getType() {
+ return eventSpec.type;
+ },
+ getContent() {
+ return eventSpec.content || {};
+ },
+ getPrevContent() {
+ return eventSpec.prevContent || {};
+ },
+ getWireContent() {
+ return eventSpec.content;
+ },
+ getSender() {
+ return eventSpec.sender;
+ },
+ getDate() {
+ return time;
+ },
+ sender: {
+ name: "foo bar",
+ getAvatarUrl() {
+ return "https://example.com/avatar";
+ },
+ },
+ getId() {
+ return eventSpec.id || 0;
+ },
+ isEncrypted() {
+ return (
+ eventSpec.type == MatrixSDK.EventType.RoomMessageEncrypted ||
+ eventSpec.isEncrypted
+ );
+ },
+ shouldAttemptDecryption() {
+ return Boolean(eventSpec.shouldDecrypt);
+ },
+ isBeingDecrypted() {
+ return Boolean(eventSpec.decrypting);
+ },
+ isDecryptionFailure() {
+ return eventSpec.content?.msgtype == "m.bad.encrypted";
+ },
+ isRedaction() {
+ return eventSpec.type == MatrixSDK.EventType.RoomRedaction;
+ },
+ getRedactionEvent() {
+ return eventSpec.redaction;
+ },
+ target: eventSpec.target,
+ replyEventId:
+ eventSpec.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id,
+ threadRootId: eventSpec.threadRootId || null,
+ getRoomId() {
+ return eventSpec.roomId || "!test:example.com";
+ },
+ status: eventSpec.status || null,
+ _listeners: {},
+ once(event, listener) {
+ this._listeners[event] = listener;
+ },
+ };
+}
diff --git a/comm/chat/protocols/matrix/test/test_matrixAccount.js b/comm/chat/protocols/matrix/test/test_matrixAccount.js
new file mode 100644
index 0000000000..e49d150a21
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixAccount.js
@@ -0,0 +1,399 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+loadMatrix();
+
+add_task(function test_getConversationById() {
+ const mockAccount = {
+ roomList: new Map(),
+ _pendingRoomAliases: new Map(),
+ };
+ mockAccount.roomList.set("foo", "bar");
+ mockAccount._pendingRoomAliases.set("lorem", "ipsum");
+
+ equal(MatrixAccount.prototype.getConversationById.call(mockAccount), null);
+ equal(
+ MatrixAccount.prototype.getConversationById.call(mockAccount, "foo"),
+ "bar"
+ );
+ equal(
+ MatrixAccount.prototype.getConversationById.call(mockAccount, "lorem"),
+ "ipsum"
+ );
+});
+
+add_task(function test_getConversationByIdOrAlias() {
+ const mockAccount = {
+ getConversationById(id) {
+ if (id === "foo") {
+ return "bar";
+ }
+ if (id === "_lorem") {
+ return "ipsum";
+ }
+ return null;
+ },
+ _client: {
+ getRoom(id) {
+ if (id === "lorem") {
+ return {
+ roomId: "_" + id,
+ };
+ }
+ return null;
+ },
+ },
+ };
+
+ equal(
+ MatrixAccount.prototype.getConversationByIdOrAlias.call(mockAccount),
+ null
+ );
+ equal(
+ MatrixAccount.prototype.getConversationByIdOrAlias.call(mockAccount, "foo"),
+ "bar"
+ );
+ equal(
+ MatrixAccount.prototype.getConversationByIdOrAlias.call(
+ mockAccount,
+ "lorem"
+ ),
+ "ipsum"
+ );
+ equal(
+ MatrixAccount.prototype.getConversationByIdOrAlias.call(mockAccount, "baz"),
+ null
+ );
+});
+
+add_task(async function test_getGroupConversation() {
+ registerCleanupFunction(() => {
+ const conversations = IMServices.conversations.getConversations();
+ for (const conversation of conversations) {
+ try {
+ conversation.forget();
+ } catch {}
+ }
+ });
+
+ let allowedGetRoomIds = new Set(["baz"]);
+ const mockAccount = getAccount({
+ getRoom(roomId) {
+ if (this._rooms.has(roomId)) {
+ return this._rooms.get(roomId);
+ }
+ if (allowedGetRoomIds.has(roomId)) {
+ return getClientRoom("baz", {}, mockAccount._client);
+ }
+ return null;
+ },
+ async joinRoom(roomId) {
+ if (roomId === "lorem") {
+ allowedGetRoomIds.add(roomId);
+ return getClientRoom(roomId, {}, mockAccount._client);
+ } else if (roomId.endsWith(":example.com")) {
+ const error = new Error("not found");
+ error.errcode = "M_NOT_FOUND";
+ throw error;
+ }
+ throw new Error("Could not join");
+ },
+ getDomain() {
+ return "example.com";
+ },
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ leave(roomId) {
+ this._rooms.delete(roomId);
+ mockAccount.left = true;
+ },
+ });
+ const fooRoom = getRoom(true, "bar", {}, mockAccount);
+ mockAccount.roomList.set("foo", fooRoom);
+
+ equal(mockAccount.getGroupConversation(""), null, "No room with empty ID");
+ equal(
+ mockAccount.getGroupConversation("foo").name,
+ "bar",
+ "Room with expected name"
+ );
+ fooRoom.close();
+
+ const existingRoom = mockAccount.getGroupConversation("baz");
+ await existingRoom.waitForRoom();
+ strictEqual(existingRoom, mockAccount.roomList.get("baz"));
+ ok(!existingRoom.joining, "Not joining existing room");
+ existingRoom.close();
+
+ const joinedRoom = mockAccount.getGroupConversation("lorem");
+ ok(joinedRoom.joining, "joining room");
+ allowedGetRoomIds.add("lorem");
+ await joinedRoom.waitForRoom();
+ strictEqual(joinedRoom, mockAccount.roomList.get("lorem"));
+ ok(!joinedRoom.joining, "Joined room");
+ joinedRoom.close();
+
+ const createdRoom = mockAccount.getGroupConversation("#ipsum:example.com");
+ ok(createdRoom.joining, "Joining new room");
+ await createdRoom.waitForRoom();
+ ok(!createdRoom.joining, "Joined new room");
+ strictEqual(createdRoom, mockAccount.roomList.get("!ipsum:example.com"));
+ // Wait for catchup to complete.
+ await TestUtils.waitForTick();
+ createdRoom.close();
+
+ const roomAlreadyBeingCreated =
+ mockAccount.getGroupConversation("#lorem:example.com");
+ ok(
+ roomAlreadyBeingCreated.joining,
+ "Joining room that is about to get replaced"
+ );
+ const pendingRoom = getRoom(true, "hi", {}, mockAccount);
+ mockAccount._pendingRoomAliases.set("#lorem:example.com", pendingRoom);
+ await roomAlreadyBeingCreated.waitForRoom();
+ ok(!roomAlreadyBeingCreated.joining, "Not joining replaced room");
+ ok(roomAlreadyBeingCreated._replacedBy, "Room got replaced");
+ pendingRoom.forget();
+
+ const missingLocalRoom =
+ mockAccount.getGroupConversation("!ipsum:example.com");
+ await TestUtils.waitForTick();
+ ok(!missingLocalRoom.joining, "Not joining missing room");
+ ok(mockAccount.left, "Left missing room");
+
+ mockAccount.left = false;
+ const unjoinableRemoteRoom =
+ mockAccount.getGroupConversation("#test:matrix.org");
+ await TestUtils.waitForTick();
+ ok(!unjoinableRemoteRoom.joining, "Not joining unjoinable room");
+ ok(mockAccount.left, "Left unjoinable room");
+});
+
+add_task(async function test_joinChat() {
+ const roomId = "!foo:example.com";
+ const conversation = {
+ waitForRoom() {
+ return Promise.resolve();
+ },
+ checkForUpdate() {
+ this.checked = true;
+ },
+ };
+ const mockAccount = {
+ getGroupConversation(id) {
+ this.groupConv = id;
+ return conversation;
+ },
+ };
+ const components = {
+ getValue(key) {
+ if (key === "roomIdOrAlias") {
+ return roomId;
+ }
+ ok(false, "Unknown chat room field");
+ return null;
+ },
+ };
+
+ const conv = MatrixAccount.prototype.joinChat.call(mockAccount, components);
+ equal(mockAccount.groupConv, roomId);
+ strictEqual(conv, conversation);
+ await Promise.resolve();
+ ok(conversation.checked);
+});
+
+add_task(async function test_getDMRoomIdsForUserId() {
+ const account = getAccount({
+ getRoom(roomId) {
+ if (roomId === "!invalid:example.com") {
+ return null;
+ }
+ return getClientRoom(
+ roomId,
+ {
+ isSpaceRoom() {
+ return roomId === "!space:example.com";
+ },
+ getMyMembership() {
+ return roomId === "!left:example.com" ? "leave" : "join";
+ },
+ getMember(userId) {
+ return {
+ membership: "invite",
+ };
+ },
+ },
+ account._client
+ );
+ },
+ });
+ account._userToRoom = {
+ "@test:example.com": [
+ "!asdf:example.com",
+ "!space:example.com",
+ "!left:example.com",
+ "!invalid:example.com",
+ ],
+ };
+ const invalid = account.getDMRoomIdsForUserId("@nouser:example.com");
+ ok(Array.isArray(invalid));
+ equal(invalid.length, 0);
+
+ const rooms = account.getDMRoomIdsForUserId("@test:example.com");
+ ok(Array.isArray(rooms));
+ equal(rooms.length, 1);
+ equal(rooms[0], "!asdf:example.com");
+});
+
+add_task(async function test_invitedToDMIn_deny() {
+ const dmRoomId = "!test:example.com";
+ let leftRoom = false;
+ const account = getAccount({
+ leave(roomId) {
+ equal(roomId, dmRoomId);
+ leftRoom = true;
+ },
+ });
+ const room = getClientRoom(
+ dmRoomId,
+ {
+ getDMInviter() {
+ return "@other:example.com";
+ },
+ },
+ account._client
+ );
+ const requestObserver = TestUtils.topicObserved(
+ "buddy-authorization-request"
+ );
+ account.invitedToDM(room);
+ const [request] = await requestObserver;
+ request.QueryInterface(Ci.prplIBuddyRequest);
+ equal(request.userName, "@other:example.com");
+ request.deny();
+ ok(leftRoom);
+});
+
+add_task(async function test_nameIsMXID() {
+ const account = getAccount();
+ account.imAccount.name = "@test:example.com";
+ ok(account.nameIsMXID);
+ account.imAccount.name = "@test:example.com:8443";
+ ok(account.nameIsMXID);
+ account.imAccount.name = "test:example.com";
+ ok(!account.nameIsMXID);
+ account.imAccount.name = "test";
+ ok(!account.nameIsMXID);
+});
+
+add_task(async function test_invitedToChat_deny() {
+ const chatRoomId = "!test:xample.com";
+ let leftRoom = false;
+ const account = getAccount({
+ leave(roomId) {
+ equal(roomId, chatRoomId);
+ leftRoom = true;
+ return Promise.resolve();
+ },
+ });
+ const room = getClientRoom(
+ chatRoomId,
+ {
+ getCanonicalAlias() {
+ return "#foo:example.com";
+ },
+ },
+ account._client
+ );
+ const requestObserver = TestUtils.topicObserved("conv-authorization-request");
+ account.invitedToChat(room);
+ const [request] = await requestObserver;
+ request.QueryInterface(Ci.prplIChatRequest);
+ equal(request.conversationName, "#foo:example.com");
+ ok(request.canDeny);
+ request.deny();
+ ok(leftRoom);
+});
+
+add_task(async function test_invitedToChat_cannotDenyServerNotice() {
+ const chatRoomId = "!test:xample.com";
+ const account = getAccount({});
+ const room = getClientRoom(
+ chatRoomId,
+ {
+ getCanonicalAlias() {
+ return "#foo:example.com";
+ },
+ tags: {
+ "m.server_notice": true,
+ },
+ },
+ account._client
+ );
+ console.log(room.tags);
+ const requestObserver = TestUtils.topicObserved("conv-authorization-request");
+ account.invitedToChat(room);
+ const [request] = await requestObserver;
+ request.QueryInterface(Ci.prplIChatRequest);
+ equal(request.conversationName, "#foo:example.com");
+ ok(!request.canDeny);
+});
+
+add_task(async function test_deleteAccount() {
+ let clientLoggedIn = true;
+ let storesCleared;
+ let storesPromise = new Promise(resolve => {
+ storesCleared = resolve;
+ });
+ let stopped = false;
+ let removedListeners;
+ const account = getAccount({
+ isLoggedIn() {
+ return true;
+ },
+ logout() {
+ clientLoggedIn = false;
+ return Promise.resolve();
+ },
+ clearStores() {
+ storesCleared();
+ },
+ stopClient() {
+ stopped = true;
+ },
+ removeAllListeners(type) {
+ removedListeners = type;
+ },
+ });
+ const conv = account.getGroupConversation("example");
+ await conv.waitForRoom();
+ const timeout = setTimeout(() => ok(false), 1000); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ account._verificationRequestTimeouts.add(timeout);
+ let verificationRequestCancelled = false;
+ account._pendingOutgoingVerificationRequests.set("foo", {
+ cancel() {
+ verificationRequestCancelled = true;
+ return Promise.reject(new Error("test"));
+ },
+ });
+ account.remove();
+ account.unInit();
+ await storesPromise;
+ ok(!clientLoggedIn, "logged out");
+ ok(
+ !IMServices.conversations.getConversations().includes(conv),
+ "room closed"
+ );
+ ok(verificationRequestCancelled, "verification request cancelled");
+ ok(stopped);
+ equal(removedListeners, MatrixSDK.ClientEvent.Sync);
+ equal(account._verificationRequestTimeouts.size, 0);
+});
diff --git a/comm/chat/protocols/matrix/test/test_matrixCommands.js b/comm/chat/protocols/matrix/test/test_matrixCommands.js
new file mode 100644
index 0000000000..e65ff195e7
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixCommands.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { commands } = ChromeUtils.importESModule(
+ "resource:///modules/matrixCommands.sys.mjs"
+);
+
+add_task(function testUnhandledEmptyCommands() {
+ const noopCommands = [
+ "ban",
+ "unban",
+ "invite",
+ "kick",
+ "nick",
+ "op",
+ "deop",
+ "topic",
+ "roomname",
+ "addalias",
+ "removealias",
+ "upgraderoom",
+ "me",
+ "msg",
+ "join",
+ ];
+ for (const command of commands) {
+ if (noopCommands.includes(command.name)) {
+ ok(
+ !command.run(""),
+ "Command " + command.name + " reports it handled no arguments"
+ );
+ ok(
+ !command.run(" "),
+ "Command " +
+ command.name +
+ " reports it handled purely whitespace arguments"
+ );
+ }
+ }
+});
+
+add_task(function testHelpString() {
+ for (const command of commands) {
+ const helpString = command.helpString;
+ equal(
+ typeof helpString,
+ "string",
+ "Usage help for " + command.name + " is not a string"
+ );
+ ok(
+ helpString.includes(command.name),
+ command.name + " is not mentioned in its help string"
+ );
+ }
+});
+
+add_task(function testTopic() {
+ const conversation = {
+ wrappedJSObject: {
+ set topic(value) {
+ conversation._topic = value;
+ },
+ },
+ };
+ const topic = "foo bar";
+ const command = _getRunCommand("topic");
+ const result = command(topic, conversation);
+ ok(result, "Setting topic was not handled");
+ equal(conversation._topic, topic, "Topic not correctly set");
+});
+
+add_task(async function testMsgSuccess() {
+ const targetUser = "@test:example.com";
+ const directMessage = "lorem ipsum";
+ let onMessage;
+ const sendMsgPromise = new Promise(resolve => {
+ onMessage = resolve;
+ });
+ const dm = {
+ waitForRoom() {
+ return Promise.resolve(this);
+ },
+ sendMsg(message) {
+ onMessage(message);
+ },
+ };
+ const conversation = {
+ wrappedJSObject: {
+ _account: {
+ getDirectConversation(userId) {
+ if (userId === targetUser) {
+ return dm;
+ }
+ return null;
+ },
+ },
+ },
+ };
+ const command = _getRunCommand("msg");
+ const result = command(targetUser + " " + directMessage, conversation);
+ ok(result, "Sending direct message was not handled");
+ const message = await sendMsgPromise;
+ equal(message, directMessage, "Message was not sent in DM room");
+});
+
+add_task(function testMsgMissingMessage() {
+ const targetUser = "@test:example.com";
+ const conversation = {};
+ const command = _getRunCommand("msg");
+ const result = command(targetUser, conversation);
+ ok(!result, "Sending direct message was handled");
+});
+
+add_task(function testMsgNoRoom() {
+ const targetUser = "@test:example.com";
+ const directMessage = "lorem ipsum";
+ const conversation = {
+ wrappedJSObject: {
+ _account: {
+ getDirectConversation(userId) {
+ conversation.userId = userId;
+ return null;
+ },
+ ERROR(errorMsg) {
+ conversation.errorMsg = errorMsg;
+ },
+ },
+ },
+ };
+ const command = _getRunCommand("msg");
+ const result = command(targetUser + " " + directMessage, conversation);
+ ok(result, "Sending direct message was not handled");
+ equal(
+ conversation.userId,
+ targetUser,
+ "Did not try to get the conversation for the target user"
+ );
+ ok(conversation.errorMsg, "Did not report an error");
+});
+
+add_task(function testJoinSuccess() {
+ const roomName = "#test:example.com";
+ const conversation = {
+ wrappedJSObject: {
+ _account: {
+ getGroupConversation(roomId) {
+ conversation.roomId = roomId;
+ },
+ },
+ },
+ };
+ const command = _getRunCommand("join");
+ const result = command(roomName, conversation);
+ ok(result, "Did not handle join command");
+ equal(conversation.roomId, roomName, "Did not try to join expected room");
+});
+
+add_task(function testJoinNotRoomId() {
+ const roomName = "!asdf:example.com";
+ const conversation = {};
+ const command = _getRunCommand("join");
+ const result = command(roomName, conversation);
+ ok(!result, "Handled join command for unsupported room Id");
+});
+
+// Fetch the run() of a named command.
+function _getRunCommand(aCommandName) {
+ for (let command of commands) {
+ if (command.name == aCommandName) {
+ return command.run;
+ }
+ }
+
+ // Fail if no command was found.
+ ok(false, "Could not find the '" + aCommandName + "' command.");
+ return null;
+}
diff --git a/comm/chat/protocols/matrix/test/test_matrixMessage.js b/comm/chat/protocols/matrix/test/test_matrixMessage.js
new file mode 100644
index 0000000000..4d0d2120ba
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixMessage.js
@@ -0,0 +1,441 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ReceiptType } = ChromeUtils.importESModule(
+ "resource:///modules/matrix-sdk.sys.mjs"
+);
+
+const kSendReadPref = "purple.conversations.im.send_read";
+
+loadMatrix();
+
+add_task(function test_whenDisplayed() {
+ const mockConv = {
+ _account: {
+ _client: {
+ sendReadReceipt(event, receiptType) {
+ mockConv.readEvent = event;
+ mockConv.receiptType = receiptType;
+ return Promise.resolve();
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: "baz",
+ },
+ mockConv
+ );
+
+ message.whenDisplayed();
+
+ equal(mockConv.readEvent, "baz");
+ equal(
+ mockConv.receiptType,
+ message.hideReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read
+ );
+
+ mockConv.readEvent = false;
+
+ message.whenDisplayed();
+ ok(!mockConv.readEvent);
+});
+
+add_task(async function test_whenDisplayedError() {
+ let resolveError;
+ const errorPromise = new Promise(resolve => {
+ resolveError = resolve;
+ });
+ const readReceiptRejection = "foo bar";
+ const mockConv = {
+ ERROR(error) {
+ resolveError(error);
+ },
+ _account: {
+ _client: {
+ sendReadReceipt() {
+ return Promise.reject(readReceiptRejection);
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: "baz",
+ },
+ mockConv
+ );
+
+ message.whenDisplayed();
+ const error = await errorPromise;
+ equal(error, readReceiptRejection);
+});
+
+add_task(function test_whenRead() {
+ const mockConv = {
+ _roomId: "lorem",
+ _account: {
+ _client: {
+ setRoomReadMarkers(roomId, eventId) {
+ mockConv.readRoomId = roomId;
+ mockConv.readEventId = eventId;
+ return Promise.resolve();
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: {
+ getId() {
+ return "baz";
+ },
+ },
+ },
+ mockConv
+ );
+
+ message.whenRead();
+
+ equal(mockConv.readEventId, "baz");
+ equal(mockConv.readRoomId, "lorem");
+
+ mockConv.readEventId = false;
+
+ message.whenRead();
+ ok(!mockConv.readEventId);
+});
+
+add_task(async function test_whenReadError() {
+ let resolveError;
+ const errorPromise = new Promise(resolve => {
+ resolveError = resolve;
+ });
+ const readReceiptRejection = "foo bar";
+ const mockConv = {
+ ERROR(error) {
+ resolveError(error);
+ },
+ _account: {
+ _client: {
+ setRoomReadMarkers() {
+ return Promise.reject(readReceiptRejection);
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: {
+ getId() {
+ return "baz";
+ },
+ },
+ },
+ mockConv
+ );
+
+ message.whenRead();
+ const error = await errorPromise;
+ equal(error, readReceiptRejection);
+});
+
+add_task(async function test_whenDisplayedNoEvent() {
+ const message = new MatrixMessage("foo", "bar", {
+ system: true,
+ });
+
+ message.whenDisplayed();
+
+ ok(!message._displayed);
+});
+
+add_task(async function test_whenReadNoEvent() {
+ const message = new MatrixMessage("foo", "bar", {
+ system: true,
+ });
+
+ message.whenRead();
+
+ ok(!message._read);
+});
+
+add_task(async function test_hideReadReceipts() {
+ const message = new MatrixMessage("foo", "bar", {});
+ const initialSendRead = Services.prefs.getBoolPref(kSendReadPref);
+ strictEqual(message.hideReadReceipts, !initialSendRead);
+ Services.prefs.setBoolPref(kSendReadPref, !initialSendRead);
+ const message2 = new MatrixMessage("lorem", "ipsum", {});
+ strictEqual(message2.hideReadReceipts, initialSendRead);
+ strictEqual(message.hideReadReceipts, !initialSendRead);
+ Services.prefs.setBoolPref(kSendReadPref, initialSendRead);
+});
+
+add_task(async function test_getActions() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ { event },
+ {
+ roomState: {
+ maySendRedactionForEvent() {
+ return false;
+ },
+ },
+ }
+ );
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ equal(actions.length, 0);
+});
+
+add_task(async function test_getActions_decryptionFailure() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: "m.bad.encrypted",
+ },
+ });
+ let eventKeysWereRequestedFor;
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ { event },
+ {
+ _account: {
+ _client: {
+ cancelAndResendEventRoomKeyRequest(matrixEvent) {
+ eventKeysWereRequestedFor = matrixEvent;
+ return Promise.resolve();
+ },
+ },
+ },
+ roomState: {
+ maySendRedactionForEvent() {
+ return false;
+ },
+ },
+ }
+ );
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ equal(actions.length, 1);
+ const [action] = actions;
+ ok(action.label);
+ action.run();
+ strictEqual(eventKeysWereRequestedFor, event);
+});
+
+add_task(async function test_getActions_redact() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "foo bar",
+ },
+ roomId: "!actions:example.com",
+ threadRootId: "$thread:example.com",
+ id: "$ev:example.com",
+ });
+ let eventRedacted = false;
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ { event },
+ {
+ _account: {
+ userId: 0,
+ _client: {
+ redactEvent(roomId, threadRootId, eventId) {
+ equal(roomId, "!actions:example.com");
+ equal(threadRootId, "$thread:example.com");
+ equal(eventId, "$ev:example.com");
+ eventRedacted = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ roomState: {
+ maySendRedactionForEvent(ev, userId) {
+ equal(ev, event);
+ equal(userId, 0);
+ return true;
+ },
+ },
+ }
+ );
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ equal(actions.length, 1);
+ const [action] = actions;
+ ok(action.label);
+ action.run();
+ ok(eventRedacted);
+});
+
+add_task(async function test_getActions_noEvent() {
+ const message = new MatrixMessage("system", "test", {
+ system: true,
+ });
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ deepEqual(actions, []);
+});
+
+add_task(async function test_getActions_report() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum",
+ },
+ roomId: "!actions:example.com",
+ id: "$ev:example.com",
+ });
+ let eventReported = false;
+ const message = new MatrixMessage(
+ "user",
+ "lorem ipsum",
+ { event, incoming: true },
+ {
+ _account: {
+ _client: {
+ reportEvent(roomId, eventId, score, reason) {
+ equal(roomId, "!actions:example.com");
+ equal(eventId, "$ev:example.com");
+ equal(score, -100);
+ equal(reason, "");
+ eventReported = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ roomState: {
+ maySendRedactionForEvent(ev, userId) {
+ return false;
+ },
+ },
+ }
+ );
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ const [action] = actions;
+ ok(action.label);
+ action.run();
+ ok(eventReported);
+});
+
+add_task(async function test_getActions_notSent() {
+ let resendCalled = false;
+ let cancelCalled = false;
+ const event = makeEvent({
+ status: MatrixSDK.EventStatus.NOT_SENT,
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "foo bar",
+ },
+ });
+ const message = new MatrixMessage(
+ "!test:example.com",
+ "Error sending message",
+ {
+ event,
+ error: true,
+ },
+ {
+ _account: {
+ _client: {
+ resendEvent(ev, room) {
+ equal(ev, event);
+ ok(room);
+ resendCalled = true;
+ },
+ cancelPendingEvent(ev) {
+ equal(ev, event);
+ cancelCalled = true;
+ },
+ },
+ },
+ roomState: {
+ maySendRedactionForEvent(ev, userId) {
+ return false;
+ },
+ },
+ room: {},
+ }
+ );
+ const actions = message.getActions();
+ ok(Array.isArray(actions));
+ equal(actions.length, 2);
+ const [retryAction, cancelAction] = actions;
+ ok(retryAction.label);
+ ok(cancelAction.label);
+ retryAction.run();
+ ok(resendCalled);
+ ok(!cancelCalled);
+ cancelAction.run();
+ ok(cancelCalled);
+});
+
+add_task(function test_whenDisplayedUnsent() {
+ const mockConv = {
+ _account: {
+ _client: {
+ sendReadReceipt(event, options) {
+ ok(false, "Should not send read receipt for unsent event");
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: makeEvent({
+ status: MatrixSDK.EventStatus.NOT_SENT,
+ }),
+ },
+ mockConv
+ );
+
+ message.whenDisplayed();
+ ok(!message._displayed);
+});
+
+add_task(function test_whenReadUnsent() {
+ const mockConv = {
+ _account: {
+ _client: {
+ setRoomReadMarkers(event, options) {
+ ok(false, "Should not send read marker for unsent event");
+ },
+ },
+ },
+ };
+ const message = new MatrixMessage(
+ "foo",
+ "bar",
+ {
+ event: makeEvent({
+ status: MatrixSDK.EventStatus.NOT_SENT,
+ }),
+ },
+ mockConv
+ );
+
+ message.whenRead();
+ ok(!message._read);
+});
diff --git a/comm/chat/protocols/matrix/test/test_matrixMessageContent.js b/comm/chat/protocols/matrix/test/test_matrixMessageContent.js
new file mode 100644
index 0000000000..b09d3807ca
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixMessageContent.js
@@ -0,0 +1,652 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { MatrixMessageContent } = ChromeUtils.importESModule(
+ "resource:///modules/matrixMessageContent.sys.mjs"
+);
+const { XPCShellContentUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/XPCShellContentUtils.sys.mjs"
+);
+var { getMatrixTextForEvent } = ChromeUtils.importESModule(
+ "resource:///modules/matrixTextForEvent.sys.mjs"
+);
+var { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+var _ = l10nHelper("chrome://chat/locale/matrix.properties");
+
+// Required to make it so the DOMParser can handle images and such.
+XPCShellContentUtils.init(this);
+
+const PLAIN_FIXTURES = [
+ {
+ description: "Normal text message plain quote",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+> dolor sit amet
+
+dolor sit amet`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum!",
+ },
+ sender: "@foo:example.com",
+ },
+ result: `@foo:example.com:
+&gt; lorem ipsum!
+
+dolor sit amet`,
+ },
+ {
+ description: "Normal text message plain quote with missing quote message",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+
+dolor sit amet`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ result: `&gt; lorem ipsum
+
+dolor sit amet`,
+ },
+ {
+ description: "Emote message plain quote",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+
+dolor sit amet`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Emote,
+ body: "lorem ipsum",
+ },
+ sender: "@foo:example.com",
+ },
+ result: `&gt; * @foo:example.com lorem ipsum *
+
+dolor sit amet`,
+ },
+ {
+ description: "Reply is emote",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Emote,
+ body: `> lorem ipsum
+
+dolor sit amet`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum",
+ },
+ sender: "@foo:example.com",
+ },
+ result: "\ndolor sit amet",
+ },
+ {
+ description: "Attachment",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.File,
+ body: "example.png",
+ url: "mxc://example.com/asdf",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "https://example.com/_matrix/media/r0/download/example.com/asdf",
+ },
+ {
+ description: "Sticker",
+ event: {
+ type: MatrixSDK.EventType.Sticker,
+ content: {
+ body: "example.png",
+ url: "mxc://example.com/asdf",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "https://example.com/_matrix/media/r0/download/example.com/asdf",
+ },
+ {
+ description: "Normal body with HTML-y contents",
+ event: {
+ type: MatrixSDK.EventType.Text,
+ content: {
+ body: "<foo>",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "&lt;foo&gt;",
+ },
+ {
+ description: "Non-mxc attachment",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "hello.jpg",
+ msgtype: MatrixSDK.MsgType.Image,
+ url: "https://example.com/hello.jpg",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "hello.jpg",
+ },
+ {
+ description: "Key verification request",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.KeyVerificationRequest,
+ },
+ sender: "@bar:example.com",
+ },
+ isGetTextForEvent: true,
+ },
+ {
+ description: "Decryption failure",
+ event: {
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ content: {
+ msgtype: "m.bad.encrypted",
+ },
+ },
+ isGetTextForEvent: true,
+ },
+ {
+ description: "Being decrypted",
+ event: {
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ decrypting: true,
+ },
+ result: _("message.decrypting"),
+ },
+ {
+ description: "Unsent event",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Text,
+ },
+ sender: "@bar:example.com",
+ status: MatrixSDK.EventStatus.NOT_SENT,
+ },
+ result: "",
+ },
+ {
+ description: "Redacted event",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {},
+ sender: "@bar:example.com",
+ redacted: true,
+ },
+ result: _("message.redacted"),
+ },
+ {
+ description: "Tombstone",
+ event: {
+ type: MatrixSDK.EventType.RoomTombstone,
+ content: {
+ body: "tombstone",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "tombstone",
+ },
+ {
+ description: "Encryption start",
+ event: {
+ type: MatrixSDK.EventType.RoomEncryption,
+ content: {},
+ sender: "@bar:example.com",
+ },
+ isGetTextForEvent: true,
+ },
+ {
+ description: "Reaction",
+ event: {
+ type: MatrixSDK.EventType.Reaction,
+ content: {
+ ["m.relates_to"]: {
+ rel_type: MatrixSDK.RelationType.Annotation,
+ event_id: "!event:example.com",
+ key: "🐦",
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum!",
+ },
+ sender: "@foo:example.com",
+ },
+ result: _("message.reaction", "@bar:example.com", "@foo:example.com", "🐦"),
+ },
+];
+
+const HTML_FIXTURES = [
+ {
+ description: "Normal text message plain quote",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+> dolor sit amet
+
+dolor sit amet`,
+ format: "org.matrix.custom.html",
+ formatted_body: `<mx-reply>
+ <a href="https://matrix.to/#/@foo:example.com">Foo</a> wrote:<br>
+ <blockquote>lorem ipsum</blockquote>
+</mx-reply>
+<p>dolor sit amet</p>`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum!",
+ },
+ sender: "@foo:example.com",
+ },
+ result: `<span class="ib-person">@foo:example.com</span>:<blockquote>lorem ipsum!</blockquote>\n<p>dolor sit amet</p>`,
+ },
+ {
+ description: "Normal text message with missing quote message",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+> dolor sit amet
+
+dolor sit amet`,
+ format: "org.matrix.custom.html",
+ formatted_body: `<mx-reply>
+ <a href="https://matrix.to/#/@foo:example.com">Foo</a> wrote:<br>
+ <blockquote>lorem ipsum</blockquote>
+</mx-reply>
+<p>dolor sit amet</p>`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ result: `
+ <span class="ib-person">@foo:example.com</span> wrote:<br>
+ <blockquote>lorem ipsum</blockquote>
+
+<p>dolor sit amet</p>`,
+ },
+ {
+ description: "Quoted emote message",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `> lorem ipsum
+
+dolor sit amet`,
+ format: "org.matrix.custom.html",
+ formatted_body: `<mx-reply>
+ <a href="https://matrix.to/#/@foo:example.com">Foo</a> wrote:<br>
+ <blockquote>lorem ipsum</blockquote>
+</mx-reply>
+<p>dolor sit amet</p>`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Emote,
+ body: "lorem ipsum",
+ format: "org.matrix.custom.html",
+ formatted_body: "<p>lorem ipsum</p>",
+ },
+ sender: "@foo:example.com",
+ },
+ result: `<blockquote>* @foo:example.com <p>lorem ipsum</p> *</blockquote>
+<p>dolor sit amet</p>`,
+ },
+ {
+ description: "Reply is emote",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Emote,
+ body: `> lorem ipsum
+
+dolor sit amet`,
+ format: "org.matrix.custom.html",
+ formatted_body: `<mx-reply>
+ <a href="https://matrix.to/#/@foo:example.com">Foo</a> wrote:<br>
+ <blockquote>lorem ipsum</blockquote>
+</mx-reply>
+<p>dolor sit amet</p>`,
+ ["m.relates_to"]: {
+ "m.in_reply_to": {
+ event_id: "!event:example.com",
+ },
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum",
+ },
+ sender: "@foo:example.com",
+ },
+ result: "\n<p>dolor sit amet</p>",
+ },
+ {
+ description: "Attachment",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.File,
+ body: "example.png",
+ url: "mxc://example.com/asdf",
+ },
+ sender: "@bar:example.com",
+ },
+ result:
+ '<a href="https://example.com/_matrix/media/r0/download/example.com/asdf">example.png</a>',
+ },
+ {
+ description: "Sticker",
+ event: {
+ type: MatrixSDK.EventType.Sticker,
+ content: {
+ body: "example.png",
+ url: "mxc://example.com/asdf",
+ },
+ sender: "@bar:example.com",
+ },
+ result:
+ '<a href="https://example.com/_matrix/media/r0/download/example.com/asdf">example.png</a>',
+ },
+ {
+ description: "Normal formatted body",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "foo bar",
+ msgtype: MatrixSDK.MsgType.Text,
+ format: "org.matrix.custom.html",
+ formatted_body: "<p>foo bar</p>",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "<p>foo bar</p>",
+ },
+ {
+ description: "Inline image",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: ":emote:",
+ msgtype: MatrixSDK.MsgType.Text,
+ format: "org.matrix.custom.html",
+ formatted_body: '<img alt=":emote:" src="mxc://example.com/emote.png">',
+ },
+ sender: "@bar:example.com",
+ },
+ result:
+ '<a href="https://example.com/_matrix/media/r0/download/example.com/emote.png">:emote:</a>',
+ },
+ {
+ description: "Non-mxc attachment",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "foo.png",
+ msgtype: MatrixSDK.MsgType.Image,
+ url: "https://example.com/image.png",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "foo.png",
+ },
+ {
+ description: "Fallback to normal body",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "hello world <!>",
+ msgtype: MatrixSDK.MsgType.Notice,
+ },
+ sender: "@bar:example.com",
+ },
+ result: "hello world &lt;!&gt;",
+ },
+ {
+ description: "Colored text",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "rainbow",
+ msgtype: MatrixSDK.MsgType.Text,
+ format: "org.matrix.custom.html",
+ formatted_body:
+ '<font data-mx-color="ff0000">ra</font><span data-mx-color="00ff00">inb</span><i data-mx-color="0000ff">ow</i>',
+ },
+ sender: "@bar:example.com",
+ },
+ result:
+ '<font style="color: rgb(255, 0, 0);">ra</font><span style="color: rgb(0, 255, 0);">inb</span><i data-mx-color="0000ff">ow</i>',
+ },
+ {
+ description: "Unsent event",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Text,
+ },
+ sender: "@bar:example.com",
+ status: MatrixSDK.EventStatus.NOT_SENT,
+ },
+ result: "",
+ },
+ {
+ description: "Redacted event",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {},
+ sender: "@bar:example.com",
+ redacted: true,
+ },
+ result: _("message.redacted"),
+ },
+ {
+ description: "Tombstone",
+ event: {
+ type: MatrixSDK.EventType.RoomTombstone,
+ content: {
+ body: "tombstone",
+ },
+ sender: "@bar:example.com",
+ },
+ result: "tombstone",
+ },
+ {
+ description: "Encryption start",
+ event: {
+ type: MatrixSDK.EventType.RoomEncryption,
+ content: {},
+ sender: "@bar:example.com",
+ },
+ isGetTextForEvent: true,
+ },
+ {
+ description: "Reaction",
+ event: {
+ type: MatrixSDK.EventType.Reaction,
+ content: {
+ ["m.relates_to"]: {
+ rel_type: MatrixSDK.RelationType.Annotation,
+ event_id: "!event:example.com",
+ key: "🐦",
+ },
+ },
+ sender: "@bar:example.com",
+ },
+ getEventResult: {
+ id: "!event:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "lorem ipsum!",
+ },
+ sender: "@foo:example.com",
+ },
+ result: _(
+ "message.reaction",
+ '<span class="ib-person">@bar:example.com</span>',
+ '<span class="ib-person">@foo:example.com</span>',
+ "🐦"
+ ),
+ },
+ {
+ description: "URL encoded mention",
+ event: {
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: `@foo:example.com dolor sit amet`,
+ format: "org.matrix.custom.html",
+ formatted_body: `<a href="https://matrix.to/#/%40foo%3Aexample.com">Foo</a> dolor sit amet`,
+ },
+ sender: "@bar:example.com",
+ },
+ result: '<span class="ib-person">@foo:example.com</span> dolor sit amet',
+ },
+];
+
+add_task(function test_plainBody() {
+ for (const fixture of PLAIN_FIXTURES) {
+ const event = makeEvent(fixture.event);
+ const result = MatrixMessageContent.getIncomingPlain(
+ event,
+ "https://example.com",
+ eventId => {
+ if (fixture.getEventResult) {
+ equal(
+ eventId,
+ fixture.getEventResult.id,
+ `${fixture.description}: getEvent event ID`
+ );
+ return makeEvent(fixture.getEventResult);
+ }
+ return undefined;
+ }
+ );
+ if (fixture.isGetTextForEvent) {
+ equal(result, getMatrixTextForEvent(event));
+ } else {
+ equal(result, fixture.result, fixture.description);
+ }
+ }
+});
+
+add_task(function test_htmlBody() {
+ for (const fixture of HTML_FIXTURES) {
+ const event = makeEvent(fixture.event);
+ const result = MatrixMessageContent.getIncomingHTML(
+ event,
+ "https://example.com",
+ eventId => {
+ if (fixture.getEventResult) {
+ equal(
+ eventId,
+ fixture.getEventResult.id,
+ `${fixture.description}: getEvent event ID`
+ );
+ return makeEvent(fixture.getEventResult);
+ }
+ return undefined;
+ }
+ );
+ if (fixture.isGetTextForEvent) {
+ equal(result, getMatrixTextForEvent(event));
+ } else {
+ equal(result, fixture.result, fixture.description);
+ }
+ }
+});
diff --git a/comm/chat/protocols/matrix/test/test_matrixPowerLevels.js b/comm/chat/protocols/matrix/test/test_matrixPowerLevels.js
new file mode 100644
index 0000000000..40237664a3
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixPowerLevels.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { MatrixPowerLevels } = ChromeUtils.importESModule(
+ "resource:///modules/matrixPowerLevels.sys.mjs"
+);
+var { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+var _ = l10nHelper("chrome://chat/locale/matrix.properties");
+
+const TO_TEXT_FIXTURES = [
+ {
+ level: MatrixPowerLevels.user,
+ defaultLevel: MatrixPowerLevels.user,
+ result: _(
+ "powerLevel.detailed",
+ _("powerLevel.default"),
+ MatrixPowerLevels.user
+ ),
+ name: "Default power level for default 0",
+ },
+ {
+ level: MatrixPowerLevels.user,
+ defaultLevel: 10,
+ result: _(
+ "powerLevel.detailed",
+ _("powerLevel.restricted"),
+ MatrixPowerLevels.user
+ ),
+ name: "Restricted power level",
+ },
+ {
+ level: 10,
+ defaultLevel: 10,
+ result: _("powerLevel.detailed", _("powerLevel.default"), 10),
+ name: "Default power level for default 10",
+ },
+ {
+ level: MatrixPowerLevels.moderator,
+ defaultLevel: MatrixPowerLevels.user,
+ result: _(
+ "powerLevel.detailed",
+ _("powerLevel.moderator"),
+ MatrixPowerLevels.moderator
+ ),
+ name: "Moderator",
+ },
+ {
+ level: MatrixPowerLevels.admin,
+ defaultLevel: MatrixPowerLevels.user,
+ result: _(
+ "powerLevel.detailed",
+ _("powerLevel.admin"),
+ MatrixPowerLevels.admin
+ ),
+ name: "Admin",
+ },
+ {
+ level: 25,
+ defaultLevel: MatrixPowerLevels.user,
+ result: _("powerLevel.detailed", _("powerLevel.custom"), 25),
+ name: "Custom power level 25",
+ },
+];
+const GET_EVENT_LEVEL_FIXTURES = [
+ {
+ powerLevels: undefined,
+ expected: 0,
+ },
+ {
+ powerLevels: {},
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 10,
+ },
+ expected: 10,
+ },
+ {
+ powerLevels: {
+ events_default: Infinity,
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: "foo",
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 0,
+ events: {},
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 0,
+ events: {
+ [MatrixSDK.EventType.RoomMessage]: 0,
+ },
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 0,
+ events: {
+ [MatrixSDK.EventType.RoomMessage]: Infinity,
+ },
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 0,
+ events: {
+ [MatrixSDK.EventType.RoomMessage]: "foo",
+ },
+ },
+ expected: 0,
+ },
+ {
+ powerLevels: {
+ events_default: 0,
+ events: {
+ [MatrixSDK.EventType.RoomMessage]: 10,
+ },
+ },
+ expected: 10,
+ },
+];
+
+add_task(async function testToText() {
+ for (const fixture of TO_TEXT_FIXTURES) {
+ const result = MatrixPowerLevels.toText(
+ fixture.level,
+ fixture.defaultLevel
+ );
+ equal(result, fixture.result);
+ }
+});
+
+add_task(async function testGetUserDefaultLevel() {
+ equal(MatrixPowerLevels.getUserDefaultLevel(), 0);
+ equal(MatrixPowerLevels.getUserDefaultLevel({}), 0);
+ equal(
+ MatrixPowerLevels.getUserDefaultLevel({
+ users_default: 10,
+ }),
+ 10
+ );
+ equal(
+ MatrixPowerLevels.getUserDefaultLevel({
+ users_default: Infinity,
+ }),
+ 0
+ );
+ equal(
+ MatrixPowerLevels.getUserDefaultLevel({
+ users_default: "foo",
+ }),
+ 0
+ );
+});
+
+add_task(async function testGetEventDefaultLevel() {
+ equal(MatrixPowerLevels.getEventDefaultLevel(), 0);
+ equal(MatrixPowerLevels.getEventDefaultLevel({}), 0);
+ equal(
+ MatrixPowerLevels.getEventDefaultLevel({
+ events_default: 10,
+ }),
+ 10
+ );
+ equal(
+ MatrixPowerLevels.getEventDefaultLevel({
+ events_default: Infinity,
+ }),
+ 0
+ );
+ equal(
+ MatrixPowerLevels.getEventDefaultLevel({
+ events_default: "foo",
+ }),
+ 0
+ );
+});
+
+add_task(async function testGetEventLevel() {
+ for (const eventLevelTest of GET_EVENT_LEVEL_FIXTURES) {
+ equal(
+ MatrixPowerLevels.getEventLevel(
+ eventLevelTest.powerLevels,
+ MatrixSDK.EventType.RoomMessage
+ ),
+ eventLevelTest.expected
+ );
+ }
+});
diff --git a/comm/chat/protocols/matrix/test/test_matrixRoom.js b/comm/chat/protocols/matrix/test/test_matrixRoom.js
new file mode 100644
index 0000000000..3e4c72a19a
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixRoom.js
@@ -0,0 +1,928 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { setTimeout, clearTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+loadMatrix();
+
+add_task(async function test_initRoom() {
+ const roomStub = getRoom(true);
+ equal(typeof roomStub._resolveInitializer, "function");
+ ok(roomStub._initialized);
+ await roomStub._initialized;
+ roomStub.forget();
+});
+
+add_task(async function test_initRoom_withSpace() {
+ const roomStub = getRoom(true, "#test:example.com", (target, key) => {
+ if (key === "isSpaceRoom") {
+ return () => true;
+ }
+ return null;
+ });
+ ok(roomStub._initialized);
+ ok(roomStub.left);
+ await roomStub._initialized;
+ roomStub.forget();
+});
+
+add_task(function test_replaceRoom() {
+ const roomStub = {
+ __proto__: MatrixRoom.prototype,
+ _resolveInitializer() {
+ this.initialized = true;
+ },
+ _mostRecentEventId: "foo",
+ _joiningLocks: new Set(),
+ };
+ const newRoom = {};
+ MatrixRoom.prototype.replaceRoom.call(roomStub, newRoom);
+ strictEqual(roomStub._replacedBy, newRoom);
+ ok(roomStub.initialized);
+ equal(newRoom._mostRecentEventId, roomStub._mostRecentEventId);
+});
+
+add_task(async function test_waitForRoom() {
+ const roomStub = {
+ _initialized: Promise.resolve(),
+ };
+ const awaitedRoom = await MatrixRoom.prototype.waitForRoom.call(roomStub);
+ strictEqual(awaitedRoom, roomStub);
+});
+
+add_task(async function test_waitForRoomReplaced() {
+ const roomStub = getRoom(true);
+ const newRoom = {
+ waitForRoom() {
+ return Promise.resolve("success");
+ },
+ };
+ MatrixRoom.prototype.replaceRoom.call(roomStub, newRoom);
+ const awaitedRoom = await MatrixRoom.prototype.waitForRoom.call(roomStub);
+ equal(awaitedRoom, "success");
+ roomStub.forget();
+});
+
+add_task(function test_addEventRedacted() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ redacted: true,
+ redaction: {
+ event_id: 2,
+ type: MatrixSDK.EventType.RoomRedaction,
+ },
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ let updatedMessage;
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com/";
+ },
+ },
+ },
+ updateMessage(sender, message, opts) {
+ updatedMessage = {
+ sender,
+ message,
+ opts,
+ };
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub._mostRecentEventId, 2);
+ equal(typeof updatedMessage, "object");
+ ok(!updatedMessage.opts.system);
+ ok(updatedMessage.opts.deleted);
+ equal(typeof updatedMessage.message, "string");
+ equal(updatedMessage.sender, "@user:example.com");
+});
+
+add_task(function test_addEventMessageIncoming() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Text,
+ },
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com/";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ writeMessage(who, message, options) {
+ this.who = who;
+ this.message = message;
+ this.options = options;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub.who, "@user:example.com");
+ equal(roomStub.message, "foo");
+ ok(!roomStub.options.system);
+ ok(!roomStub.options.delayed);
+ equal(roomStub._mostRecentEventId, 0);
+});
+
+add_task(function test_addEventMessageOutgoing() {
+ const event = makeEvent({
+ sender: "@test:example.com",
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Text,
+ },
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ writeMessage(who, message, options) {
+ this.who = who;
+ this.message = message;
+ this.options = options;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub.who, "@test:example.com");
+ equal(roomStub.message, "foo");
+ ok(!roomStub.options.system);
+ ok(!roomStub.options.delayed);
+ equal(roomStub._mostRecentEventId, 0);
+});
+
+add_task(function test_addEventMessageEmote() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Emote,
+ },
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ writeMessage(who, message, options) {
+ this.who = who;
+ this.message = message;
+ this.options = options;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub.who, "@user:example.com");
+ equal(roomStub.message, "foo");
+ ok(roomStub.options.action);
+ ok(!roomStub.options.system);
+ ok(!roomStub.options.delayed);
+ equal(roomStub._mostRecentEventId, 0);
+});
+
+add_task(function test_addEventMessageDelayed() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ content: {
+ body: "foo",
+ msgtype: MatrixSDK.MsgType.Text,
+ },
+ type: MatrixSDK.EventType.RoomMessage,
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ writeMessage(who, message, options) {
+ this.who = who;
+ this.message = message;
+ this.options = options;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event, true);
+ equal(roomStub.who, "@user:example.com");
+ equal(roomStub.message, "foo");
+ ok(!roomStub.options.system);
+ ok(roomStub.options.delayed);
+ equal(roomStub._mostRecentEventId, 0);
+});
+
+add_task(function test_addEventTopic() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomTopic,
+ id: 1,
+ content: {
+ topic: "foo bar",
+ },
+ sender: "@user:example.com",
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com/";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ setTopic(topic, who) {
+ this.who = who;
+ this.topic = topic;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub.who, "@user:example.com");
+ equal(roomStub.topic, "foo bar");
+ equal(roomStub._mostRecentEventId, 1);
+});
+
+add_task(async function test_addEventTombstone() {
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomTombstone,
+ id: 1,
+ content: {
+ body: "updated room",
+ replacement_room: "!new_room:example.com",
+ },
+ sender: "@test:example.com",
+ });
+ const conversation = getRoom(true);
+ const newText = waitForNotification(conversation, "new-text");
+ conversation.addEvent(event);
+ const { subject: message } = await newText;
+ const newConversation = await conversation.waitForRoom();
+ equal(newConversation.normalizedName, event.getContent().replacement_room);
+ equal(message.who, event.getSender());
+ equal(message.message, event.getContent().body);
+ ok(message.system);
+ ok(message.incoming);
+ ok(!conversation._account);
+ newConversation.forget();
+});
+
+add_task(function test_forgetWith_close() {
+ const roomList = new Map();
+ const roomStub = {
+ closeDm() {
+ this.closeCalled = true;
+ },
+ _roomId: "foo",
+ _account: {
+ roomList,
+ },
+ // stubs for jsProtoHelper implementations
+ addObserver() {},
+ unInit() {},
+ _releaseJoiningLock(lock) {
+ this.releasedLock = lock;
+ },
+ };
+ roomList.set(roomStub._roomId, roomStub);
+ IMServices.conversations.addConversation(roomStub);
+
+ MatrixRoom.prototype.forget.call(roomStub);
+ ok(!roomList.has(roomStub._roomId));
+ ok(roomStub.closeCalled);
+ equal(roomStub.releasedLock, "roomInit", "Released roomInit lock");
+});
+
+add_task(function test_forgetWithout_close() {
+ const roomList = new Map();
+ const roomStub = {
+ isChat: true,
+ _roomId: "foo",
+ _account: {
+ roomList,
+ },
+ // stubs for jsProtoHelper implementations
+ addObserver() {},
+ unInit() {},
+ _releaseJoiningLock(lock) {
+ this.releasedLock = lock;
+ },
+ };
+ roomList.set(roomStub._roomId, roomStub);
+ IMServices.conversations.addConversation(roomStub);
+
+ MatrixRoom.prototype.forget.call(roomStub);
+ ok(!roomList.has(roomStub._roomId));
+ equal(roomStub.releasedLock, "roomInit", "Released roomInit lock");
+});
+
+add_task(function test_close() {
+ const roomStub = {
+ forget() {
+ this.forgetCalled = true;
+ },
+ cleanUpOutgoingVerificationRequests() {
+ this.cleanUpCalled = true;
+ },
+ _roomId: "foo",
+ _account: {
+ _client: {
+ leave(roomId) {
+ roomStub.leftRoom = roomId;
+ },
+ },
+ },
+ };
+
+ MatrixRoom.prototype.close.call(roomStub);
+ equal(roomStub.leftRoom, roomStub._roomId);
+ ok(roomStub.forgetCalled);
+ ok(roomStub.cleanUpCalled);
+});
+
+add_task(function test_setTypingState() {
+ const roomStub = getRoom(true, "foo", {
+ sendTyping(roomId, isTyping) {
+ roomStub.typingRoomId = roomId;
+ roomStub.typing = isTyping;
+ return Promise.resolve();
+ },
+ });
+
+ roomStub._setTypingState(true);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(roomStub.typing);
+
+ roomStub._setTypingState(false);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(!roomStub.typing);
+
+ roomStub._setTypingState(true);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(roomStub.typing);
+
+ roomStub._cleanUpTimers();
+ roomStub.forget();
+});
+
+add_task(function test_setTypingStateDebounce() {
+ const roomStub = getRoom(true, "foo", {
+ sendTyping(roomId, isTyping) {
+ roomStub.typingRoomId = roomId;
+ roomStub.typing = isTyping;
+ return Promise.resolve();
+ },
+ });
+
+ roomStub._setTypingState(true);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(roomStub.typing);
+ ok(roomStub._typingDebounce);
+
+ roomStub.typing = false;
+
+ roomStub._setTypingState(true);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(!roomStub.typing);
+ ok(roomStub._typingDebounce);
+
+ clearTimeout(roomStub._typingDebounce);
+ roomStub._typingDebounce = null;
+
+ roomStub._setTypingState(true);
+ equal(roomStub.typingRoomId, roomStub._roomId);
+ ok(roomStub.typing);
+
+ roomStub._cleanUpTimers();
+ roomStub.forget();
+});
+
+add_task(function test_cancelTypingTimer() {
+ const roomStub = {
+ _typingTimer: setTimeout(() => {}, 10000), // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ };
+ MatrixRoom.prototype._cancelTypingTimer.call(roomStub);
+ ok(!roomStub._typingTimer);
+});
+
+add_task(function test_cleanUpTimers() {
+ const roomStub = getRoom(true);
+ roomStub._typingTimer = setTimeout(() => {}, 10000); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ roomStub._typingDebounce = setTimeout(() => {}, 1000); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ roomStub._cleanUpTimers();
+ ok(!roomStub._typingTimer);
+ ok(!roomStub._typingDebounce);
+ roomStub.forget();
+});
+
+add_task(function test_finishedComposing() {
+ let typingState = true;
+ const roomStub = {
+ __proto__: MatrixRoom.prototype,
+ shouldSendTypingNotifications: false,
+ _roomId: "foo",
+ _account: {
+ _client: {
+ sendTyping(roomId, state) {
+ typingState = state;
+ return Promise.resolve();
+ },
+ },
+ },
+ };
+
+ MatrixRoom.prototype.finishedComposing.call(roomStub);
+ ok(typingState);
+
+ roomStub.shouldSendTypingNotifications = true;
+ MatrixRoom.prototype.finishedComposing.call(roomStub);
+ ok(!typingState);
+});
+
+add_task(function test_sendTyping() {
+ let typingState = false;
+ const roomStub = getRoom(true, "foo", {
+ sendTyping(roomId, state) {
+ typingState = state;
+ return Promise.resolve();
+ },
+ });
+ Services.prefs.setBoolPref("purple.conversations.im.send_typing", false);
+
+ let result = roomStub.sendTyping("lorem ipsum");
+ ok(!roomStub._typingTimer);
+ equal(result, Ci.prplIConversation.NO_TYPING_LIMIT);
+ ok(!typingState);
+
+ Services.prefs.setBoolPref("purple.conversations.im.send_typing", true);
+ result = roomStub.sendTyping("lorem ipsum");
+ ok(roomStub._typingTimer);
+ equal(result, Ci.prplIConversation.NO_TYPING_LIMIT);
+ ok(typingState);
+
+ result = roomStub.sendTyping("");
+ ok(!roomStub._typingTimer);
+ equal(result, Ci.prplIConversation.NO_TYPING_LIMIT);
+ ok(!typingState);
+
+ roomStub._cleanUpTimers();
+ roomStub.forget();
+});
+
+add_task(function test_setInitialized() {
+ const roomStub = {
+ _resolveInitializer() {
+ this.calledResolve = true;
+ },
+ _releaseJoiningLock(lock) {
+ this.releasedLock = lock;
+ },
+ };
+ MatrixRoom.prototype._setInitialized.call(roomStub);
+ ok(roomStub.calledResolve);
+ equal(roomStub.releasedLock, "roomInit", "Released roomInit lock");
+});
+
+add_task(function test_addEventSticker() {
+ const date = new Date();
+ const event = makeEvent({
+ time: date,
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.Sticker,
+ content: {
+ body: "foo",
+ url: "mxc://example.com/sticker.png",
+ },
+ });
+ const roomStub = {
+ _account: {
+ userId: "@test:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com";
+ },
+ },
+ },
+ _eventsWaitingForDecryption: new Set(),
+ writeMessage(who, message, options) {
+ this.who = who;
+ this.message = message;
+ this.options = options;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub.who, "@user:example.com");
+ equal(
+ roomStub.message,
+ "https://example.com/_matrix/media/r0/download/example.com/sticker.png"
+ );
+ ok(!roomStub.options.system);
+ ok(!roomStub.options.delayed);
+ equal(roomStub._mostRecentEventId, 0);
+});
+
+add_task(function test_sendMsg() {
+ let isTyping = true;
+ let message;
+ const roomStub = getRoom(true, "#test:example.com", {
+ sendTyping(roomId, typing) {
+ equal(roomId, roomStub._roomId);
+ isTyping = typing;
+ return Promise.resolve();
+ },
+ sendTextMessage(roomId, threadId, msg) {
+ equal(roomId, roomStub._roomId);
+ equal(threadId, null);
+ message = msg;
+ return Promise.resolve();
+ },
+ });
+ roomStub.dispatchMessage("foo bar");
+ ok(!isTyping);
+ equal(message, "foo bar");
+ roomStub._cleanUpTimers();
+ roomStub.forget();
+});
+
+add_task(function test_sendMsg_emote() {
+ let isTyping = true;
+ let message;
+ const roomStub = getRoom(true, "#test:example.com", {
+ sendTyping(roomId, typing) {
+ equal(roomId, roomStub._roomId);
+ isTyping = typing;
+ return Promise.resolve();
+ },
+ sendEmoteMessage(roomId, threadId, msg) {
+ equal(roomId, roomStub._roomId);
+ equal(threadId, null);
+ message = msg;
+ return Promise.resolve();
+ },
+ });
+ roomStub.dispatchMessage("foo bar", true);
+ ok(!isTyping);
+ equal(message, "foo bar");
+ roomStub._cleanUpTimers();
+ roomStub.forget();
+});
+
+add_task(function test_createMessage() {
+ const time = Date.now();
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ time,
+ sender: "@foo:example.com",
+ });
+ const roomStub = getRoom(true, "#test:example.com", {
+ getPushActionsForEvent(eventToProcess) {
+ equal(eventToProcess, event);
+ return {
+ tweaks: {
+ highlight: true,
+ },
+ };
+ },
+ });
+ const message = roomStub.createMessage("@foo:example.com", "bar", {
+ event,
+ });
+ equal(message.message, "bar");
+ equal(message.who, "@foo:example.com");
+ equal(message.conversation, roomStub);
+ ok(!message.outgoing);
+ ok(message.incoming);
+ equal(message.alias, "foo bar");
+ ok(!message.isEncrypted);
+ ok(message.containsNick);
+ equal(message.time, Math.floor(time / 1000));
+ equal(message.iconURL, "https://example.com/avatar");
+ equal(message.remoteId, 0);
+ roomStub.forget();
+});
+
+add_task(async function test_addEventWaitingForDecryption() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ shouldDecrypt: true,
+ });
+ const roomStub = getRoom(true, "#test:example.com");
+ const writePromise = waitForNotification(roomStub, "new-text");
+ roomStub.addEvent(event);
+ const { subject: result } = await writePromise;
+ ok(!result.error, "Waiting for decryption message is not an error");
+ ok(!result.system, "Waiting for decryption message is not system");
+ roomStub.forget();
+});
+
+add_task(async function test_addEventReplaceDecryptedEvent() {
+ //TODO need to emit event on event?
+ let spec = {
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.RoomMessage,
+ isEncrypted: true,
+ shouldDecrypt: true,
+ content: {
+ msgtype: MatrixSDK.MsgType.Text,
+ body: "foo",
+ },
+ };
+ const event = makeEvent(spec);
+ const roomStub = getRoom(true, "#test:example.com");
+ const writePromise = waitForNotification(roomStub, "new-text");
+ roomStub.addEvent(event);
+ const { subject: initialEvent } = await writePromise;
+ ok(!initialEvent.error, "Pending event is not an error");
+ ok(!initialEvent.system, "Pending event is not a system message");
+ equal(
+ initialEvent.who,
+ "@user:example.com",
+ "Pending message has correct sender"
+ );
+ const updatePromise = waitForNotification(roomStub, "update-text");
+ spec.shouldDecrypt = false;
+ event._listeners[MatrixSDK.MatrixEventEvent.Decrypted](event);
+ const { subject: result } = await updatePromise;
+ equal(result.who, "@user:example.com", "Correct message sender");
+ equal(result.message, "foo", "Message contents displayed");
+ roomStub.forget();
+});
+
+add_task(async function test_addEventDecryptionError() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ content: {
+ msgtype: "m.bad.encrypted",
+ },
+ });
+ const roomStub = getRoom(true, "#test:example.com");
+ const writePromise = waitForNotification(roomStub, "new-text");
+ roomStub.addEvent(event);
+ const { subject: result } = await writePromise;
+ ok(result.error, "Message is an error");
+ ok(!result.system, "Not displayed as system event");
+ roomStub.forget();
+});
+
+add_task(async function test_addEventPendingDecryption() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ decrypting: true,
+ });
+ const roomStub = getRoom(true, "#test:example.com");
+ const writePromise = waitForNotification(roomStub, "new-text");
+ roomStub.addEvent(event);
+ const { subject: result } = await writePromise;
+ ok(!result.error, "Not marked as error");
+ ok(!result.system, "Not displayed as system event");
+ roomStub.forget();
+});
+
+add_task(async function test_addEventRedaction() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ id: 1443,
+ type: MatrixSDK.EventType.RoomRedaction,
+ });
+ const roomStub = {
+ writeMessage() {
+ ok(false, "called writeMessage");
+ },
+ updateMessage() {
+ ok(false, "called updateMessage");
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ equal(roomStub._mostRecentEventId, undefined);
+});
+
+add_task(function test_encryptionStateUnavailable() {
+ const room = getRoom(true, "#test:example.com");
+ equal(
+ room.encryptionState,
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED,
+ "Encryption state is encryption not supported with crypto disabled"
+ );
+ room.forget();
+});
+
+add_task(function test_encryptionStateCanEncrypt() {
+ const room = getRoom(true, "#test:example.com", {
+ isCryptoEnabled() {
+ return true;
+ },
+ });
+ let maySendStateEvent = false;
+ room.room.currentState = {
+ mayClientSendStateEvent(eventType, client) {
+ equal(
+ eventType,
+ MatrixSDK.EventType.RoomEncryption,
+ "mayClientSendStateEvent called for room encryption"
+ );
+ equal(
+ client,
+ room._account._client,
+ "mayClientSendStateEvent got the expected client"
+ );
+ return maySendStateEvent;
+ },
+ };
+ equal(
+ room.encryptionState,
+ Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED,
+ "Encryption state is encryption not supported when state event can't be sent"
+ );
+ maySendStateEvent = true;
+ equal(
+ room.encryptionState,
+ Ci.prplIConversation.ENCRYPTION_AVAILABLE,
+ "Encryption state is available"
+ );
+ room.forget();
+});
+
+add_task(async function test_encryptionStateOn() {
+ const room = getRoom(true, "#test:example.com", {
+ isCryptoEnabled() {
+ return true;
+ },
+ isRoomEncrypted(roomId) {
+ return true;
+ },
+ });
+ room.room.currentState = {
+ mayClientSendStateEvent(eventType, client) {
+ equal(
+ eventType,
+ MatrixSDK.EventType.RoomEncryption,
+ "mayClientSendStateEvent called for room encryption"
+ );
+ equal(
+ client,
+ room._account._client,
+ "mayClientSendStateEvent got the expected client"
+ );
+ return false;
+ },
+ };
+ equal(
+ room.encryptionState,
+ Ci.prplIConversation.ENCRYPTION_ENABLED,
+ "Encryption state is enabled"
+ );
+ room._hasUnverifiedDevices = false;
+ equal(
+ room.encryptionState,
+ Ci.prplIConversation.ENCRYPTION_TRUSTED,
+ "Encryption state is trusted"
+ );
+ await Promise.resolve();
+ room.forget();
+});
+
+add_task(async function test_addEventReaction() {
+ const event = makeEvent({
+ sender: "@user:example.com",
+ type: MatrixSDK.EventType.Reaction,
+ content: {
+ ["m.relates_to"]: {
+ rel_type: MatrixSDK.RelationType.Annotation,
+ event_id: "!event:example.com",
+ key: "🐦",
+ },
+ },
+ });
+ let wroteMessage = false;
+ const roomStub = {
+ _account: {
+ userId: "@user:example.com",
+ _client: {
+ getHomeserverUrl() {
+ return "https://example.com/";
+ },
+ },
+ },
+ room: {
+ findEventById(id) {
+ equal(id, "!event:example.com", "Reading expected annotated event");
+ return {
+ getSender() {
+ return "@foo:example.com";
+ },
+ };
+ },
+ },
+ writeMessage(who, message, options) {
+ equal(who, "@user:example.com", "Correct sender for reaction");
+ ok(message.includes("🐦"), "Message contains reaction content");
+ ok(options.system, "reaction is a system message");
+ wroteMessage = true;
+ },
+ };
+ MatrixRoom.prototype.addEvent.call(roomStub, event);
+ ok(wroteMessage, "Wrote reaction to conversation");
+});
+
+add_task(async function test_removeParticipant() {
+ let roomMembers = [
+ {
+ userId: "@foo:example.com",
+ },
+ {
+ userId: "@bar:example.com",
+ },
+ ];
+ const room = getRoom(true, "#test:example.com", {
+ getJoinedMembers() {
+ return roomMembers;
+ },
+ });
+ for (const member of roomMembers) {
+ room.addParticipant(member);
+ }
+ equal(room._participants.size, 2, "Room has two participants");
+
+ const participantRemoved = waitForNotification(room, "chat-buddy-remove");
+ room.removeParticipant(roomMembers.splice(1, 1)[0].userId);
+ const { subject } = await participantRemoved;
+ const participantsArray = Array.from(
+ subject.QueryInterface(Ci.nsISimpleEnumerator)
+ );
+ equal(participantsArray.length, 1, "One participant is being removed");
+ equal(
+ participantsArray[0].QueryInterface(Ci.nsISupportsString).data,
+ "@bar:example.com",
+ "The participant is being removed by its user ID"
+ );
+ equal(room._participants.size, 1, "One participant is left");
+ room.forget();
+});
+
+add_task(function test_highlightForNotifications() {
+ const time = Date.now();
+ const event = makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ time,
+ sender: "@foo:example.com",
+ });
+ const roomStub = getRoom(true, "#test:example.com", {
+ getPushActionsForEvent(eventToProcess) {
+ equal(eventToProcess, event);
+ return {
+ notify: true,
+ };
+ },
+ });
+ const message = roomStub.createMessage("@foo:example.com", "bar", {
+ event,
+ });
+ equal(message.message, "bar");
+ equal(message.who, "@foo:example.com");
+ equal(message.conversation, roomStub);
+ ok(!message.outgoing);
+ ok(message.incoming);
+ equal(message.alias, "foo bar");
+ ok(message.containsNick);
+ roomStub.forget();
+});
+
+function waitForNotification(target, expectedTopic) {
+ let promise = new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ if (topic === expectedTopic) {
+ resolve({ subject, data });
+ target.removeObserver(observer);
+ }
+ },
+ };
+ target.addObserver(observer);
+ });
+ return promise;
+}
diff --git a/comm/chat/protocols/matrix/test/test_matrixTextForEvent.js b/comm/chat/protocols/matrix/test/test_matrixTextForEvent.js
new file mode 100644
index 0000000000..feeab4edee
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_matrixTextForEvent.js
@@ -0,0 +1,834 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { getMatrixTextForEvent } = ChromeUtils.importESModule(
+ "resource:///modules/matrixTextForEvent.sys.mjs"
+);
+var { l10nHelper } = ChromeUtils.importESModule(
+ "resource:///modules/imXPCOMUtils.sys.mjs"
+);
+var _ = l10nHelper("chrome://chat/locale/matrix.properties");
+
+function run_test() {
+ add_test(testGetTextForMatrixEvent);
+ run_next_test();
+}
+
+const SENDER = "@test:example.com";
+const FIXTURES = [
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "ban",
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.banned", SENDER, "@foo:example.com"),
+ name: "Banned without reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "ban",
+ reason: "test",
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.bannedWithReason", SENDER, "@foo:example.com", "test"),
+ name: "Banned with reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "invite",
+ third_party_invite: {
+ display_name: "bar",
+ },
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.acceptedInviteFor", "@foo:example.com", "bar"),
+ name: "Invite accepted by other user with display name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "invite",
+ third_party_invite: {},
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.acceptedInvite", "@foo:example.com"),
+ name: "Invite accepted by other user",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "invite",
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.invited", SENDER, "@foo:example.com"),
+ name: "User invited",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "join",
+ displayname: "ipsum",
+ },
+ prevContent: {
+ membership: "join",
+ displayname: "lorem",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.displayName.changed", SENDER, "lorem", "ipsum"),
+ name: "User changed their display name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "join",
+ displayname: "ipsum",
+ },
+ prevContent: {
+ membership: "join",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.displayName.set", SENDER, "ipsum"),
+ name: "User set their display name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "join",
+ },
+ prevContent: {
+ membership: "join",
+ displayname: "lorem",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.displayName.remove", SENDER, "lorem"),
+ name: "User removed their display name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "join",
+ },
+ prevContent: {
+ membership: "join",
+ },
+ sender: SENDER,
+ }),
+ result: null,
+ name: "Users join event was edited without relevant changes",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "join",
+ },
+ target: {
+ userId: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.joined", "@foo:example.com"),
+ name: "Users joined",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "invite",
+ },
+ target: {
+ userId: "@test:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.rejectedInvite", "@test:example.com"),
+ name: "Invite rejected",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "join",
+ },
+ target: {
+ userId: "@test:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.left", "@test:example.com"),
+ name: "Left room",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "ban",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.unbanned", SENDER, "@target:example.com"),
+ name: "Unbanned",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "join",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.kicked", SENDER, "@target:example.com"),
+ name: "Kicked without reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ reason: "lorem ipsum",
+ },
+ prevContent: {
+ membership: "join",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.kickedWithReason",
+ SENDER,
+ "@target:example.com",
+ "lorem ipsum"
+ ),
+ name: "Kicked with reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ reason: "lorem ipsum",
+ },
+ prevContent: {
+ membership: "invite",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.withdrewInviteWithReason",
+ SENDER,
+ "@target:example.com",
+ "lorem ipsum"
+ ),
+ name: "Invite withdrawn with reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "invite",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.withdrewInvite", SENDER, "@target:example.com"),
+ name: "Invite withdrawn without reason",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMember,
+ content: {
+ membership: "leave",
+ },
+ prevContent: {
+ membership: "leave",
+ },
+ target: {
+ userId: "@target:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: null,
+ name: "No message for leave to leave",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ sender: SENDER,
+ }),
+ result: null,
+ name: "No previous power levels",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ },
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ },
+ },
+ sender: SENDER,
+ }),
+ result: null,
+ name: "No user power level changes",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 50,
+ },
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ },
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.default") + " (0)",
+ _("powerLevel.moderator") + " (50)"
+ )
+ ),
+ name: "Gave a user power levels",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 50,
+ },
+ users_default: 10,
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ },
+ users_default: 10,
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.default") + " (10)",
+ _("powerLevel.moderator") + " (50)"
+ )
+ ),
+ name: "Gave a user power levels with default level",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 10,
+ },
+ users_default: 10,
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 0,
+ },
+ users_default: 10,
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.restricted") + " (0)",
+ _("powerLevel.default") + " (10)"
+ )
+ ),
+ name: "Promote a restricted user to default",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 100,
+ },
+ users_default: 10,
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 50,
+ },
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.moderator") + " (50)",
+ _("powerLevel.admin") + " (100)"
+ )
+ ),
+ name: "Prompted user from moderator to admin",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 0,
+ },
+ users_default: 0,
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 100,
+ },
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.admin") + " (100)",
+ _("powerLevel.default") + " (0)"
+ )
+ ),
+ name: "Demote user from admin to default",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomPowerLevels,
+ content: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 50,
+ "@bar:example.com": 0,
+ },
+ users_default: 0,
+ },
+ prevContent: {
+ users: {
+ "@test:example.com": 100,
+ "@foo:example.com": 0,
+ "@bar:example.com": 50,
+ },
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.powerLevel.changed",
+ SENDER,
+ _(
+ "message.powerLevel.fromTo",
+ "@foo:example.com",
+ _("powerLevel.default") + " (0)",
+ _("powerLevel.moderator") + " (50)"
+ ) +
+ ", " +
+ _(
+ "message.powerLevel.fromTo",
+ "@bar:example.com",
+ _("powerLevel.moderator") + " (50)",
+ _("powerLevel.default") + " (0)"
+ )
+ ),
+ name: "Changed multiple users's power level",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomName,
+ content: {
+ name: "test",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.roomName.changed", SENDER, "test"),
+ name: "Set room name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomName,
+ sender: SENDER,
+ }),
+ result: _("message.roomName.remove", SENDER),
+ name: "Remove room name",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomGuestAccess,
+ content: {
+ guest_access: MatrixSDK.GuestAccess.Forbidden,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.guest.prevented", SENDER),
+ name: "Guest access forbidden",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomGuestAccess,
+ content: {
+ guest_access: MatrixSDK.GuestAccess.CanJoin,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.guest.allowed", SENDER),
+ name: "Guest access allowed",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomHistoryVisibility,
+ content: {
+ history_visibility: MatrixSDK.HistoryVisibility.WorldReadable,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.history.anyone", SENDER),
+ name: "History access granted to anyone",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomHistoryVisibility,
+ content: {
+ history_visibility: MatrixSDK.HistoryVisibility.Shared,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.history.shared", SENDER),
+ name: "History access granted to members, including before they joined",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomHistoryVisibility,
+ content: {
+ history_visibility: MatrixSDK.HistoryVisibility.Invited,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.history.invited", SENDER),
+ name: "History access granted to members, including invited",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomHistoryVisibility,
+ content: {
+ history_visibility: MatrixSDK.HistoryVisibility.Joined,
+ },
+ sender: SENDER,
+ }),
+ result: _("message.history.joined", SENDER),
+ name: "History access granted to members from the point they join",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.alias.main", SENDER, undefined, "#test:example.com"),
+ name: "Room alias added",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ },
+ prevContent: {
+ alias: "#old:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.alias.main",
+ SENDER,
+ "#old:example.com",
+ "#test:example.com"
+ ),
+ name: "Room alias changed",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: ["#foo:example.com"],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.alias.added", SENDER, "#foo:example.com"),
+ name: "Room alt alias added",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ alt_aliases: ["#foo:example.com"],
+ },
+ sender: SENDER,
+ }),
+ result: _("message.alias.removed", SENDER, "#foo:example.com"),
+ name: "Room alt alias removed",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: ["#bar:example.com"],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ alt_aliases: ["#foo:example.com", "#bar:example.com"],
+ },
+ sender: SENDER,
+ }),
+ result: _("message.alias.removed", SENDER, "#foo:example.com"),
+ name: "Room alt alias removed with multiple alts",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: ["#foo:example.com", "#bar:example.com"],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ alt_aliases: ["#bar:example.com"],
+ },
+ sender: SENDER,
+ }),
+ result: _("message.alias.added", SENDER, "#foo:example.com"),
+ name: "Room alt alias added with multiple alts",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: [
+ "#foo:example.com",
+ "#bar:example.com",
+ "#baz:example.com",
+ ],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ alt_aliases: ["#bar:example.com"],
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.alias.added",
+ SENDER,
+ "#foo:example.com, #baz:example.com"
+ ),
+ name: "Multiple room alt aliases added with multiple alts",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: ["#foo:example.com", "#bar:example.com"],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ alt_aliases: ["#bar:example.com", "#baz:example.com"],
+ },
+ sender: SENDER,
+ }),
+ result: _(
+ "message.alias.removedAndAdded",
+ SENDER,
+ "#baz:example.com",
+ "#foo:example.com"
+ ),
+ name: "Room alias added and removed",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomCanonicalAlias,
+ content: {
+ alias: "#test:example.com",
+ alt_aliases: [],
+ },
+ prevContent: {
+ alias: "#test:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: null,
+ name: "No discernible changes to the room aliases",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMessage,
+ content: {
+ msgtype: MatrixSDK.MsgType.KeyVerificationRequest,
+ to: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.verification.request2", SENDER, "@foo:example.com"),
+ name: "Inline key verification request",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.KeyVerificationRequest,
+ content: {
+ to: "@foo:example.com",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.verification.request2", SENDER, "@foo:example.com"),
+ name: "Key verification request",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.KeyVerificationCancel,
+ content: {
+ reason: "Lorem ipsum",
+ },
+ sender: SENDER,
+ }),
+ result: _("message.verification.cancel2", SENDER, "Lorem ipsum"),
+ name: "Key verification cancelled",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.KeyVerificationDone,
+ sender: SENDER,
+ }),
+ result: _("message.verification.done"),
+ name: "Key verification done",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomMessageEncrypted,
+ content: {
+ msgtype: "m.bad.encrypted",
+ },
+ }),
+ result: _("message.decryptionError"),
+ name: "Decryption error",
+ },
+ {
+ event: makeEvent({
+ type: MatrixSDK.EventType.RoomEncryption,
+ }),
+ result: _("message.encryptionStart"),
+ name: "Encryption start",
+ },
+];
+
+function testGetTextForMatrixEvent() {
+ for (const fixture of FIXTURES) {
+ const result = getMatrixTextForEvent(fixture.event);
+ equal(result, fixture.result, fixture.name);
+ }
+ run_next_test();
+}
diff --git a/comm/chat/protocols/matrix/test/test_roomTypeChange.js b/comm/chat/protocols/matrix/test/test_roomTypeChange.js
new file mode 100644
index 0000000000..df2bc39200
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/test_roomTypeChange.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+loadMatrix();
+
+add_task(async function test_toDMConversation() {
+ const acc = getAccount({});
+ const roomId = "#test:example.com";
+ acc.isDirectRoom = rId => roomId === rId;
+ const conversation = new MatrixRoom(acc, true, roomId);
+ conversation.initRoom(
+ getClientRoom(
+ roomId,
+ {
+ guessDMUserId() {
+ return "@user:example.com";
+ },
+ // Avoid running searchForVerificationRequests
+ getMyMembership() {
+ return "leave";
+ },
+ },
+ acc._client
+ )
+ );
+ await conversation.checkForUpdate();
+ ok(!conversation.isChat);
+ conversation.forget();
+});
+
+add_task(async function test_toGroupConversation() {
+ const acc = getAccount({});
+ const roomId = "#test:example.com";
+ acc.isDirectRoom = rId => roomId !== rId;
+ const conversation = new MatrixRoom(acc, false, roomId);
+ conversation.initRoom(
+ getClientRoom(
+ roomId,
+ {
+ guessDMUserId() {
+ return "@user:example.com";
+ },
+ // Avoid running searchForVerificationRequests
+ getMyMembership() {
+ return "leave";
+ },
+ },
+ acc._client
+ )
+ );
+ await conversation.checkForUpdate();
+ ok(conversation.isChat);
+ conversation.forget();
+});
diff --git a/comm/chat/protocols/matrix/test/xpcshell.ini b/comm/chat/protocols/matrix/test/xpcshell.ini
new file mode 100644
index 0000000000..92bcc7168e
--- /dev/null
+++ b/comm/chat/protocols/matrix/test/xpcshell.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+head = head.js
+tail =
+
+[test_matrixAccount.js]
+[test_matrixCommands.js]
+[test_matrixTextForEvent.js]
+[test_matrixMessage.js]
+[test_matrixMessageContent.js]
+[test_matrixPowerLevels.js]
+[test_matrixRoom.js]
+[test_roomTypeChange.js]
diff --git a/comm/chat/protocols/odnoklassniki/components.conf b/comm/chat/protocols/odnoklassniki/components.conf
new file mode 100644
index 0000000000..0889166787
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{29b09a83-81c1-2032-11e2-6d9bc4f8e969}',
+ 'contract_ids': ['@mozilla.org/chat/odnoklassniki;1'],
+ 'esModule': 'resource:///modules/odnoklassniki.sys.mjs',
+ 'constructor': 'OdnoklassnikiProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-odnoklassniki'},
+ },
+]
diff --git a/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-32.png b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-32.png
new file mode 100644
index 0000000000..5057ef77bf
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-32.png
Binary files differ
diff --git a/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-48.png b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-48.png
new file mode 100644
index 0000000000..63e2517ffd
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-48.png
Binary files differ
diff --git a/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki.png b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki.png
new file mode 100644
index 0000000000..8904397333
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki.png
Binary files differ
diff --git a/comm/chat/protocols/odnoklassniki/jar.mn b/comm/chat/protocols/odnoklassniki/jar.mn
new file mode 100644
index 0000000000..8667e00236
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-odnoklassniki classic/1.0 %skin/classic/prpl/odnoklassniki/
+ skin/classic/prpl/odnoklassniki/icon32.png (icons/prpl-odnoklassniki-32.png)
+ skin/classic/prpl/odnoklassniki/icon48.png (icons/prpl-odnoklassniki-48.png)
+ skin/classic/prpl/odnoklassniki/icon.png (icons/prpl-odnoklassniki.png)
diff --git a/comm/chat/protocols/odnoklassniki/moz.build b/comm/chat/protocols/odnoklassniki/moz.build
new file mode 100644
index 0000000000..a7e9104619
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXTRA_JS_MODULES += [
+ "odnoklassniki.sys.mjs",
+]
diff --git a/comm/chat/protocols/odnoklassniki/odnoklassniki.sys.mjs b/comm/chat/protocols/odnoklassniki/odnoklassniki.sys.mjs
new file mode 100644
index 0000000000..de8fe2f42e
--- /dev/null
+++ b/comm/chat/protocols/odnoklassniki/odnoklassniki.sys.mjs
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ XMPPAccountPrototype: "resource:///modules/xmpp-base.sys.mjs",
+ XMPPSession: "resource:///modules/xmpp-session.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "OdnoklassnikiAccount", () => {
+ function OdnoklassnikiAccount(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+ }
+ OdnoklassnikiAccount.prototype = {
+ __proto__: lazy.XMPPAccountPrototype,
+ get canJoinChat() {
+ return false;
+ },
+ connect() {
+ if (!this.name.includes("@")) {
+ // TODO: Do not use the default resource value if the user has not
+ // specified it and let the service generate it.
+ let jid =
+ this.name +
+ "@odnoklassniki.ru/" +
+ Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName");
+ this._jid = this._parseJID(jid);
+ } else {
+ this._jid = this._parseJID(this.name);
+ if (this._jid.domain != "odnoklassniki.ru") {
+ // We can't use this.onError because this._connection doesn't exist.
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_INVALID_USERNAME,
+ lazy._("connection.error.invalidUsername")
+ );
+ this.reportDisconnected();
+ return;
+ }
+ }
+
+ this._connection = new lazy.XMPPSession(
+ "xmpp.odnoklassniki.ru",
+ 5222,
+ "require_tls",
+ this._jid,
+ this.imAccount.password,
+ this
+ );
+ },
+ };
+ return OdnoklassnikiAccount;
+});
+
+export function OdnoklassnikiProtocol() {}
+OdnoklassnikiProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "odnoklassniki";
+ },
+ get name() {
+ return lazy._("odnoklassniki.protocolName");
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-odnoklassniki/skin/";
+ },
+ get usernameEmptyText() {
+ return lazy._("odnoklassniki.usernameHint");
+ },
+ getAccount(aImAccount) {
+ return new lazy.OdnoklassnikiAccount(this, aImAccount);
+ },
+};
diff --git a/comm/chat/protocols/twitter/components.conf b/comm/chat/protocols/twitter/components.conf
new file mode 100644
index 0000000000..d39639f09d
--- /dev/null
+++ b/comm/chat/protocols/twitter/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{31082ff6-1de8-422b-ab60-ca0ac0b2af13}',
+ 'contract_ids': ['@mozilla.org/chat/twitter;1'],
+ 'esModule': 'resource:///modules/twitter.sys.mjs',
+ 'constructor': 'TwitterProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-twitter'},
+ },
+]
diff --git a/comm/chat/protocols/twitter/icons/prpl-twitter-32.png b/comm/chat/protocols/twitter/icons/prpl-twitter-32.png
new file mode 100644
index 0000000000..61f6c703f1
--- /dev/null
+++ b/comm/chat/protocols/twitter/icons/prpl-twitter-32.png
Binary files differ
diff --git a/comm/chat/protocols/twitter/icons/prpl-twitter-48.png b/comm/chat/protocols/twitter/icons/prpl-twitter-48.png
new file mode 100644
index 0000000000..166ba27160
--- /dev/null
+++ b/comm/chat/protocols/twitter/icons/prpl-twitter-48.png
Binary files differ
diff --git a/comm/chat/protocols/twitter/icons/prpl-twitter-left.png b/comm/chat/protocols/twitter/icons/prpl-twitter-left.png
new file mode 100644
index 0000000000..fc1906da07
--- /dev/null
+++ b/comm/chat/protocols/twitter/icons/prpl-twitter-left.png
Binary files differ
diff --git a/comm/chat/protocols/twitter/icons/prpl-twitter.png b/comm/chat/protocols/twitter/icons/prpl-twitter.png
new file mode 100644
index 0000000000..3f36dabcaf
--- /dev/null
+++ b/comm/chat/protocols/twitter/icons/prpl-twitter.png
Binary files differ
diff --git a/comm/chat/protocols/twitter/jar.mn b/comm/chat/protocols/twitter/jar.mn
new file mode 100644
index 0000000000..cb2ed81c0d
--- /dev/null
+++ b/comm/chat/protocols/twitter/jar.mn
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-twitter classic/1.0 %skin/classic/prpl/twitter/
+ skin/classic/prpl/twitter/icon32.png (icons/prpl-twitter-32.png)
+ skin/classic/prpl/twitter/icon48.png (icons/prpl-twitter-48.png)
+ skin/classic/prpl/twitter/icon.png (icons/prpl-twitter.png)
+ skin/classic/prpl/twitter/icon-left.png (icons/prpl-twitter-left.png)
diff --git a/comm/chat/protocols/twitter/moz.build b/comm/chat/protocols/twitter/moz.build
new file mode 100644
index 0000000000..c3b11e5de6
--- /dev/null
+++ b/comm/chat/protocols/twitter/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "twitter.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/twitter/twitter.sys.mjs b/comm/chat/protocols/twitter/twitter.sys.mjs
new file mode 100644
index 0000000000..96f856ea0f
--- /dev/null
+++ b/comm/chat/protocols/twitter/twitter.sys.mjs
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericProtocolPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/twitter.properties")
+);
+
+function Account(aProtocol, aImAccount) {
+ this._init(aProtocol, aImAccount);
+}
+Account.prototype = {
+ __proto__: GenericAccountPrototype,
+
+ connect() {
+ this.WARN(
+ "Twitter is no longer supported due to Twitter disabling the streaming " +
+ "support in their API. See bug 1445778."
+ );
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("twitter.disabled")
+ );
+ this.reportDisconnected();
+ },
+
+ // Nothing to do.
+ unInit() {},
+ remove() {},
+};
+
+export function TwitterProtocol() {
+ this.registerCommands();
+}
+
+TwitterProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "twitter";
+ },
+ get name() {
+ return lazy._("twitter.protocolName");
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-twitter/skin/";
+ },
+ get noPassword() {
+ return true;
+ },
+ getAccount(aImAccount) {
+ return new Account(this, aImAccount);
+ },
+};
diff --git a/comm/chat/protocols/xmpp/.eslintrc.js b/comm/chat/protocols/xmpp/.eslintrc.js
new file mode 100644
index 0000000000..66953c7e25
--- /dev/null
+++ b/comm/chat/protocols/xmpp/.eslintrc.js
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ rules: {
+ // The following rules will not be enabled currently.
+ complexity: "off",
+ },
+};
diff --git a/comm/chat/protocols/xmpp/components.conf b/comm/chat/protocols/xmpp/components.conf
new file mode 100644
index 0000000000..83943f1a34
--- /dev/null
+++ b/comm/chat/protocols/xmpp/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{dde786d1-6f59-43d0-9bc8-b505a757fb30}',
+ 'contract_ids': ['@mozilla.org/chat/xmpp;1'],
+ 'esModule': 'resource:///modules/xmpp.sys.mjs',
+ 'constructor': 'XMPPProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-jabber'},
+ },
+]
diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png b/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png
new file mode 100644
index 0000000000..98897f75fb
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png
Binary files differ
diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png
new file mode 100644
index 0000000000..805820c565
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png
Binary files differ
diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber.png b/comm/chat/protocols/xmpp/icons/prpl-jabber.png
new file mode 100644
index 0000000000..bb04c6e6df
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber.png
Binary files differ
diff --git a/comm/chat/protocols/xmpp/jar.mn b/comm/chat/protocols/xmpp/jar.mn
new file mode 100644
index 0000000000..1f7ac54abc
--- /dev/null
+++ b/comm/chat/protocols/xmpp/jar.mn
@@ -0,0 +1,5 @@
+chat.jar:
+% skin prpl-jabber classic/1.0 %skin/classic/prpl/xmpp/
+ skin/classic/prpl/xmpp/icon32.png (icons/prpl-jabber-32.png)
+ skin/classic/prpl/xmpp/icon48.png (icons/prpl-jabber-48.png)
+ skin/classic/prpl/xmpp/icon.png (icons/prpl-jabber.png)
diff --git a/comm/chat/protocols/xmpp/lib/README.md b/comm/chat/protocols/xmpp/lib/README.md
new file mode 100644
index 0000000000..c813b5d070
--- /dev/null
+++ b/comm/chat/protocols/xmpp/lib/README.md
@@ -0,0 +1,6 @@
+This directory contains sax-js from https://github.com/isaacs/sax-js. Current version is v1.2.4.
+
+## Updating sax-js
+
+1. Download a release version from https://github.com/isaacs/sax-js.
+2. Copy `lib/sax.js` from the git repo to `sax/sax.js` in this directory.
diff --git a/comm/chat/protocols/xmpp/lib/moz.build b/comm/chat/protocols/xmpp/lib/moz.build
new file mode 100644
index 0000000000..b4f1787145
--- /dev/null
+++ b/comm/chat/protocols/xmpp/lib/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES.sax += [
+ "sax/sax.js",
+]
diff --git a/comm/chat/protocols/xmpp/lib/sax/LICENSE b/comm/chat/protocols/xmpp/lib/sax/LICENSE
new file mode 100644
index 0000000000..ccffa082c9
--- /dev/null
+++ b/comm/chat/protocols/xmpp/lib/sax/LICENSE
@@ -0,0 +1,41 @@
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+====
+
+`String.fromCodePoint` by Mathias Bynens used according to terms of MIT
+License, as follows:
+
+ Copyright Mathias Bynens <https://mathiasbynens.be/>
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/comm/chat/protocols/xmpp/lib/sax/sax.js b/comm/chat/protocols/xmpp/lib/sax/sax.js
new file mode 100644
index 0000000000..564d8d4235
--- /dev/null
+++ b/comm/chat/protocols/xmpp/lib/sax/sax.js
@@ -0,0 +1,1648 @@
+/* This program is made available under an ISC-style license. */
+(function(sax) {
+ // wrapper for non-node envs
+ sax.parser = function(strict, opt) {
+ return new SAXParser(strict, opt);
+ };
+ sax.SAXParser = SAXParser;
+ sax.SAXStream = SAXStream;
+ sax.createStream = createStream;
+
+ // When we pass the MAX_BUFFER_LENGTH position, start checking for buffer overruns.
+ // When we check, schedule the next check for MAX_BUFFER_LENGTH - (max(buffer lengths)),
+ // since that's the earliest that a buffer overrun could occur. This way, checks are
+ // as rare as required, but as often as necessary to ensure never crossing this bound.
+ // Furthermore, buffers are only tested at most once per write(), so passing a very
+ // large string into write() might have undesirable effects, but this is manageable by
+ // the caller, so it is assumed to be safe. Thus, a call to write() may, in the extreme
+ // edge case, result in creating at most one complete copy of the string passed in.
+ // Set to Infinity to have unlimited buffers.
+ sax.MAX_BUFFER_LENGTH = 64 * 1024;
+
+ var buffers = [
+ "comment",
+ "sgmlDecl",
+ "textNode",
+ "tagName",
+ "doctype",
+ "procInstName",
+ "procInstBody",
+ "entity",
+ "attribName",
+ "attribValue",
+ "cdata",
+ "script",
+ ];
+
+ sax.EVENTS = [
+ "text",
+ "processinginstruction",
+ "sgmldeclaration",
+ "doctype",
+ "comment",
+ "opentagstart",
+ "attribute",
+ "opentag",
+ "closetag",
+ "opencdata",
+ "cdata",
+ "closecdata",
+ "error",
+ "end",
+ "ready",
+ "script",
+ "opennamespace",
+ "closenamespace",
+ ];
+
+ function SAXParser(strict, opt) {
+ if (!(this instanceof SAXParser)) {
+ return new SAXParser(strict, opt);
+ }
+
+ var parser = this;
+ clearBuffers(parser);
+ parser.q = parser.c = "";
+ parser.bufferCheckPosition = sax.MAX_BUFFER_LENGTH;
+ parser.opt = opt || {};
+ parser.opt.lowercase = parser.opt.lowercase || parser.opt.lowercasetags;
+ parser.looseCase = parser.opt.lowercase ? "toLowerCase" : "toUpperCase";
+ parser.tags = [];
+ parser.closed = parser.closedRoot = parser.sawRoot = false;
+ parser.tag = parser.error = null;
+ parser.strict = !!strict;
+ parser.noscript = !!(strict || parser.opt.noscript);
+ parser.state = S.BEGIN;
+ parser.strictEntities = parser.opt.strictEntities;
+ parser.ENTITIES = parser.strictEntities
+ ? Object.create(sax.XML_ENTITIES)
+ : Object.create(sax.ENTITIES);
+ parser.attribList = [];
+
+ // namespaces form a prototype chain.
+ // it always points at the current tag,
+ // which protos to its parent tag.
+ if (parser.opt.xmlns) {
+ parser.ns = Object.create(rootNS);
+ }
+
+ // mostly just for error reporting
+ parser.trackPosition = parser.opt.position !== false;
+ if (parser.trackPosition) {
+ parser.position = parser.line = parser.column = 0;
+ }
+ emit(parser, "onready");
+ }
+
+ if (!Object.create) {
+ Object.create = function(o) {
+ function F() {}
+ F.prototype = o;
+ var newf = new F();
+ return newf;
+ };
+ }
+
+ if (!Object.keys) {
+ Object.keys = function(o) {
+ var a = [];
+ for (var i in o) {
+ if (o.hasOwnProperty(i)) {
+ a.push(i);
+ }
+ }
+ return a;
+ };
+ }
+
+ function checkBufferLength(parser) {
+ var maxAllowed = Math.max(sax.MAX_BUFFER_LENGTH, 10);
+ var maxActual = 0;
+ for (var i = 0, l = buffers.length; i < l; i++) {
+ var len = parser[buffers[i]].length;
+ if (len > maxAllowed) {
+ // Text/cdata nodes can get big, and since they're buffered,
+ // we can get here under normal conditions.
+ // Avoid issues by emitting the text node now,
+ // so at least it won't get any bigger.
+ switch (buffers[i]) {
+ case "textNode":
+ closeText(parser);
+ break;
+
+ case "cdata":
+ emitNode(parser, "oncdata", parser.cdata);
+ parser.cdata = "";
+ break;
+
+ case "script":
+ emitNode(parser, "onscript", parser.script);
+ parser.script = "";
+ break;
+
+ default:
+ error(parser, "Max buffer length exceeded: " + buffers[i]);
+ }
+ }
+ maxActual = Math.max(maxActual, len);
+ }
+ // schedule the next check for the earliest possible buffer overrun.
+ var m = sax.MAX_BUFFER_LENGTH - maxActual;
+ parser.bufferCheckPosition = m + parser.position;
+ }
+
+ function clearBuffers(parser) {
+ for (var i = 0, l = buffers.length; i < l; i++) {
+ parser[buffers[i]] = "";
+ }
+ }
+
+ function flushBuffers(parser) {
+ closeText(parser);
+ if (parser.cdata !== "") {
+ emitNode(parser, "oncdata", parser.cdata);
+ parser.cdata = "";
+ }
+ if (parser.script !== "") {
+ emitNode(parser, "onscript", parser.script);
+ parser.script = "";
+ }
+ }
+
+ SAXParser.prototype = {
+ end() {
+ end(this);
+ },
+ write,
+ resume() {
+ this.error = null;
+ return this;
+ },
+ close() {
+ return this.write(null);
+ },
+ flush() {
+ flushBuffers(this);
+ },
+ };
+
+ var Stream;
+ try {
+ Stream = require("stream").Stream;
+ } catch (ex) {
+ Stream = function() {};
+ }
+
+ var streamWraps = sax.EVENTS.filter(function(ev) {
+ return ev !== "error" && ev !== "end";
+ });
+
+ function createStream(strict, opt) {
+ return new SAXStream(strict, opt);
+ }
+
+ function SAXStream(strict, opt) {
+ if (!(this instanceof SAXStream)) {
+ return new SAXStream(strict, opt);
+ }
+
+ Stream.apply(this);
+
+ this._parser = new SAXParser(strict, opt);
+ this.writable = true;
+ this.readable = true;
+
+ var me = this;
+
+ this._parser.onend = function() {
+ me.emit("end");
+ };
+
+ this._parser.onerror = function(er) {
+ me.emit("error", er);
+
+ // if didn't throw, then means error was handled.
+ // go ahead and clear error, so we can write again.
+ me._parser.error = null;
+ };
+
+ this._decoder = null;
+
+ streamWraps.forEach(function(ev) {
+ Object.defineProperty(me, "on" + ev, {
+ get() {
+ return me._parser["on" + ev];
+ },
+ set(h) {
+ if (!h) {
+ me.removeAllListeners(ev);
+ me._parser["on" + ev] = h;
+ return h;
+ }
+ me.on(ev, h);
+ },
+ enumerable: true,
+ configurable: false,
+ });
+ });
+ }
+
+ SAXStream.prototype = Object.create(Stream.prototype, {
+ constructor: {
+ value: SAXStream,
+ },
+ });
+
+ SAXStream.prototype.write = function(data) {
+ if (
+ typeof Buffer === "function" &&
+ typeof Buffer.isBuffer === "function" &&
+ Buffer.isBuffer(data)
+ ) {
+ if (!this._decoder) {
+ var SD = require("string_decoder").StringDecoder;
+ this._decoder = new SD("utf8");
+ }
+ data = this._decoder.write(data);
+ }
+
+ this._parser.write(data.toString());
+ this.emit("data", data);
+ return true;
+ };
+
+ SAXStream.prototype.end = function(chunk) {
+ if (chunk && chunk.length) {
+ this.write(chunk);
+ }
+ this._parser.end();
+ return true;
+ };
+
+ SAXStream.prototype.on = function(ev, handler) {
+ var me = this;
+ if (!me._parser["on" + ev] && streamWraps.indexOf(ev) !== -1) {
+ me._parser["on" + ev] = function() {
+ var args =
+ arguments.length === 1
+ ? [arguments[0]]
+ : Array.apply(null, arguments);
+ args.splice(0, 0, ev);
+ me.emit.apply(me, args);
+ };
+ }
+
+ return Stream.prototype.on.call(me, ev, handler);
+ };
+
+ // this really needs to be replaced with character classes.
+ // XML allows all manner of ridiculous numbers and digits.
+ var CDATA = "[CDATA[";
+ var DOCTYPE = "DOCTYPE";
+ var XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
+ var XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/";
+ var rootNS = { xml: XML_NAMESPACE, xmlns: XMLNS_NAMESPACE };
+
+ // http://www.w3.org/TR/REC-xml/#NT-NameStartChar
+ // This implementation works on strings, a single character at a time
+ // as such, it cannot ever support astral-plane characters (10000-EFFFF)
+ // without a significant breaking change to either this parser, or the
+ // JavaScript language. Implementation of an emoji-capable xml parser
+ // is left as an exercise for the reader.
+ var nameStart = /[:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/;
+
+ var nameBody = /[:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u00B7\u0300-\u036F\u203F-\u2040.\d-]/;
+
+ var entityStart = /[#:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/;
+ var entityBody = /[#:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u00B7\u0300-\u036F\u203F-\u2040.\d-]/;
+
+ function isWhitespace(c) {
+ return c === " " || c === "\n" || c === "\r" || c === "\t";
+ }
+
+ function isQuote(c) {
+ return c === '"' || c === "'";
+ }
+
+ function isAttribEnd(c) {
+ return c === ">" || isWhitespace(c);
+ }
+
+ function isMatch(regex, c) {
+ return regex.test(c);
+ }
+
+ function notMatch(regex, c) {
+ return !isMatch(regex, c);
+ }
+
+ var S = 0;
+ sax.STATE = {
+ BEGIN: S++, // leading byte order mark or whitespace
+ BEGIN_WHITESPACE: S++, // leading whitespace
+ TEXT: S++, // general stuff
+ TEXT_ENTITY: S++, // &amp and such.
+ OPEN_WAKA: S++, // <
+ SGML_DECL: S++, // <!BLARG
+ SGML_DECL_QUOTED: S++, // <!BLARG foo "bar
+ DOCTYPE: S++, // <!DOCTYPE
+ DOCTYPE_QUOTED: S++, // <!DOCTYPE "//blah
+ DOCTYPE_DTD: S++, // <!DOCTYPE "//blah" [ ...
+ DOCTYPE_DTD_QUOTED: S++, // <!DOCTYPE "//blah" [ "foo
+ COMMENT_STARTING: S++, // <!-
+ COMMENT: S++, // <!--
+ COMMENT_ENDING: S++, // <!-- blah -
+ COMMENT_ENDED: S++, // <!-- blah --
+ CDATA: S++, // <![CDATA[ something
+ CDATA_ENDING: S++, // ]
+ CDATA_ENDING_2: S++, // ]]
+ PROC_INST: S++, // <?hi
+ PROC_INST_BODY: S++, // <?hi there
+ PROC_INST_ENDING: S++, // <?hi "there" ?
+ OPEN_TAG: S++, // <strong
+ OPEN_TAG_SLASH: S++, // <strong /
+ ATTRIB: S++, // <a
+ ATTRIB_NAME: S++, // <a foo
+ ATTRIB_NAME_SAW_WHITE: S++, // <a foo _
+ ATTRIB_VALUE: S++, // <a foo=
+ ATTRIB_VALUE_QUOTED: S++, // <a foo="bar
+ ATTRIB_VALUE_CLOSED: S++, // <a foo="bar"
+ ATTRIB_VALUE_UNQUOTED: S++, // <a foo=bar
+ ATTRIB_VALUE_ENTITY_Q: S++, // <foo bar="&quot;"
+ ATTRIB_VALUE_ENTITY_U: S++, // <foo bar=&quot
+ CLOSE_TAG: S++, // </a
+ CLOSE_TAG_SAW_WHITE: S++, // </a >
+ SCRIPT: S++, // <script> ...
+ SCRIPT_ENDING: S++, // <script> ... <
+ };
+
+ sax.XML_ENTITIES = {
+ amp: "&",
+ gt: ">",
+ lt: "<",
+ quot: '"',
+ apos: "'",
+ };
+
+ sax.ENTITIES = {
+ amp: "&",
+ gt: ">",
+ lt: "<",
+ quot: '"',
+ apos: "'",
+ AElig: 198,
+ Aacute: 193,
+ Acirc: 194,
+ Agrave: 192,
+ Aring: 197,
+ Atilde: 195,
+ Auml: 196,
+ Ccedil: 199,
+ ETH: 208,
+ Eacute: 201,
+ Ecirc: 202,
+ Egrave: 200,
+ Euml: 203,
+ Iacute: 205,
+ Icirc: 206,
+ Igrave: 204,
+ Iuml: 207,
+ Ntilde: 209,
+ Oacute: 211,
+ Ocirc: 212,
+ Ograve: 210,
+ Oslash: 216,
+ Otilde: 213,
+ Ouml: 214,
+ THORN: 222,
+ Uacute: 218,
+ Ucirc: 219,
+ Ugrave: 217,
+ Uuml: 220,
+ Yacute: 221,
+ aacute: 225,
+ acirc: 226,
+ aelig: 230,
+ agrave: 224,
+ aring: 229,
+ atilde: 227,
+ auml: 228,
+ ccedil: 231,
+ eacute: 233,
+ ecirc: 234,
+ egrave: 232,
+ eth: 240,
+ euml: 235,
+ iacute: 237,
+ icirc: 238,
+ igrave: 236,
+ iuml: 239,
+ ntilde: 241,
+ oacute: 243,
+ ocirc: 244,
+ ograve: 242,
+ oslash: 248,
+ otilde: 245,
+ ouml: 246,
+ szlig: 223,
+ thorn: 254,
+ uacute: 250,
+ ucirc: 251,
+ ugrave: 249,
+ uuml: 252,
+ yacute: 253,
+ yuml: 255,
+ copy: 169,
+ reg: 174,
+ nbsp: 160,
+ iexcl: 161,
+ cent: 162,
+ pound: 163,
+ curren: 164,
+ yen: 165,
+ brvbar: 166,
+ sect: 167,
+ uml: 168,
+ ordf: 170,
+ laquo: 171,
+ not: 172,
+ shy: 173,
+ macr: 175,
+ deg: 176,
+ plusmn: 177,
+ sup1: 185,
+ sup2: 178,
+ sup3: 179,
+ acute: 180,
+ micro: 181,
+ para: 182,
+ middot: 183,
+ cedil: 184,
+ ordm: 186,
+ raquo: 187,
+ frac14: 188,
+ frac12: 189,
+ frac34: 190,
+ iquest: 191,
+ times: 215,
+ divide: 247,
+ OElig: 338,
+ oelig: 339,
+ Scaron: 352,
+ scaron: 353,
+ Yuml: 376,
+ fnof: 402,
+ circ: 710,
+ tilde: 732,
+ Alpha: 913,
+ Beta: 914,
+ Gamma: 915,
+ Delta: 916,
+ Epsilon: 917,
+ Zeta: 918,
+ Eta: 919,
+ Theta: 920,
+ Iota: 921,
+ Kappa: 922,
+ Lambda: 923,
+ Mu: 924,
+ Nu: 925,
+ Xi: 926,
+ Omicron: 927,
+ Pi: 928,
+ Rho: 929,
+ Sigma: 931,
+ Tau: 932,
+ Upsilon: 933,
+ Phi: 934,
+ Chi: 935,
+ Psi: 936,
+ Omega: 937,
+ alpha: 945,
+ beta: 946,
+ gamma: 947,
+ delta: 948,
+ epsilon: 949,
+ zeta: 950,
+ eta: 951,
+ theta: 952,
+ iota: 953,
+ kappa: 954,
+ lambda: 955,
+ mu: 956,
+ nu: 957,
+ xi: 958,
+ omicron: 959,
+ pi: 960,
+ rho: 961,
+ sigmaf: 962,
+ sigma: 963,
+ tau: 964,
+ upsilon: 965,
+ phi: 966,
+ chi: 967,
+ psi: 968,
+ omega: 969,
+ thetasym: 977,
+ upsih: 978,
+ piv: 982,
+ ensp: 8194,
+ emsp: 8195,
+ thinsp: 8201,
+ zwnj: 8204,
+ zwj: 8205,
+ lrm: 8206,
+ rlm: 8207,
+ ndash: 8211,
+ mdash: 8212,
+ lsquo: 8216,
+ rsquo: 8217,
+ sbquo: 8218,
+ ldquo: 8220,
+ rdquo: 8221,
+ bdquo: 8222,
+ dagger: 8224,
+ Dagger: 8225,
+ bull: 8226,
+ hellip: 8230,
+ permil: 8240,
+ prime: 8242,
+ Prime: 8243,
+ lsaquo: 8249,
+ rsaquo: 8250,
+ oline: 8254,
+ frasl: 8260,
+ euro: 8364,
+ image: 8465,
+ weierp: 8472,
+ real: 8476,
+ trade: 8482,
+ alefsym: 8501,
+ larr: 8592,
+ uarr: 8593,
+ rarr: 8594,
+ darr: 8595,
+ harr: 8596,
+ crarr: 8629,
+ lArr: 8656,
+ uArr: 8657,
+ rArr: 8658,
+ dArr: 8659,
+ hArr: 8660,
+ forall: 8704,
+ part: 8706,
+ exist: 8707,
+ empty: 8709,
+ nabla: 8711,
+ isin: 8712,
+ notin: 8713,
+ ni: 8715,
+ prod: 8719,
+ sum: 8721,
+ minus: 8722,
+ lowast: 8727,
+ radic: 8730,
+ prop: 8733,
+ infin: 8734,
+ ang: 8736,
+ and: 8743,
+ or: 8744,
+ cap: 8745,
+ cup: 8746,
+ int: 8747,
+ there4: 8756,
+ sim: 8764,
+ cong: 8773,
+ asymp: 8776,
+ ne: 8800,
+ equiv: 8801,
+ le: 8804,
+ ge: 8805,
+ sub: 8834,
+ sup: 8835,
+ nsub: 8836,
+ sube: 8838,
+ supe: 8839,
+ oplus: 8853,
+ otimes: 8855,
+ perp: 8869,
+ sdot: 8901,
+ lceil: 8968,
+ rceil: 8969,
+ lfloor: 8970,
+ rfloor: 8971,
+ lang: 9001,
+ rang: 9002,
+ loz: 9674,
+ spades: 9824,
+ clubs: 9827,
+ hearts: 9829,
+ diams: 9830,
+ };
+
+ Object.keys(sax.ENTITIES).forEach(function(key) {
+ var e = sax.ENTITIES[key];
+ var s = typeof e === "number" ? String.fromCharCode(e) : e;
+ sax.ENTITIES[key] = s;
+ });
+
+ for (var s in sax.STATE) {
+ sax.STATE[sax.STATE[s]] = s;
+ }
+
+ // shorthand
+ S = sax.STATE;
+
+ function emit(parser, event, data) {
+ parser[event] && parser[event](data);
+ }
+
+ function emitNode(parser, nodeType, data) {
+ if (parser.textNode) {
+ closeText(parser);
+ }
+ emit(parser, nodeType, data);
+ }
+
+ function closeText(parser) {
+ parser.textNode = textopts(parser.opt, parser.textNode);
+ if (parser.textNode) {
+ emit(parser, "ontext", parser.textNode);
+ }
+ parser.textNode = "";
+ }
+
+ function textopts(opt, text) {
+ if (opt.trim) {
+ text = text.trim();
+ }
+ if (opt.normalize) {
+ text = text.replace(/\s+/g, " ");
+ }
+ return text;
+ }
+
+ function error(parser, er) {
+ closeText(parser);
+ if (parser.trackPosition) {
+ er +=
+ "\nLine: " +
+ parser.line +
+ "\nColumn: " +
+ parser.column +
+ "\nChar: " +
+ parser.c;
+ }
+ er = new Error(er);
+ parser.error = er;
+ emit(parser, "onerror", er);
+ return parser;
+ }
+
+ function end(parser) {
+ if (parser.sawRoot && !parser.closedRoot) {
+ strictFail(parser, "Unclosed root tag");
+ }
+ if (
+ parser.state !== S.BEGIN &&
+ parser.state !== S.BEGIN_WHITESPACE &&
+ parser.state !== S.TEXT
+ ) {
+ error(parser, "Unexpected end");
+ }
+ closeText(parser);
+ parser.c = "";
+ parser.closed = true;
+ emit(parser, "onend");
+ SAXParser.call(parser, parser.strict, parser.opt);
+ return parser;
+ }
+
+ function strictFail(parser, message) {
+ if (typeof parser !== "object" || !(parser instanceof SAXParser)) {
+ throw new Error("bad call to strictFail");
+ }
+ if (parser.strict) {
+ error(parser, message);
+ }
+ }
+
+ function newTag(parser) {
+ if (!parser.strict) {
+ parser.tagName = parser.tagName[parser.looseCase]();
+ }
+ var parent = parser.tags[parser.tags.length - 1] || parser;
+ var tag = (parser.tag = { name: parser.tagName, attributes: {} });
+
+ // will be overridden if tag contails an xmlns="foo" or xmlns:foo="bar"
+ if (parser.opt.xmlns) {
+ tag.ns = parent.ns;
+ }
+ parser.attribList.length = 0;
+ emitNode(parser, "onopentagstart", tag);
+ }
+
+ function qname(name, attribute) {
+ var i = name.indexOf(":");
+ var qualName = i < 0 ? ["", name] : name.split(":");
+ var prefix = qualName[0];
+ var local = qualName[1];
+
+ // <x "xmlns"="http://foo">
+ if (attribute && name === "xmlns") {
+ prefix = "xmlns";
+ local = "";
+ }
+
+ return { prefix, local };
+ }
+
+ function attrib(parser) {
+ if (!parser.strict) {
+ parser.attribName = parser.attribName[parser.looseCase]();
+ }
+
+ if (
+ parser.attribList.indexOf(parser.attribName) !== -1 ||
+ parser.tag.attributes.hasOwnProperty(parser.attribName)
+ ) {
+ parser.attribName = parser.attribValue = "";
+ return;
+ }
+
+ if (parser.opt.xmlns) {
+ var qn = qname(parser.attribName, true);
+ var prefix = qn.prefix;
+ var local = qn.local;
+
+ if (prefix === "xmlns") {
+ // namespace binding attribute. push the binding into scope
+ if (local === "xml" && parser.attribValue !== XML_NAMESPACE) {
+ strictFail(
+ parser,
+ "xml: prefix must be bound to " +
+ XML_NAMESPACE +
+ "\n" +
+ "Actual: " +
+ parser.attribValue
+ );
+ } else if (
+ local === "xmlns" &&
+ parser.attribValue !== XMLNS_NAMESPACE
+ ) {
+ strictFail(
+ parser,
+ "xmlns: prefix must be bound to " +
+ XMLNS_NAMESPACE +
+ "\n" +
+ "Actual: " +
+ parser.attribValue
+ );
+ } else {
+ var tag = parser.tag;
+ var parent = parser.tags[parser.tags.length - 1] || parser;
+ if (tag.ns === parent.ns) {
+ tag.ns = Object.create(parent.ns);
+ }
+ tag.ns[local] = parser.attribValue;
+ }
+ }
+
+ // defer onattribute events until all attributes have been seen
+ // so any new bindings can take effect. preserve attribute order
+ // so deferred events can be emitted in document order
+ parser.attribList.push([parser.attribName, parser.attribValue]);
+ } else {
+ // in non-xmlns mode, we can emit the event right away
+ parser.tag.attributes[parser.attribName] = parser.attribValue;
+ emitNode(parser, "onattribute", {
+ name: parser.attribName,
+ value: parser.attribValue,
+ });
+ }
+
+ parser.attribName = parser.attribValue = "";
+ }
+
+ function openTag(parser, selfClosing) {
+ if (parser.opt.xmlns) {
+ // emit namespace binding events
+ var tag = parser.tag;
+
+ // add namespace info to tag
+ var qn = qname(parser.tagName);
+ tag.prefix = qn.prefix;
+ tag.local = qn.local;
+ tag.uri = tag.ns[qn.prefix] || "";
+
+ if (tag.prefix && !tag.uri) {
+ strictFail(
+ parser,
+ "Unbound namespace prefix: " + JSON.stringify(parser.tagName)
+ );
+ tag.uri = qn.prefix;
+ }
+
+ var parent = parser.tags[parser.tags.length - 1] || parser;
+ if (tag.ns && parent.ns !== tag.ns) {
+ Object.keys(tag.ns).forEach(function(p) {
+ emitNode(parser, "onopennamespace", {
+ prefix: p,
+ uri: tag.ns[p],
+ });
+ });
+ }
+
+ // handle deferred onattribute events
+ // Note: do not apply default ns to attributes:
+ // http://www.w3.org/TR/REC-xml-names/#defaulting
+ for (var i = 0, l = parser.attribList.length; i < l; i++) {
+ var nv = parser.attribList[i];
+ var name = nv[0];
+ var value = nv[1];
+ var qualName = qname(name, true);
+ var prefix = qualName.prefix;
+ var local = qualName.local;
+ var uri = prefix === "" ? "" : tag.ns[prefix] || "";
+ var a = {
+ name,
+ value,
+ prefix,
+ local,
+ uri,
+ };
+
+ // if there's any attributes with an undefined namespace,
+ // then fail on them now.
+ if (prefix && prefix !== "xmlns" && !uri) {
+ strictFail(
+ parser,
+ "Unbound namespace prefix: " + JSON.stringify(prefix)
+ );
+ a.uri = prefix;
+ }
+ parser.tag.attributes[name] = a;
+ emitNode(parser, "onattribute", a);
+ }
+ parser.attribList.length = 0;
+ }
+
+ parser.tag.isSelfClosing = !!selfClosing;
+
+ // process the tag
+ parser.sawRoot = true;
+ parser.tags.push(parser.tag);
+ emitNode(parser, "onopentag", parser.tag);
+ if (!selfClosing) {
+ // special case for <script> in non-strict mode.
+ if (!parser.noscript && parser.tagName.toLowerCase() === "script") {
+ parser.state = S.SCRIPT;
+ } else {
+ parser.state = S.TEXT;
+ }
+ parser.tag = null;
+ parser.tagName = "";
+ }
+ parser.attribName = parser.attribValue = "";
+ parser.attribList.length = 0;
+ }
+
+ function closeTag(parser) {
+ if (!parser.tagName) {
+ strictFail(parser, "Weird empty close tag.");
+ parser.textNode += "</>";
+ parser.state = S.TEXT;
+ return;
+ }
+
+ if (parser.script) {
+ if (parser.tagName !== "script") {
+ parser.script += "</" + parser.tagName + ">";
+ parser.tagName = "";
+ parser.state = S.SCRIPT;
+ return;
+ }
+ emitNode(parser, "onscript", parser.script);
+ parser.script = "";
+ }
+
+ // first make sure that the closing tag actually exists.
+ // <a><b></c></b></a> will close everything, otherwise.
+ var t = parser.tags.length;
+ var tagName = parser.tagName;
+ if (!parser.strict) {
+ tagName = tagName[parser.looseCase]();
+ }
+ var closeTo = tagName;
+ while (t--) {
+ var close = parser.tags[t];
+ if (close.name !== closeTo) {
+ // fail the first time in strict mode
+ strictFail(parser, "Unexpected close tag");
+ } else {
+ break;
+ }
+ }
+
+ // didn't find it. we already failed for strict, so just abort.
+ if (t < 0) {
+ strictFail(parser, "Unmatched closing tag: " + parser.tagName);
+ parser.textNode += "</" + parser.tagName + ">";
+ parser.state = S.TEXT;
+ return;
+ }
+ parser.tagName = tagName;
+ var s = parser.tags.length;
+ while (s-- > t) {
+ var tag = (parser.tag = parser.tags.pop());
+ parser.tagName = parser.tag.name;
+ emitNode(parser, "onclosetag", parser.tagName);
+
+ var x = {};
+ for (var i in tag.ns) {
+ x[i] = tag.ns[i];
+ }
+
+ var parent = parser.tags[parser.tags.length - 1] || parser;
+ if (parser.opt.xmlns && tag.ns !== parent.ns) {
+ // remove namespace bindings introduced by tag
+ Object.keys(tag.ns).forEach(function(p) {
+ var n = tag.ns[p];
+ emitNode(parser, "onclosenamespace", { prefix: p, uri: n });
+ });
+ }
+ }
+ if (t === 0) {
+ parser.closedRoot = true;
+ }
+ parser.tagName = parser.attribValue = parser.attribName = "";
+ parser.attribList.length = 0;
+ parser.state = S.TEXT;
+ }
+
+ function parseEntity(parser) {
+ var entity = parser.entity;
+ var entityLC = entity.toLowerCase();
+ var num;
+ var numStr = "";
+
+ if (parser.ENTITIES[entity]) {
+ return parser.ENTITIES[entity];
+ }
+ if (parser.ENTITIES[entityLC]) {
+ return parser.ENTITIES[entityLC];
+ }
+ entity = entityLC;
+ if (entity.charAt(0) === "#") {
+ if (entity.charAt(1) === "x") {
+ entity = entity.slice(2);
+ num = parseInt(entity, 16);
+ numStr = num.toString(16);
+ } else {
+ entity = entity.slice(1);
+ num = parseInt(entity, 10);
+ numStr = num.toString(10);
+ }
+ }
+ entity = entity.replace(/^0+/, "");
+ if (isNaN(num) || numStr.toLowerCase() !== entity) {
+ strictFail(parser, "Invalid character entity");
+ return "&" + parser.entity + ";";
+ }
+
+ return String.fromCodePoint(num);
+ }
+
+ function beginWhiteSpace(parser, c) {
+ if (c === "<") {
+ parser.state = S.OPEN_WAKA;
+ parser.startTagPosition = parser.position;
+ } else if (!isWhitespace(c)) {
+ // have to process this as a text node.
+ // weird, but happens.
+ strictFail(parser, "Non-whitespace before first tag.");
+ parser.textNode = c;
+ parser.state = S.TEXT;
+ }
+ }
+
+ function charAt(chunk, i) {
+ var result = "";
+ if (i < chunk.length) {
+ result = chunk.charAt(i);
+ }
+ return result;
+ }
+
+ function write(chunk) {
+ var parser = this;
+ if (this.error) {
+ throw this.error;
+ }
+ if (parser.closed) {
+ return error(
+ parser,
+ "Cannot write after close. Assign an onready handler."
+ );
+ }
+ if (chunk === null) {
+ return end(parser);
+ }
+ if (typeof chunk === "object") {
+ chunk = chunk.toString();
+ }
+ var i = 0;
+ var c = "";
+ while (true) {
+ c = charAt(chunk, i++);
+ parser.c = c;
+
+ if (!c) {
+ break;
+ }
+
+ if (parser.trackPosition) {
+ parser.position++;
+ if (c === "\n") {
+ parser.line++;
+ parser.column = 0;
+ } else {
+ parser.column++;
+ }
+ }
+
+ switch (parser.state) {
+ case S.BEGIN:
+ parser.state = S.BEGIN_WHITESPACE;
+ if (c === "\uFEFF") {
+ continue;
+ }
+ beginWhiteSpace(parser, c);
+ continue;
+
+ case S.BEGIN_WHITESPACE:
+ beginWhiteSpace(parser, c);
+ continue;
+
+ case S.TEXT:
+ if (parser.sawRoot && !parser.closedRoot) {
+ var starti = i - 1;
+ while (c && c !== "<" && c !== "&") {
+ c = charAt(chunk, i++);
+ if (c && parser.trackPosition) {
+ parser.position++;
+ if (c === "\n") {
+ parser.line++;
+ parser.column = 0;
+ } else {
+ parser.column++;
+ }
+ }
+ }
+ parser.textNode += chunk.substring(starti, i - 1);
+ }
+ if (
+ c === "<" &&
+ !(parser.sawRoot && parser.closedRoot && !parser.strict)
+ ) {
+ parser.state = S.OPEN_WAKA;
+ parser.startTagPosition = parser.position;
+ } else {
+ if (!isWhitespace(c) && (!parser.sawRoot || parser.closedRoot)) {
+ strictFail(parser, "Text data outside of root node.");
+ }
+ if (c === "&") {
+ parser.state = S.TEXT_ENTITY;
+ } else {
+ parser.textNode += c;
+ }
+ }
+ continue;
+
+ case S.SCRIPT:
+ // only non-strict
+ if (c === "<") {
+ parser.state = S.SCRIPT_ENDING;
+ } else {
+ parser.script += c;
+ }
+ continue;
+
+ case S.SCRIPT_ENDING:
+ if (c === "/") {
+ parser.state = S.CLOSE_TAG;
+ } else {
+ parser.script += "<" + c;
+ parser.state = S.SCRIPT;
+ }
+ continue;
+
+ case S.OPEN_WAKA:
+ // either a /, ?, !, or text is coming next.
+ if (c === "!") {
+ parser.state = S.SGML_DECL;
+ parser.sgmlDecl = "";
+ } else if (isWhitespace(c)) {
+ // wait for it...
+ } else if (isMatch(nameStart, c)) {
+ parser.state = S.OPEN_TAG;
+ parser.tagName = c;
+ } else if (c === "/") {
+ parser.state = S.CLOSE_TAG;
+ parser.tagName = "";
+ } else if (c === "?") {
+ parser.state = S.PROC_INST;
+ parser.procInstName = parser.procInstBody = "";
+ } else {
+ strictFail(parser, "Unencoded <");
+ // if there was some whitespace, then add that in.
+ if (parser.startTagPosition + 1 < parser.position) {
+ var pad = parser.position - parser.startTagPosition;
+ c = new Array(pad).join(" ") + c;
+ }
+ parser.textNode += "<" + c;
+ parser.state = S.TEXT;
+ }
+ continue;
+
+ case S.SGML_DECL:
+ if ((parser.sgmlDecl + c).toUpperCase() === CDATA) {
+ emitNode(parser, "onopencdata");
+ parser.state = S.CDATA;
+ parser.sgmlDecl = "";
+ parser.cdata = "";
+ } else if (parser.sgmlDecl + c === "--") {
+ parser.state = S.COMMENT;
+ parser.comment = "";
+ parser.sgmlDecl = "";
+ } else if ((parser.sgmlDecl + c).toUpperCase() === DOCTYPE) {
+ parser.state = S.DOCTYPE;
+ if (parser.doctype || parser.sawRoot) {
+ strictFail(parser, "Inappropriately located doctype declaration");
+ }
+ parser.doctype = "";
+ parser.sgmlDecl = "";
+ } else if (c === ">") {
+ emitNode(parser, "onsgmldeclaration", parser.sgmlDecl);
+ parser.sgmlDecl = "";
+ parser.state = S.TEXT;
+ } else if (isQuote(c)) {
+ parser.state = S.SGML_DECL_QUOTED;
+ parser.sgmlDecl += c;
+ } else {
+ parser.sgmlDecl += c;
+ }
+ continue;
+
+ case S.SGML_DECL_QUOTED:
+ if (c === parser.q) {
+ parser.state = S.SGML_DECL;
+ parser.q = "";
+ }
+ parser.sgmlDecl += c;
+ continue;
+
+ case S.DOCTYPE:
+ if (c === ">") {
+ parser.state = S.TEXT;
+ emitNode(parser, "ondoctype", parser.doctype);
+ parser.doctype = true; // just remember that we saw it.
+ } else {
+ parser.doctype += c;
+ if (c === "[") {
+ parser.state = S.DOCTYPE_DTD;
+ } else if (isQuote(c)) {
+ parser.state = S.DOCTYPE_QUOTED;
+ parser.q = c;
+ }
+ }
+ continue;
+
+ case S.DOCTYPE_QUOTED:
+ parser.doctype += c;
+ if (c === parser.q) {
+ parser.q = "";
+ parser.state = S.DOCTYPE;
+ }
+ continue;
+
+ case S.DOCTYPE_DTD:
+ parser.doctype += c;
+ if (c === "]") {
+ parser.state = S.DOCTYPE;
+ } else if (isQuote(c)) {
+ parser.state = S.DOCTYPE_DTD_QUOTED;
+ parser.q = c;
+ }
+ continue;
+
+ case S.DOCTYPE_DTD_QUOTED:
+ parser.doctype += c;
+ if (c === parser.q) {
+ parser.state = S.DOCTYPE_DTD;
+ parser.q = "";
+ }
+ continue;
+
+ case S.COMMENT:
+ if (c === "-") {
+ parser.state = S.COMMENT_ENDING;
+ } else {
+ parser.comment += c;
+ }
+ continue;
+
+ case S.COMMENT_ENDING:
+ if (c === "-") {
+ parser.state = S.COMMENT_ENDED;
+ parser.comment = textopts(parser.opt, parser.comment);
+ if (parser.comment) {
+ emitNode(parser, "oncomment", parser.comment);
+ }
+ parser.comment = "";
+ } else {
+ parser.comment += "-" + c;
+ parser.state = S.COMMENT;
+ }
+ continue;
+
+ case S.COMMENT_ENDED:
+ if (c !== ">") {
+ strictFail(parser, "Malformed comment");
+ // allow <!-- blah -- bloo --> in non-strict mode,
+ // which is a comment of " blah -- bloo "
+ parser.comment += "--" + c;
+ parser.state = S.COMMENT;
+ } else {
+ parser.state = S.TEXT;
+ }
+ continue;
+
+ case S.CDATA:
+ if (c === "]") {
+ parser.state = S.CDATA_ENDING;
+ } else {
+ parser.cdata += c;
+ }
+ continue;
+
+ case S.CDATA_ENDING:
+ if (c === "]") {
+ parser.state = S.CDATA_ENDING_2;
+ } else {
+ parser.cdata += "]" + c;
+ parser.state = S.CDATA;
+ }
+ continue;
+
+ case S.CDATA_ENDING_2:
+ if (c === ">") {
+ if (parser.cdata) {
+ emitNode(parser, "oncdata", parser.cdata);
+ }
+ emitNode(parser, "onclosecdata");
+ parser.cdata = "";
+ parser.state = S.TEXT;
+ } else if (c === "]") {
+ parser.cdata += "]";
+ } else {
+ parser.cdata += "]]" + c;
+ parser.state = S.CDATA;
+ }
+ continue;
+
+ case S.PROC_INST:
+ if (c === "?") {
+ parser.state = S.PROC_INST_ENDING;
+ } else if (isWhitespace(c)) {
+ parser.state = S.PROC_INST_BODY;
+ } else {
+ parser.procInstName += c;
+ }
+ continue;
+
+ case S.PROC_INST_BODY:
+ if (!parser.procInstBody && isWhitespace(c)) {
+ continue;
+ } else if (c === "?") {
+ parser.state = S.PROC_INST_ENDING;
+ } else {
+ parser.procInstBody += c;
+ }
+ continue;
+
+ case S.PROC_INST_ENDING:
+ if (c === ">") {
+ emitNode(parser, "onprocessinginstruction", {
+ name: parser.procInstName,
+ body: parser.procInstBody,
+ });
+ parser.procInstName = parser.procInstBody = "";
+ parser.state = S.TEXT;
+ } else {
+ parser.procInstBody += "?" + c;
+ parser.state = S.PROC_INST_BODY;
+ }
+ continue;
+
+ case S.OPEN_TAG:
+ if (isMatch(nameBody, c)) {
+ parser.tagName += c;
+ } else {
+ newTag(parser);
+ if (c === ">") {
+ openTag(parser);
+ } else if (c === "/") {
+ parser.state = S.OPEN_TAG_SLASH;
+ } else {
+ if (!isWhitespace(c)) {
+ strictFail(parser, "Invalid character in tag name");
+ }
+ parser.state = S.ATTRIB;
+ }
+ }
+ continue;
+
+ case S.OPEN_TAG_SLASH:
+ if (c === ">") {
+ openTag(parser, true);
+ closeTag(parser);
+ } else {
+ strictFail(
+ parser,
+ "Forward-slash in opening tag not followed by >"
+ );
+ parser.state = S.ATTRIB;
+ }
+ continue;
+
+ case S.ATTRIB:
+ // haven't read the attribute name yet.
+ if (isWhitespace(c)) {
+ continue;
+ } else if (c === ">") {
+ openTag(parser);
+ } else if (c === "/") {
+ parser.state = S.OPEN_TAG_SLASH;
+ } else if (isMatch(nameStart, c)) {
+ parser.attribName = c;
+ parser.attribValue = "";
+ parser.state = S.ATTRIB_NAME;
+ } else {
+ strictFail(parser, "Invalid attribute name");
+ }
+ continue;
+
+ case S.ATTRIB_NAME:
+ if (c === "=") {
+ parser.state = S.ATTRIB_VALUE;
+ } else if (c === ">") {
+ strictFail(parser, "Attribute without value");
+ parser.attribValue = parser.attribName;
+ attrib(parser);
+ openTag(parser);
+ } else if (isWhitespace(c)) {
+ parser.state = S.ATTRIB_NAME_SAW_WHITE;
+ } else if (isMatch(nameBody, c)) {
+ parser.attribName += c;
+ } else {
+ strictFail(parser, "Invalid attribute name");
+ }
+ continue;
+
+ case S.ATTRIB_NAME_SAW_WHITE:
+ if (c === "=") {
+ parser.state = S.ATTRIB_VALUE;
+ } else if (isWhitespace(c)) {
+ continue;
+ } else {
+ strictFail(parser, "Attribute without value");
+ parser.tag.attributes[parser.attribName] = "";
+ parser.attribValue = "";
+ emitNode(parser, "onattribute", {
+ name: parser.attribName,
+ value: "",
+ });
+ parser.attribName = "";
+ if (c === ">") {
+ openTag(parser);
+ } else if (isMatch(nameStart, c)) {
+ parser.attribName = c;
+ parser.state = S.ATTRIB_NAME;
+ } else {
+ strictFail(parser, "Invalid attribute name");
+ parser.state = S.ATTRIB;
+ }
+ }
+ continue;
+
+ case S.ATTRIB_VALUE:
+ if (isWhitespace(c)) {
+ continue;
+ } else if (isQuote(c)) {
+ parser.q = c;
+ parser.state = S.ATTRIB_VALUE_QUOTED;
+ } else {
+ strictFail(parser, "Unquoted attribute value");
+ parser.state = S.ATTRIB_VALUE_UNQUOTED;
+ parser.attribValue = c;
+ }
+ continue;
+
+ case S.ATTRIB_VALUE_QUOTED:
+ if (c !== parser.q) {
+ if (c === "&") {
+ parser.state = S.ATTRIB_VALUE_ENTITY_Q;
+ } else {
+ parser.attribValue += c;
+ }
+ continue;
+ }
+ attrib(parser);
+ parser.q = "";
+ parser.state = S.ATTRIB_VALUE_CLOSED;
+ continue;
+
+ case S.ATTRIB_VALUE_CLOSED:
+ if (isWhitespace(c)) {
+ parser.state = S.ATTRIB;
+ } else if (c === ">") {
+ openTag(parser);
+ } else if (c === "/") {
+ parser.state = S.OPEN_TAG_SLASH;
+ } else if (isMatch(nameStart, c)) {
+ strictFail(parser, "No whitespace between attributes");
+ parser.attribName = c;
+ parser.attribValue = "";
+ parser.state = S.ATTRIB_NAME;
+ } else {
+ strictFail(parser, "Invalid attribute name");
+ }
+ continue;
+
+ case S.ATTRIB_VALUE_UNQUOTED:
+ if (!isAttribEnd(c)) {
+ if (c === "&") {
+ parser.state = S.ATTRIB_VALUE_ENTITY_U;
+ } else {
+ parser.attribValue += c;
+ }
+ continue;
+ }
+ attrib(parser);
+ if (c === ">") {
+ openTag(parser);
+ } else {
+ parser.state = S.ATTRIB;
+ }
+ continue;
+
+ case S.CLOSE_TAG:
+ if (!parser.tagName) {
+ if (isWhitespace(c)) {
+ continue;
+ } else if (notMatch(nameStart, c)) {
+ if (parser.script) {
+ parser.script += "</" + c;
+ parser.state = S.SCRIPT;
+ } else {
+ strictFail(parser, "Invalid tagname in closing tag.");
+ }
+ } else {
+ parser.tagName = c;
+ }
+ } else if (c === ">") {
+ closeTag(parser);
+ } else if (isMatch(nameBody, c)) {
+ parser.tagName += c;
+ } else if (parser.script) {
+ parser.script += "</" + parser.tagName;
+ parser.tagName = "";
+ parser.state = S.SCRIPT;
+ } else {
+ if (!isWhitespace(c)) {
+ strictFail(parser, "Invalid tagname in closing tag");
+ }
+ parser.state = S.CLOSE_TAG_SAW_WHITE;
+ }
+ continue;
+
+ case S.CLOSE_TAG_SAW_WHITE:
+ if (isWhitespace(c)) {
+ continue;
+ }
+ if (c === ">") {
+ closeTag(parser);
+ } else {
+ strictFail(parser, "Invalid characters in closing tag");
+ }
+ continue;
+
+ case S.TEXT_ENTITY:
+ case S.ATTRIB_VALUE_ENTITY_Q:
+ case S.ATTRIB_VALUE_ENTITY_U:
+ var returnState;
+ var buffer;
+ switch (parser.state) {
+ case S.TEXT_ENTITY:
+ returnState = S.TEXT;
+ buffer = "textNode";
+ break;
+
+ case S.ATTRIB_VALUE_ENTITY_Q:
+ returnState = S.ATTRIB_VALUE_QUOTED;
+ buffer = "attribValue";
+ break;
+
+ case S.ATTRIB_VALUE_ENTITY_U:
+ returnState = S.ATTRIB_VALUE_UNQUOTED;
+ buffer = "attribValue";
+ break;
+ }
+
+ if (c === ";") {
+ parser[buffer] += parseEntity(parser);
+ parser.entity = "";
+ parser.state = returnState;
+ } else if (
+ isMatch(parser.entity.length ? entityBody : entityStart, c)
+ ) {
+ parser.entity += c;
+ } else {
+ strictFail(parser, "Invalid character in entity name");
+ parser[buffer] += "&" + parser.entity + c;
+ parser.entity = "";
+ parser.state = returnState;
+ }
+
+ continue;
+
+ default:
+ throw new Error(parser, "Unknown state: " + parser.state);
+ }
+ } // while
+
+ if (parser.position >= parser.bufferCheckPosition) {
+ checkBufferLength(parser);
+ }
+ return parser;
+ }
+
+ /*! http://mths.be/fromcodepoint v0.1.0 by @mathias */
+ /* istanbul ignore next */
+ if (!String.fromCodePoint) {
+ (function() {
+ var stringFromCharCode = String.fromCharCode;
+ var floor = Math.floor;
+ var fromCodePoint = function() {
+ var MAX_SIZE = 0x4000;
+ var codeUnits = [];
+ var highSurrogate;
+ var lowSurrogate;
+ var index = -1;
+ var length = arguments.length;
+ if (!length) {
+ return "";
+ }
+ var result = "";
+ while (++index < length) {
+ var codePoint = Number(arguments[index]);
+ if (
+ !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
+ codePoint < 0 || // not a valid Unicode code point
+ codePoint > 0x10ffff || // not a valid Unicode code point
+ floor(codePoint) !== codePoint // not an integer
+ ) {
+ throw RangeError("Invalid code point: " + codePoint);
+ }
+ if (codePoint <= 0xffff) {
+ // BMP code point
+ codeUnits.push(codePoint);
+ } else {
+ // Astral code point; split in surrogate halves
+ // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+ codePoint -= 0x10000;
+ highSurrogate = (codePoint >> 10) + 0xd800;
+ lowSurrogate = (codePoint % 0x400) + 0xdc00;
+ codeUnits.push(highSurrogate, lowSurrogate);
+ }
+ if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+ result += stringFromCharCode.apply(null, codeUnits);
+ codeUnits.length = 0;
+ }
+ }
+ return result;
+ };
+ /* istanbul ignore next */
+ if (Object.defineProperty) {
+ Object.defineProperty(String, "fromCodePoint", {
+ value: fromCodePoint,
+ configurable: true,
+ writable: true,
+ });
+ } else {
+ String.fromCodePoint = fromCodePoint;
+ }
+ })();
+ }
+})(typeof exports === "undefined" ? (this.sax = {}) : exports);
diff --git a/comm/chat/protocols/xmpp/moz.build b/comm/chat/protocols/xmpp/moz.build
new file mode 100644
index 0000000000..56140aeef6
--- /dev/null
+++ b/comm/chat/protocols/xmpp/moz.build
@@ -0,0 +1,26 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+DIRS += [
+ "lib",
+]
+
+EXTRA_JS_MODULES += [
+ "sax.sys.mjs",
+ "xmpp-authmechs.sys.mjs",
+ "xmpp-base.sys.mjs",
+ "xmpp-commands.sys.mjs",
+ "xmpp-session.sys.mjs",
+ "xmpp-xml.sys.mjs",
+ "xmpp.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/xmpp/sax.sys.mjs b/comm/chat/protocols/xmpp/sax.sys.mjs
new file mode 100644
index 0000000000..3098c6df79
--- /dev/null
+++ b/comm/chat/protocols/xmpp/sax.sys.mjs
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let scope = {};
+Services.scriptloader.loadSubScript("resource:///modules/sax/sax.js", scope);
+export var SAX = scope.sax;
diff --git a/comm/chat/protocols/xmpp/test/test_authmechs.js b/comm/chat/protocols/xmpp/test/test_authmechs.js
new file mode 100644
index 0000000000..f935026dbc
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_authmechs.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { XMPPAuthMechanisms } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-authmechs.sys.mjs"
+);
+var { Stanza } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-xml.sys.mjs"
+);
+
+/*
+ * Test PLAIN using the examples given in section 6 of RFC 6120.
+ */
+add_task(async function testPlain() {
+ const username = "juliet";
+ const password = "r0m30myr0m30";
+
+ let mech = XMPPAuthMechanisms.PLAIN(username, password, undefined);
+
+ // Send the initiation message.
+ let result = mech.next();
+ ok(!result.done);
+ let value = await Promise.resolve(result.value);
+
+ // Check the algorithm.
+ equal(value.send.attributes.mechanism, "PLAIN");
+ // Check the PLAIN content.
+ equal(value.send.children[0].text, "AGp1bGlldAByMG0zMG15cjBtMzA=");
+
+ // Receive the success.
+ let response = Stanza.node("success", Stanza.NS.sasl);
+ result = mech.next(response);
+ ok(result.done);
+ // There is no final value.
+ equal(result.value, undefined);
+});
+
+/*
+ * Test SCRAM-SHA-1 using the examples given in section 5 of RFC 5802.
+ *
+ * Full test vectors of intermediate values are available at:
+ * https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1
+ */
+add_task(async function testScramSha1() {
+ const username = "user";
+ const password = "pencil";
+
+ // Use a constant value for the nonce.
+ const nonce = "fyko+d2lbbFgONRv9qkxdawL";
+
+ let mech = XMPPAuthMechanisms["SCRAM-SHA-1"](
+ username,
+ password,
+ undefined,
+ nonce
+ );
+
+ // Send the client-first-message.
+ let result = mech.next();
+ ok(!result.done);
+ let value = await Promise.resolve(result.value);
+
+ // Check the algorithm.
+ equal(value.send.attributes.mechanism, "SCRAM-SHA-1");
+ // Check the SCRAM content.
+ equal(
+ atob(value.send.children[0].text),
+ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL"
+ );
+
+ // Receive the server-first-message and send the client-final-message.
+ let response = Stanza.node(
+ "challenge",
+ Stanza.NS.sasl,
+ null,
+ btoa(
+ "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
+ )
+ );
+ result = mech.next(response);
+ ok(!result.done);
+ value = await Promise.resolve(result.value);
+
+ // Check the SCRAM content.
+ equal(
+ atob(value.send.children[0].text),
+ "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts="
+ );
+
+ // Receive the server-final-message.
+ response = Stanza.node(
+ "success",
+ Stanza.NS.sasl,
+ null,
+ btoa("v=rmF9pqV8S7suAoZWja4dJRkFsKQ=")
+ );
+ result = mech.next(response);
+ ok(result.done);
+ // There is no final value.
+ equal(result.value, undefined);
+});
+
+/*
+ * Test SCRAM-SHA-256 using the examples given in section 3 of RFC 7677.
+ */
+add_task(async function testScramSha256() {
+ const username = "user";
+ const password = "pencil";
+
+ // Use a constant value for the nonce.
+ const nonce = "rOprNGfwEbeRWgbNEkqO";
+
+ let mech = XMPPAuthMechanisms["SCRAM-SHA-256"](
+ username,
+ password,
+ undefined,
+ nonce
+ );
+
+ // Send the client-first-message.
+ let result = mech.next();
+ ok(!result.done);
+ let value = await Promise.resolve(result.value);
+
+ // Check the algorithm.
+ equal(value.send.attributes.mechanism, "SCRAM-SHA-256");
+ // Check the SCRAM content.
+ equal(atob(value.send.children[0].text), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
+
+ // Receive the server-first-message and send the client-final-message.
+ let response = Stanza.node(
+ "challenge",
+ Stanza.NS.sasl,
+ null,
+ btoa(
+ "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"
+ )
+ );
+ result = mech.next(response);
+ ok(!result.done);
+ value = await Promise.resolve(result.value);
+
+ // Check the SCRAM content.
+ equal(
+ atob(value.send.children[0].text),
+ "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
+ );
+
+ // Receive the server-final-message.
+ response = Stanza.node(
+ "success",
+ Stanza.NS.sasl,
+ null,
+ btoa("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=")
+ );
+ result = mech.next(response);
+ ok(result.done);
+ // There is no final value.
+ equal(result.value, undefined);
+});
diff --git a/comm/chat/protocols/xmpp/test/test_dnsSrv.js b/comm/chat/protocols/xmpp/test/test_dnsSrv.js
new file mode 100644
index 0000000000..37f1b6b052
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_dnsSrv.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { XMPPAccountPrototype } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-base.sys.mjs"
+);
+var { XMPPSession } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-session.sys.mjs"
+);
+var { SRVRecord } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+function FakeXMPPSession() {}
+FakeXMPPSession.prototype = {
+ __proto__: XMPPSession.prototype,
+ _account: { __proto__: XMPPAccountPrototype },
+ _host: null,
+ _port: 0,
+ connect(
+ aHostOrigin,
+ aPortOrigin,
+ aSecurity,
+ aProxy,
+ aHost = aHostOrigin,
+ aPort = aPortOrigin
+ ) {},
+ _connectNextRecord() {
+ this.isConnectNextRecord = true;
+ },
+
+ // Used to indicate that method _connectNextRecord is called or not.
+ isConnectNextRecord: false,
+
+ LOG(aMsg) {},
+ WARN(aMsg) {},
+};
+
+var TEST_DATA = [
+ {
+ // Test sorting based on priority and weight.
+ input: [
+ new SRVRecord(20, 0, "xmpp.instantbird.com", 5222),
+ new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222),
+ new SRVRecord(10, 0, "xmpp2.instantbird.com", 5222),
+ new SRVRecord(0, 0, "xmpp3.instantbird.com", 5222),
+ new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222),
+ ],
+ output: [
+ new SRVRecord(0, 0, "xmpp3.instantbird.com", 5222),
+ new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222),
+ new SRVRecord(10, 0, "xmpp2.instantbird.com", 5222),
+ new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222),
+ new SRVRecord(20, 0, "xmpp.instantbird.com", 5222),
+ ],
+ isConnectNextRecord: true,
+ },
+ {
+ input: [
+ new SRVRecord(5, 30, "xmpp5.instantbird.com", 5222),
+ new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222),
+ new SRVRecord(10, 60, "xmpp2.instantbird.com", 5222),
+ new SRVRecord(5, 10, "xmpp3.instantbird.com", 5222),
+ new SRVRecord(20, 10, "xmpp.instantbird.com", 5222),
+ new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222),
+ ],
+ output: [
+ new SRVRecord(5, 30, "xmpp5.instantbird.com", 5222),
+ new SRVRecord(5, 10, "xmpp3.instantbird.com", 5222),
+ new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222),
+ new SRVRecord(10, 60, "xmpp2.instantbird.com", 5222),
+ new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222),
+ new SRVRecord(20, 10, "xmpp.instantbird.com", 5222),
+ ],
+ isConnectNextRecord: true,
+ },
+
+ // Tests no SRV records are found.
+ {
+ input: [],
+ output: [],
+ isConnectNextRecord: false,
+ },
+
+ // Tests XMPP is not supported if the result is one record with target ".".
+ {
+ input: [new SRVRecord(5, 30, ".", 5222)],
+ output: XMPPSession.prototype.SRV_ERROR_XMPP_NOT_SUPPORTED,
+ isConnectNextRecord: false,
+ },
+ {
+ input: [new SRVRecord(5, 30, "xmpp.instantbird.com", 5222)],
+ output: [new SRVRecord(5, 30, "xmpp.instantbird.com", 5222)],
+ isConnectNextRecord: true,
+ },
+];
+
+function run_test() {
+ for (let currentQuery of TEST_DATA) {
+ let session = new FakeXMPPSession();
+ try {
+ session._handleSrvQuery(currentQuery.input);
+ equal(session._srvRecords.length, currentQuery.output.length);
+ for (let index = 0; index < session._srvRecords.length; index++) {
+ deepEqual(session._srvRecords[index], currentQuery.output[index]);
+ }
+ } catch (e) {
+ equal(e, currentQuery.output);
+ }
+ equal(session.isConnectNextRecord, currentQuery.isConnectNextRecord);
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js b/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js
new file mode 100644
index 0000000000..f041d2356b
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { XMPPAccountPrototype } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-base.sys.mjs"
+);
+
+var TEST_DATA = {
+ "abdelrhman@instantbird": {
+ node: "abdelrhman",
+ domain: "instantbird",
+ jid: "abdelrhman@instantbird",
+ normalized: "abdelrhman@instantbird",
+ },
+ " room@instantbird/abdelrhman ": {
+ node: "room",
+ domain: "instantbird",
+ resource: "abdelrhman",
+ jid: "room@instantbird/abdelrhman",
+ normalized: "room@instantbird",
+ },
+ "room@instantbird/@bdelrhman": {
+ node: "room",
+ domain: "instantbird",
+ resource: "@bdelrhman",
+ jid: "room@instantbird/@bdelrhman",
+ normalized: "room@instantbird",
+ },
+ "room@instantbird/abdelrhm\u0061\u0308n": {
+ node: "room",
+ domain: "instantbird",
+ resource: "abdelrhm\u0061\u0308n",
+ jid: "room@instantbird/abdelrhm\u0061\u0308n",
+ normalized: "room@instantbird",
+ },
+ "Room@Instantbird/Abdelrhman": {
+ node: "room",
+ domain: "instantbird",
+ resource: "Abdelrhman",
+ jid: "room@instantbird/Abdelrhman",
+ normalized: "room@instantbird",
+ },
+ "Abdelrhman@instantbird/Instant bird": {
+ node: "abdelrhman",
+ domain: "instantbird",
+ resource: "Instant bird",
+ jid: "abdelrhman@instantbird/Instant bird",
+ normalized: "abdelrhman@instantbird",
+ },
+ "abdelrhman@host/instant/Bird": {
+ node: "abdelrhman",
+ domain: "host",
+ resource: "instant/Bird",
+ jid: "abdelrhman@host/instant/Bird",
+ normalized: "abdelrhman@host",
+ },
+ instantbird: {
+ domain: "instantbird",
+ jid: "instantbird",
+ normalized: "instantbird",
+ },
+};
+
+function testParseJID() {
+ for (let currentJID in TEST_DATA) {
+ let jid = XMPPAccountPrototype._parseJID(currentJID);
+ equal(jid.node, TEST_DATA[currentJID].node);
+ equal(jid.domain, TEST_DATA[currentJID].domain);
+ equal(jid.resource, TEST_DATA[currentJID].resource);
+ equal(jid.jid, TEST_DATA[currentJID].jid);
+ }
+
+ run_next_test();
+}
+
+function testNormalize() {
+ for (let currentJID in TEST_DATA) {
+ equal(
+ XMPPAccountPrototype.normalize(currentJID),
+ TEST_DATA[currentJID].normalized
+ );
+ }
+
+ run_next_test();
+}
+
+function testNormalizeFullJid() {
+ for (let currentJID in TEST_DATA) {
+ equal(
+ XMPPAccountPrototype.normalizeFullJid(currentJID),
+ TEST_DATA[currentJID].jid
+ );
+ }
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(testParseJID);
+ add_test(testNormalize);
+ add_test(testNormalizeFullJid);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/test_parseVCard.js b/comm/chat/protocols/xmpp/test/test_parseVCard.js
new file mode 100644
index 0000000000..08155218de
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_parseVCard.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { XMPPAccountPrototype } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-base.sys.mjs"
+);
+var { XMPPParser } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-xml.sys.mjs"
+);
+
+/*
+ * Open an input stream, instantiate an XMPP parser, and feed the input string
+ * into it. Then assert that the resulting vCard matches the expected result.
+ */
+function _test_vcard(aInput, aExpectedResult) {
+ let listener = {
+ onXMLError(aError, aException) {
+ // Ensure that no errors happen.
+ ok(false, aError + " - " + aException);
+ },
+ LOG(aString) {},
+ onXmppStanza(aStanza) {
+ // This is a simplified stanza parser that assumes inputs are vCards.
+ let vCard = aStanza.getElement(["vCard"]);
+ deepEqual(XMPPAccountPrototype.parseVCard(vCard), aExpectedResult);
+ },
+ };
+ let parser = new XMPPParser(listener);
+ parser.onDataAvailable(aInput);
+ parser.destroy();
+}
+
+/*
+ * Test parsing of the example vCard from XEP-0054 section 3.1, example 2.
+ */
+function test_standard_vcard() {
+ const standard_vcard =
+ "<iq xmlns='jabber:client'\
+ id='v1'\
+ to='stpeter@jabber.org/roundabout'\
+ type='result'>\
+ <vCard xmlns='vcard-temp'>\
+ <FN>Peter Saint-Andre</FN>\
+ <N>\
+ <FAMILY>Saint-Andre</FAMILY>\
+ <GIVEN>Peter</GIVEN>\
+ <MIDDLE/>\
+ </N>\
+ <NICKNAME>stpeter</NICKNAME>\
+ <URL>http://www.xmpp.org/xsf/people/stpeter.shtml</URL>\
+ <BDAY>1966-08-06</BDAY>\
+ <ORG>\
+ <ORGNAME>XMPP Standards Foundation</ORGNAME>\
+ <ORGUNIT/>\
+ </ORG>\
+ <TITLE>Executive Director</TITLE>\
+ <ROLE>Patron Saint</ROLE>\
+ <TEL><WORK/><VOICE/><NUMBER>303-308-3282</NUMBER></TEL>\
+ <TEL><WORK/><FAX/><NUMBER/></TEL>\
+ <TEL><WORK/><MSG/><NUMBER/></TEL>\
+ <ADR>\
+ <WORK/>\
+ <EXTADD>Suite 600</EXTADD>\
+ <STREET>1899 Wynkoop Street</STREET>\
+ <LOCALITY>Denver</LOCALITY>\
+ <REGION>CO</REGION>\
+ <PCODE>80202</PCODE>\
+ <CTRY>USA</CTRY>\
+ </ADR>\
+ <TEL><HOME/><VOICE/><NUMBER>303-555-1212</NUMBER></TEL>\
+ <TEL><HOME/><FAX/><NUMBER/></TEL>\
+ <TEL><HOME/><MSG/><NUMBER/></TEL>\
+ <ADR>\
+ <HOME/>\
+ <EXTADD/>\
+ <STREET/>\
+ <LOCALITY>Denver</LOCALITY>\
+ <REGION>CO</REGION>\
+ <PCODE>80209</PCODE>\
+ <CTRY>USA</CTRY>\
+ </ADR>\
+ <EMAIL><INTERNET/><PREF/><USERID>stpeter@jabber.org</USERID></EMAIL>\
+ <JABBERID>stpeter@jabber.org</JABBERID>\
+ <DESC>\
+ More information about me is located on my\
+ personal website: http://www.saint-andre.com/\
+ </DESC>\
+ </vCard>\
+</iq>";
+
+ const expectedResult = {
+ fullName: "Peter Saint-Andre",
+ // Name is not parsed.
+ nickname: "stpeter",
+ // URL is not parsed.
+ birthday: "1966-08-06",
+ organization: "XMPP Standards Foundation",
+ title: "Executive Director",
+ // Role is not parsed.
+ // This only pulls the *last* telephone number.
+ telephone: "303-555-1212",
+ // Part of the address is parsed.
+ locality: "Denver",
+ country: "USA",
+ email: "stpeter@jabber.org",
+ userName: "stpeter@jabber.org", // Jabber ID.
+ // Description is not parsed.
+ };
+
+ _test_vcard(standard_vcard, expectedResult);
+
+ run_next_test();
+}
+
+/*
+ * Test parsing of the example empty vCard from XEP-0054 section 3.1, example
+ * 4. This can be used instead of returning an error stanza.
+ */
+function test_empty_vcard() {
+ const empty_vcard =
+ "<iq xmlns='jabber:client'\
+ id='v1'\
+ to='stpeter@jabber.org/roundabout'\
+ type='result'>\
+ <vCard xmlns='vcard-temp'/>\
+</iq>";
+
+ // There should be no properties.
+ _test_vcard(empty_vcard, {});
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(test_standard_vcard);
+ add_test(test_empty_vcard);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/test_saslPrep.js b/comm/chat/protocols/xmpp/test/test_saslPrep.js
new file mode 100644
index 0000000000..b2d0a1f147
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_saslPrep.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { saslPrep } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-authmechs.sys.mjs"
+);
+
+// RFC 4013 3.Examples
+var TEST_DATA = [
+ {
+ // SOFT HYPHEN mapped to nothing.
+ input: "I\u00adX",
+ output: "IX",
+ isError: false,
+ },
+ {
+ // No transformation.
+ input: "user",
+ output: "user",
+ isError: false,
+ },
+ {
+ // Case preserved, will not match #2.
+ input: "USER",
+ output: "USER",
+ isError: false,
+ },
+ {
+ // Output is NFKC, input in ISO 8859-1.
+ input: "\u00aa",
+ output: "a",
+ isError: false,
+ },
+ {
+ // Output is NFKC, will match #1.
+ input: "\u2168",
+ output: "IX",
+ isError: false,
+ },
+ {
+ // Error - prohibited character.
+ input: "\u0007",
+ output: "",
+ isError: true,
+ },
+ {
+ // Error - bidirectional check.
+ input: "\u0627\u0031",
+ output: "",
+ isError: true,
+ },
+];
+
+function run_test() {
+ for (let current of TEST_DATA) {
+ try {
+ let result = saslPrep(current.input);
+ equal(current.isError, false);
+ equal(result, current.output);
+ } catch (e) {
+ equal(current.isError, true);
+ }
+ }
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/test_xmppParser.js b/comm/chat/protocols/xmpp/test/test_xmppParser.js
new file mode 100644
index 0000000000..c18304a544
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_xmppParser.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { XMPPParser } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-xml.sys.mjs"
+);
+
+let expectedResult =
+ '<presence xmlns="jabber:client" from="chat@example.com/Étienne" to="user@example.com/Thunderbird" \
+xml:lang="en" id="5ed0ae8b7051fa6169037da4e2a1ded6"><c xmlns="http://jabber.org/protocol/caps" \
+ver="ZyB1liM9c9GvKOnvl61+5ScWcqw=" node="https://example.com" hash="sha-1"/><x \
+xmlns="vcard-temp:x:update"><photo xmlns="vcard-temp:x:update"/></x><idle xmlns="urn:xmpp:idle:1" \
+since="2021-04-13T11:52:16.538713+00:00"/><occupant-id xmlns="urn:xmpp:occupant-id:0" \
+id="wNZPCZIVQ51D/heZQpOHi0ZgHXAEQonNPaLdyzLxHWs="/><x xmlns="http://jabber.org/protocol/muc#user"><item \
+xmlns="http://jabber.org/protocol/muc#user" jid="example@example.com/client" affiliation="member" \
+role="participant"/></x></presence>';
+let byteVersion = new TextEncoder().encode(expectedResult);
+let utf8Input = Array.from(byteVersion, byte => String.fromCharCode(byte)).join(
+ ""
+);
+
+var TEST_DATA = [
+ {
+ input:
+ '<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \
+to="romeo@montague.example/garden" type="chat">\
+<body>What man art thou that, thus bescreen"d in night, so stumblest on my \
+counsel?</body>\
+</message>',
+ output:
+ '<message xmlns="jabber:client" \
+from="juliet@capulet.example/balcony" to="romeo@montague.example/garden" \
+type="chat"><body xmlns="jabber:client">What man art thou that, thus \
+bescreen"d in night, so stumblest on my counsel?</body>\
+</message>',
+ isError: false,
+ description: "Message stanza with body element",
+ },
+ {
+ input:
+ '<message xmlns="jabber:client" from="romeo@montague.example" \
+to="romeo@montague.example/home" type="chat">\
+<received xmlns="urn:xmpp:carbons:2">\
+<forwarded xmlns="urn:xmpp:forward:0">\
+<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \
+to="romeo@montague.example/garden" type="chat">\
+<body>What man art thou that, thus bescreen"d in night, so stumblest on my \
+counsel?</body>\
+<thread>0e3141cd80894871a68e6fe6b1ec56fa</thread>\
+</message>\
+</forwarded>\
+</received>\
+</message>',
+ output:
+ '<message xmlns="jabber:client" from="romeo@montague.example" \
+to="romeo@montague.example/home" type="chat">\
+<received xmlns="urn:xmpp:carbons:2"><forwarded xmlns="urn:xmpp:forward:0">\
+<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \
+to="romeo@montague.example/garden" type="chat">\
+<body xmlns="jabber:client">What man art thou that, thus bescreen"d in night, \
+so stumblest on my counsel?</body>\
+<thread xmlns="jabber:client">0e3141cd80894871a68e6fe6b1ec56fa</thread>\
+</message>\
+</forwarded>\
+</received>\
+</message>',
+ isError: false,
+ description: "Forwarded copy of message carbons",
+ },
+ {
+ input:
+ '<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \
+to="romeo@montague.example/garden" type="chat">\
+<body>What man art thou that, thus bescreen"d in night, so stumblest on my \
+counsel?\
+</message>',
+ output: "",
+ isError: true,
+ description: "No closing of body tag",
+ },
+ {
+ input:
+ '<message xmlns="http://etherx.jabber.org/streams" from="juliet@capulet.example/balcony" \
+to="romeo@montague.example/garden" type="chat">\
+<body>What man art thou that, thus bescreen"d in night, so stumblest on my \
+counsel?</body>\
+</message>',
+ output: "",
+ isError: true,
+ description: "Invalid namespace of top-level element",
+ },
+ {
+ input:
+ '<field xmlns="jabber:x:data" type="fixed">\
+<value>What man art thou that, thus bescreen"d in night, so stumblest on my \
+counsel?</value>\
+</field>',
+ output: "",
+ isError: true,
+ description: "Invalid top-level element",
+ },
+ {
+ input: utf8Input,
+ output: expectedResult,
+ isError: false,
+ description: "UTF-8 encoded content from socket",
+ },
+];
+
+function testXMPPParser() {
+ for (let current of TEST_DATA) {
+ let listener = {
+ onXMLError(aString) {
+ ok(current.isError, aString + " - " + current.description);
+ },
+ LOG(aString) {},
+ startLegacyAuth() {},
+ onXmppStanza(aStanza) {
+ equal(current.output, aStanza.getXML(), current.description);
+ ok(!current.isError, current.description);
+ },
+ };
+ let parser = new XMPPParser(listener);
+ parser.onDataAvailable(current.input);
+ parser.destroy();
+ }
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(testXMPPParser);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/test_xmppXml.js b/comm/chat/protocols/xmpp/test/test_xmppXml.js
new file mode 100644
index 0000000000..1b6ea9a175
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/test_xmppXml.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { Stanza } = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-xml.sys.mjs"
+);
+
+var TEST_DATA = [
+ {
+ input: {
+ name: "message",
+ namespace: Stanza.NS.client,
+ attributes: {
+ jid: "user@domain",
+ type: null,
+ },
+ data: [],
+ },
+ XmlOutput: '<message xmlns="jabber:client" jid="user@domain"/>',
+ stringOutput: '<message xmlns="jabber:client" jid="user@domain"/>\n',
+ isError: false,
+ description: "Ignore attribute with null value",
+ },
+ {
+ input: {
+ name: "message",
+ namespace: Stanza.NS.client,
+ attributes: {
+ jid: "user@domain",
+ type: undefined,
+ },
+ data: [],
+ },
+ XmlOutput: '<message xmlns="jabber:client" jid="user@domain"/>',
+ stringOutput: '<message xmlns="jabber:client" jid="user@domain"/>\n',
+ isError: false,
+ description: "Ignore attribute with undefined value",
+ },
+ {
+ input: {
+ name: "message",
+ namespace: undefined,
+ attributes: {},
+ data: [],
+ },
+ XmlOutput: "<message/>",
+ stringOutput: "<message/>\n",
+ isError: false,
+ description: "Ignore namespace with undefined value",
+ },
+ {
+ input: {
+ name: undefined,
+ attributes: {},
+ data: [],
+ },
+ XmlOutput: "",
+ stringOutput: "",
+ isError: true,
+ description: "Node must have a name",
+ },
+ {
+ input: {
+ name: "message",
+ attributes: {},
+ data: "test message",
+ },
+ XmlOutput: "<message>test message</message>",
+ stringOutput: "<message>\n test message\n</message>\n",
+ isError: false,
+ description: "Node with text content",
+ },
+];
+
+function testXMLNode() {
+ for (let current of TEST_DATA) {
+ try {
+ let result = Stanza.node(
+ current.input.name,
+ current.input.namespace,
+ current.input.attributes,
+ current.input.data
+ );
+ equal(result.getXML(), current.XmlOutput, current.description);
+ equal(
+ result.convertToString(),
+ current.stringOutput,
+ current.description
+ );
+ equal(current.isError, false);
+ } catch (e) {
+ equal(current.isError, true, current.description);
+ }
+ }
+
+ run_next_test();
+}
+
+function run_test() {
+ add_test(testXMLNode);
+
+ run_next_test();
+}
diff --git a/comm/chat/protocols/xmpp/test/xpcshell.ini b/comm/chat/protocols/xmpp/test/xpcshell.ini
new file mode 100644
index 0000000000..a4cb9534b8
--- /dev/null
+++ b/comm/chat/protocols/xmpp/test/xpcshell.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+head =
+tail =
+
+[test_authmechs.js]
+[test_dnsSrv.js]
+[test_parseJidAndNormalization.js]
+[test_saslPrep.js]
+[test_parseVCard.js]
+[test_xmppParser.js]
+[test_xmppXml.js]
diff --git a/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs b/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs
new file mode 100644
index 0000000000..7517d6f6aa
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs
@@ -0,0 +1,561 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This module exports XMPPAuthMechanisms, an object containing the supported
+// SASL authentication mechanisms. Each authentication mechanism is a generator
+// function which takes the following parameters:
+//
+// * The provided username (JID node),
+// * The password
+// * The user's domain (again from the JID).
+//
+// The generator should yield objects (or Promises which resolve to objects)
+// with two properties:
+//
+// * send: The next XML stanza to send.
+// * log: The plaintext content to log (instead of the stanza, which likely
+// contains sensitive information).
+//
+// Alternately the object can have an error property which causes the account
+// to disconnect with an ERROR_AUTHENTICATION_FAILED error.
+//
+// The response stanza from the server is sent to the generator each time it
+// yields. Once the authentication negotiation is complete the generator should
+// return.
+//
+// By default the PLAIN, SCRAM-SHA-1, and SCRAM-SHA-256 mechanisms are supported.
+//
+// As this is only used by XMPPSession, it may seem like an internal detail of
+// the XMPP implementation, but exporting it is valuable for testing purposes.
+
+import { CommonUtils } from "resource://services-common/utils.sys.mjs";
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+import { Stanza } from "resource:///modules/xmpp-xml.sys.mjs";
+
+// Handle PLAIN authorization mechanism.
+function* PlainAuth(aUsername, aPassword, aDomain) {
+ let data = "\0" + aUsername + "\0" + aPassword;
+
+ // btoa for Unicode, see https://developer.mozilla.org/en-US/docs/DOM/window.btoa
+ let base64Data = btoa(unescape(encodeURIComponent(data)));
+
+ let stanza = yield {
+ send: Stanza.node(
+ "auth",
+ Stanza.NS.sasl,
+ { mechanism: "PLAIN" },
+ base64Data
+ ),
+ log: '<auth mechanism:="PLAIN"/> (base64 encoded username and password not logged)',
+ };
+
+ if (stanza.localName != "success") {
+ throw new Error("Didn't receive the expected auth success stanza.");
+ }
+}
+
+// Handle SCRAM-SHA-1 authorization mechanism.
+const RFC3454 = {
+ A1: "\u0221|[\u0234-\u024f]|[\u02ae-\u02af]|[\u02ef-\u02ff]|\
+[\u0350-\u035f]|[\u0370-\u0373]|[\u0376-\u0379]|[\u037b-\u037d]|\
+[\u037f-\u0383]|\u038b|\u038d|\u03a2|\u03cf|[\u03f7-\u03ff]|\u0487|\
+\u04cf|[\u04f6-\u04f7]|[\u04fa-\u04ff]|[\u0510-\u0530]|\
+[\u0557-\u0558]|\u0560|\u0588|[\u058b-\u0590]|\u05a2|\u05ba|\
+[\u05c5-\u05cf]|[\u05eb-\u05ef]|[\u05f5-\u060b]|[\u060d-\u061a]|\
+[\u061c-\u061e]|\u0620|[\u063b-\u063f]|[\u0656-\u065f]|\
+[\u06ee-\u06ef]|\u06ff|\u070e|[\u072d-\u072f]|[\u074b-\u077f]|\
+[\u07b2-\u0900]|\u0904|[\u093a-\u093b]|[\u094e-\u094f]|\
+[\u0955-\u0957]|[\u0971-\u0980]|\u0984|[\u098d-\u098e]|\
+[\u0991-\u0992]|\u09a9|\u09b1|[\u09b3-\u09b5]|[\u09ba-\u09bb]|\u09bd|\
+[\u09c5-\u09c6]|[\u09c9-\u09ca]|[\u09ce-\u09d6]|[\u09d8-\u09db]|\
+\u09de|[\u09e4-\u09e5]|[\u09fb-\u0a01]|[\u0a03-\u0a04]|\
+[\u0a0b-\u0a0e]|[\u0a11-\u0a12]|\u0a29|\u0a31|\u0a34|\u0a37|\
+[\u0a3a-\u0a3b]|\u0a3d|[\u0a43-\u0a46]|[\u0a49-\u0a4a]|\
+[\u0a4e-\u0a58]|\u0a5d|[\u0a5f-\u0a65]|[\u0a75-\u0a80]|\u0a84|\u0a8c|\
+\u0a8e|\u0a92|\u0aa9|\u0ab1|\u0ab4|[\u0aba-\u0abb]|\u0ac6|\u0aca|\
+[\u0ace-\u0acf]|[\u0ad1-\u0adf]|[\u0ae1-\u0ae5]|[\u0af0-\u0b00]|\
+\u0b04|[\u0b0d-\u0b0e]|[\u0b11-\u0b12]|\u0b29|\u0b31|[\u0b34-\u0b35]|\
+[\u0b3a-\u0b3b]|[\u0b44-\u0b46]|[\u0b49-\u0b4a]|[\u0b4e-\u0b55]|\
+[\u0b58-\u0b5b]|\u0b5e|[\u0b62-\u0b65]|[\u0b71-\u0b81]|\u0b84|\
+[\u0b8b-\u0b8d]|\u0b91|[\u0b96-\u0b98]|\u0b9b|\u0b9d|[\u0ba0-\u0ba2]|\
+[\u0ba5-\u0ba7]|[\u0bab-\u0bad]|\u0bb6|[\u0bba-\u0bbd]|\
+[\u0bc3-\u0bc5]|\u0bc9|[\u0bce-\u0bd6]|[\u0bd8-\u0be6]|\
+[\u0bf3-\u0c00]|\u0c04|\u0c0d|\u0c11|\u0c29|\u0c34|[\u0c3a-\u0c3d]|\
+\u0c45|\u0c49|[\u0c4e-\u0c54]|[\u0c57-\u0c5f]|[\u0c62-\u0c65]|\
+[\u0c70-\u0c81]|\u0c84|\u0c8d|\u0c91|\u0ca9|\u0cb4|[\u0cba-\u0cbd]|\
+\u0cc5|\u0cc9|[\u0cce-\u0cd4]|[\u0cd7-\u0cdd]|\u0cdf|[\u0ce2-\u0ce5]|\
+[\u0cf0-\u0d01]|\u0d04|\u0d0d|\u0d11|\u0d29|[\u0d3a-\u0d3d]|\
+[\u0d44-\u0d45]|\u0d49|[\u0d4e-\u0d56]|[\u0d58-\u0d5f]|\
+[\u0d62-\u0d65]|[\u0d70-\u0d81]|\u0d84|[\u0d97-\u0d99]|\u0db2|\u0dbc|\
+[\u0dbe-\u0dbf]|[\u0dc7-\u0dc9]|[\u0dcb-\u0dce]|\u0dd5|\u0dd7|\
+[\u0de0-\u0df1]|[\u0df5-\u0e00]|[\u0e3b-\u0e3e]|[\u0e5c-\u0e80]|\
+\u0e83|[\u0e85-\u0e86]|\u0e89|[\u0e8b-\u0e8c]|[\u0e8e-\u0e93]|\u0e98|\
+\u0ea0|\u0ea4|\u0ea6|[\u0ea8-\u0ea9]|\u0eac|\u0eba|[\u0ebe-\u0ebf]|\
+\u0ec5|\u0ec7|[\u0ece-\u0ecf]|[\u0eda-\u0edb]|[\u0ede-\u0eff]|\u0f48|\
+[\u0f6b-\u0f70]|[\u0f8c-\u0f8f]|\u0f98|\u0fbd|[\u0fcd-\u0fce]|\
+[\u0fd0-\u0fff]|\u1022|\u1028|\u102b|[\u1033-\u1035]|[\u103a-\u103f]|\
+[\u105a-\u109f]|[\u10c6-\u10cf]|[\u10f9-\u10fa]|[\u10fc-\u10ff]|\
+[\u115a-\u115e]|[\u11a3-\u11a7]|[\u11fa-\u11ff]|\u1207|\u1247|\u1249|\
+[\u124e-\u124f]|\u1257|\u1259|[\u125e-\u125f]|\u1287|\u1289|\
+[\u128e-\u128f]|\u12af|\u12b1|[\u12b6-\u12b7]|\u12bf|\u12c1|\
+[\u12c6-\u12c7]|\u12cf|\u12d7|\u12ef|\u130f|\u1311|[\u1316-\u1317]|\
+\u131f|\u1347|[\u135b-\u1360]|[\u137d-\u139f]|[\u13f5-\u1400]|\
+[\u1677-\u167f]|[\u169d-\u169f]|[\u16f1-\u16ff]|\u170d|\
+[\u1715-\u171f]|[\u1737-\u173f]|[\u1754-\u175f]|\u176d|\u1771|\
+[\u1774-\u177f]|[\u17dd-\u17df]|[\u17ea-\u17ff]|\u180f|\
+[\u181a-\u181f]|[\u1878-\u187f]|[\u18aa-\u1dff]|[\u1e9c-\u1e9f]|\
+[\u1efa-\u1eff]|[\u1f16-\u1f17]|[\u1f1e-\u1f1f]|[\u1f46-\u1f47]|\
+[\u1f4e-\u1f4f]|\u1f58|\u1f5a|\u1f5c|\u1f5e|[\u1f7e-\u1f7f]|\u1fb5|\
+\u1fc5|[\u1fd4-\u1fd5]|\u1fdc|[\u1ff0-\u1ff1]|\u1ff5|\u1fff|\
+[\u2053-\u2056]|[\u2058-\u205e]|[\u2064-\u2069]|[\u2072-\u2073]|\
+[\u208f-\u209f]|[\u20b2-\u20cf]|[\u20eb-\u20ff]|[\u213b-\u213c]|\
+[\u214c-\u2152]|[\u2184-\u218f]|[\u23cf-\u23ff]|[\u2427-\u243f]|\
+[\u244b-\u245f]|\u24ff|[\u2614-\u2615]|\u2618|[\u267e-\u267f]|\
+[\u268a-\u2700]|\u2705|[\u270a-\u270b]|\u2728|\u274c|\u274e|\
+[\u2753-\u2755]|\u2757|[\u275f-\u2760]|[\u2795-\u2797]|\u27b0|\
+[\u27bf-\u27cf]|[\u27ec-\u27ef]|[\u2b00-\u2e7f]|\u2e9a|\
+[\u2ef4-\u2eff]|[\u2fd6-\u2fef]|[\u2ffc-\u2fff]|\u3040|\
+[\u3097-\u3098]|[\u3100-\u3104]|[\u312d-\u3130]|\u318f|\
+[\u31b8-\u31ef]|[\u321d-\u321f]|[\u3244-\u3250]|[\u327c-\u327e]|\
+[\u32cc-\u32cf]|\u32ff|[\u3377-\u337a]|[\u33de-\u33df]|\u33ff|\
+[\u4db6-\u4dff]|[\u9fa6-\u9fff]|[\ua48d-\ua48f]|[\ua4c7-\uabff]|\
+[\ud7a4-\ud7ff]|[\ufa2e-\ufa2f]|[\ufa6b-\ufaff]|[\ufb07-\ufb12]|\
+[\ufb18-\ufb1c]|\ufb37|\ufb3d|\ufb3f|\ufb42|\ufb45|[\ufbb2-\ufbd2]|\
+[\ufd40-\ufd4f]|[\ufd90-\ufd91]|[\ufdc8-\ufdcf]|[\ufdfd-\ufdff]|\
+[\ufe10-\ufe1f]|[\ufe24-\ufe2f]|[\ufe47-\ufe48]|\ufe53|\ufe67|\
+[\ufe6c-\ufe6f]|\ufe75|[\ufefd-\ufefe]|\uff00|[\uffbf-\uffc1]|\
+[\uffc8-\uffc9]|[\uffd0-\uffd1]|[\uffd8-\uffd9]|[\uffdd-\uffdf]|\
+\uffe7|[\uffef-\ufff8]|[\u{10000}-\u{102ff}]|\u{1031f}|\
+[\u{10324}-\u{1032f}]|[\u{1034b}-\u{103ff}]|[\u{10426}-\u{10427}]|\
+[\u{1044e}-\u{1cfff}]|[\u{1d0f6}-\u{1d0ff}]|[\u{1d127}-\u{1d129}]|\
+[\u{1d1de}-\u{1d3ff}]|\u{1d455}|\u{1d49d}|[\u{1d4a0}-\u{1d4a1}]|\
+[\u{1d4a3}-\u{1d4a4}]|[\u{1d4a7}-\u{1d4a8}]|\u{1d4ad}|\u{1d4ba}|\
+\u{1d4bc}|\u{1d4c1}|\u{1d4c4}|\u{1d506}|[\u{1d50b}-\u{1d50c}]|\
+\u{1d515}|\u{1d51d}|\u{1d53a}|\u{1d53f}|\u{1d545}|\
+[\u{1d547}-\u{1d549}]|\u{1d551}|[\u{1d6a4}-\u{1d6a7}]|\
+[\u{1d7ca}-\u{1d7cd}]|[\u{1d800}-\u{1fffd}]|[\u{2a6d7}-\u{2f7ff}]|\
+[\u{2fa1e}-\u{2fffd}]|[\u{30000}-\u{3fffd}]|[\u{40000}-\u{4fffd}]|\
+[\u{50000}-\u{5fffd}]|[\u{60000}-\u{6fffd}]|[\u{70000}-\u{7fffd}]|\
+[\u{80000}-\u{8fffd}]|[\u{90000}-\u{9fffd}]|[\u{a0000}-\u{afffd}]|\
+[\u{b0000}-\u{bfffd}]|[\u{c0000}-\u{cfffd}]|[\u{d0000}-\u{dfffd}]|\
+\u{e0000}|[\u{e0002}-\u{e001f}]|[\u{e0080}-\u{efffd}]",
+ B1: "\u00ad|\u034f|\u1806|[\u180b-\u180d]|[\u200b-\u200d]|\u2060|\
+[\ufe00-\ufe0f]|\ufeff",
+ C12: "\u00a0|\u1680|[\u2000-\u200b]|\u202f|\u205f|\u3000",
+ C21: "[\u0000-\u001f]|\u007f",
+ C22: "[\u0080-\u009f]|\u06dd|\u070f|\u180e|\u200c|\u200d|\u2028|\u2029|\
+[\u2060-\u2063]|[\u206a-\u206f]|\ufeff|[\ufff9-\ufffc]",
+ C3: "[\ue000-\uf8ff]|[\u{f0000}-\u{ffffd}]|[\u{100000}-\u{10fffd}]",
+ C4: "[\ufdd0-\ufdef]|[\ufffe-\uffff]|[\u{1fffe}-\u{1ffff}]|\
+[\u{2fffe}-\u{2ffff}]|[\u{3fffe}-\u{3ffff}]|[\u{4fffe}-\u{4ffff}]|\
+[\u{5fffe}-\u{5ffff}]|[\u{6fffe}-\u{6ffff}]|[\u{7fffe}-\u{7ffff}]|\
+[\u{8fffe}-\u{8ffff}]|[\u{9fffe}-\u{9ffff}]|[\u{afffe}-\u{affff}]|\
+[\u{bfffe}-\u{bffff}]|[\u{cfffe}-\u{cffff}]|[\u{dfffe}-\u{dffff}]|\
+[\u{efffe}-\u{effff}]|[\u{ffffe}-\u{fffff}]|[\u{10fffe}-\u{10ffff}]",
+ C5: "[\ud800-\udfff]",
+ C6: "\ufff9|[\ufffa-\ufffd]",
+ C7: "[\u2ff0-\u2ffb]",
+ C8: "\u0340|\u0341|\u200e|\u200f|[\u202a-\u202e]|[\u206a-\u206f]",
+ C9: "\u{e0001}|[\u{e0020}-\u{e007f}]",
+ D1: "\u05be|\u05c0|\u05c3|[\u05d0-\u05ea]|[\u05f0-\u05f4]|\u061b|\u061f|\
+[\u0621-\u063a]|[\u0640-\u064a]|[\u066d-\u066f]|[\u0671-\u06d5]|\
+\u06dd|[\u06e5-\u06e6]|[\u06fa-\u06fe]|[\u0700-\u070d]|\u0710|\
+[\u0712-\u072c]|[\u0780-\u07a5]|\u07b1|\u200f|\ufb1d|[\ufb1f-\ufb28]|\
+[\ufb2a-\ufb36]|[\ufb38-\ufb3c]|\ufb3e|[\ufb40-\ufb41]|\
+[\ufb43-\ufb44]|[\ufb46-\ufbb1]|[\ufbd3-\ufd3d]|[\ufd50-\ufd8f]|\
+[\ufd92-\ufdc7]|[\ufdf0-\ufdfc]|[\ufe70-\ufe74]|[\ufe76-\ufefc]",
+ D2: "[\u0041-\u005a]|[\u0061-\u007a]|\u00aa|\u00b5|\u00ba|[\u00c0-\u00d6]|\
+[\u00d8-\u00f6]|[\u00f8-\u0220]|[\u0222-\u0233]|[\u0250-\u02ad]|\
+[\u02b0-\u02b8]|[\u02bb-\u02c1]|[\u02d0-\u02d1]|[\u02e0-\u02e4]|\
+\u02ee|\u037a|\u0386|[\u0388-\u038a]|\u038c|[\u038e-\u03a1]|\
+[\u03a3-\u03ce]|[\u03d0-\u03f5]|[\u0400-\u0482]|[\u048a-\u04ce]|\
+[\u04d0-\u04f5]|[\u04f8-\u04f9]|[\u0500-\u050f]|[\u0531-\u0556]|\
+[\u0559-\u055f]|[\u0561-\u0587]|\u0589|\u0903|[\u0905-\u0939]|\
+[\u093d-\u0940]|[\u0949-\u094c]|\u0950|[\u0958-\u0961]|\
+[\u0964-\u0970]|[\u0982-\u0983]|[\u0985-\u098c]|[\u098f-\u0990]|\
+[\u0993-\u09a8]|[\u09aa-\u09b0]|\u09b2|[\u09b6-\u09b9]|\
+[\u09be-\u09c0]|[\u09c7-\u09c8]|[\u09cb-\u09cc]|\u09d7|\
+[\u09dc-\u09dd]|[\u09df-\u09e1]|[\u09e6-\u09f1]|[\u09f4-\u09fa]|\
+[\u0a05-\u0a0a]|[\u0a0f-\u0a10]|[\u0a13-\u0a28]|[\u0a2a-\u0a30]|\
+[\u0a32-\u0a33]|[\u0a35-\u0a36]|[\u0a38-\u0a39]|[\u0a3e-\u0a40]|\
+[\u0a59-\u0a5c]|\u0a5e|[\u0a66-\u0a6f]|[\u0a72-\u0a74]|\u0a83|\
+[\u0a85-\u0a8b]|\u0a8d|[\u0a8f-\u0a91]|[\u0a93-\u0aa8]|\
+[\u0aaa-\u0ab0]|[\u0ab2-\u0ab3]|[\u0ab5-\u0ab9]|[\u0abd-\u0ac0]|\
+\u0ac9|[\u0acb-\u0acc]|\u0ad0|\u0ae0|[\u0ae6-\u0aef]|[\u0b02-\u0b03]|\
+[\u0b05-\u0b0c]|[\u0b0f-\u0b10]|[\u0b13-\u0b28]|[\u0b2a-\u0b30]|\
+[\u0b32-\u0b33]|[\u0b36-\u0b39]|[\u0b3d-\u0b3e]|\u0b40|\
+[\u0b47-\u0b48]|[\u0b4b-\u0b4c]|\u0b57|[\u0b5c-\u0b5d]|\
+[\u0b5f-\u0b61]|[\u0b66-\u0b70]|\u0b83|[\u0b85-\u0b8a]|\
+[\u0b8e-\u0b90]|[\u0b92-\u0b95]|[\u0b99-\u0b9a]|\u0b9c|\
+[\u0b9e-\u0b9f]|[\u0ba3-\u0ba4]|[\u0ba8-\u0baa]|[\u0bae-\u0bb5]|\
+[\u0bb7-\u0bb9]|[\u0bbe-\u0bbf]|[\u0bc1-\u0bc2]|[\u0bc6-\u0bc8]|\
+[\u0bca-\u0bcc]|\u0bd7|[\u0be7-\u0bf2]|[\u0c01-\u0c03]|\
+[\u0c05-\u0c0c]|[\u0c0e-\u0c10]|[\u0c12-\u0c28]|[\u0c2a-\u0c33]|\
+[\u0c35-\u0c39]|[\u0c41-\u0c44]|[\u0c60-\u0c61]|[\u0c66-\u0c6f]|\
+[\u0c82-\u0c83]|[\u0c85-\u0c8c]|[\u0c8e-\u0c90]|[\u0c92-\u0ca8]|\
+[\u0caa-\u0cb3]|[\u0cb5-\u0cb9]|\u0cbe|[\u0cc0-\u0cc4]|\
+[\u0cc7-\u0cc8]|[\u0cca-\u0ccb]|[\u0cd5-\u0cd6]|\u0cde|\
+[\u0ce0-\u0ce1]|[\u0ce6-\u0cef]|[\u0d02-\u0d03]|[\u0d05-\u0d0c]|\
+[\u0d0e-\u0d10]|[\u0d12-\u0d28]|[\u0d2a-\u0d39]|[\u0d3e-\u0d40]|\
+[\u0d46-\u0d48]|[\u0d4a-\u0d4c]|\u0d57|[\u0d60-\u0d61]|\
+[\u0d66-\u0d6f]|[\u0d82-\u0d83]|[\u0d85-\u0d96]|[\u0d9a-\u0db1]|\
+[\u0db3-\u0dbb]|\u0dbd|[\u0dc0-\u0dc6]|[\u0dcf-\u0dd1]|\
+[\u0dd8-\u0ddf]|[\u0df2-\u0df4]|[\u0e01-\u0e30]|[\u0e32-\u0e33]|\
+[\u0e40-\u0e46]|[\u0e4f-\u0e5b]|[\u0e81-\u0e82]|\u0e84|\
+[\u0e87-\u0e88]|\u0e8a|\u0e8d|[\u0e94-\u0e97]|[\u0e99-\u0e9f]|\
+[\u0ea1-\u0ea3]|\u0ea5|\u0ea7|[\u0eaa-\u0eab]|[\u0ead-\u0eb0]|\
+[\u0eb2-\u0eb3]|\u0ebd|[\u0ec0-\u0ec4]|\u0ec6|[\u0ed0-\u0ed9]|\
+[\u0edc-\u0edd]|[\u0f00-\u0f17]|[\u0f1a-\u0f34]|\u0f36|\u0f38|\
+[\u0f3e-\u0f47]|[\u0f49-\u0f6a]|\u0f7f|\u0f85|[\u0f88-\u0f8b]|\
+[\u0fbe-\u0fc5]|[\u0fc7-\u0fcc]|\u0fcf|[\u1000-\u1021]|\
+[\u1023-\u1027]|[\u1029-\u102a]|\u102c|\u1031|\u1038|[\u1040-\u1057]|\
+[\u10a0-\u10c5]|[\u10d0-\u10f8]|\u10fb|[\u1100-\u1159]|\
+[\u115f-\u11a2]|[\u11a8-\u11f9]|[\u1200-\u1206]|[\u1208-\u1246]|\
+\u1248|[\u124a-\u124d]|[\u1250-\u1256]|\u1258|[\u125a-\u125d]|\
+[\u1260-\u1286]|\u1288|[\u128a-\u128d]|[\u1290-\u12ae]|\u12b0|\
+[\u12b2-\u12b5]|[\u12b8-\u12be]|\u12c0|[\u12c2-\u12c5]|\
+[\u12c8-\u12ce]|[\u12d0-\u12d6]|[\u12d8-\u12ee]|[\u12f0-\u130e]|\
+\u1310|[\u1312-\u1315]|[\u1318-\u131e]|[\u1320-\u1346]|\
+[\u1348-\u135a]|[\u1361-\u137c]|[\u13a0-\u13f4]|[\u1401-\u1676]|\
+[\u1681-\u169a]|[\u16a0-\u16f0]|[\u1700-\u170c]|[\u170e-\u1711]|\
+[\u1720-\u1731]|[\u1735-\u1736]|[\u1740-\u1751]|[\u1760-\u176c]|\
+[\u176e-\u1770]|[\u1780-\u17b6]|[\u17be-\u17c5]|[\u17c7-\u17c8]|\
+[\u17d4-\u17da]|\u17dc|[\u17e0-\u17e9]|[\u1810-\u1819]|\
+[\u1820-\u1877]|[\u1880-\u18a8]|[\u1e00-\u1e9b]|[\u1ea0-\u1ef9]|\
+[\u1f00-\u1f15]|[\u1f18-\u1f1d]|[\u1f20-\u1f45]|[\u1f48-\u1f4d]|\
+[\u1f50-\u1f57]|\u1f59|\u1f5b|\u1f5d|[\u1f5f-\u1f7d]|[\u1f80-\u1fb4]|\
+[\u1fb6-\u1fbc]|\u1fbe|[\u1fc2-\u1fc4]|[\u1fc6-\u1fcc]|\
+[\u1fd0-\u1fd3]|[\u1fd6-\u1fdb]|[\u1fe0-\u1fec]|[\u1ff2-\u1ff4]|\
+[\u1ff6-\u1ffc]|\u200e|\u2071|\u207f|\u2102|\u2107|[\u210a-\u2113]|\
+\u2115|[\u2119-\u211d]|\u2124|\u2126|\u2128|[\u212a-\u212d]|\
+[\u212f-\u2131]|[\u2133-\u2139]|[\u213d-\u213f]|[\u2145-\u2149]|\
+[\u2160-\u2183]|[\u2336-\u237a]|\u2395|[\u249c-\u24e9]|\
+[\u3005-\u3007]|[\u3021-\u3029]|[\u3031-\u3035]|[\u3038-\u303c]|\
+[\u3041-\u3096]|[\u309d-\u309f]|[\u30a1-\u30fa]|[\u30fc-\u30ff]|\
+[\u3105-\u312c]|[\u3131-\u318e]|[\u3190-\u31b7]|[\u31f0-\u321c]|\
+[\u3220-\u3243]|[\u3260-\u327b]|[\u327f-\u32b0]|[\u32c0-\u32cb]|\
+[\u32d0-\u32fe]|[\u3300-\u3376]|[\u337b-\u33dd]|[\u33e0-\u33fe]|\
+[\u3400-\u4db5]|[\u4e00-\u9fa5]|[\ua000-\ua48c]|[\uac00-\ud7a3]|\
+[\ud800-\ufa2d]|[\ufa30-\ufa6a]|[\ufb00-\ufb06]|[\ufb13-\ufb17]|\
+[\uff21-\uff3a]|[\uff41-\uff5a]|[\uff66-\uffbe]|[\uffc2-\uffc7]|\
+[\uffca-\uffcf]|[\uffd2-\uffd7]|[\uffda-\uffdc]|[\u{10300}-\u{1031e}]|\
+[\u{10320}-\u{10323}]|[\u{10330}-\u{1034a}]|[\u{10400}-\u{10425}]|\
+[\u{10428}-\u{1044d}]|[\u{1d000}-\u{1d0f5}]|[\u{1d100}-\u{1d126}]|\
+[\u{1d12a}-\u{1d166}]|[\u{1d16a}-\u{1d172}]|[\u{1d183}-\u{1d184}]|\
+[\u{1d18c}-\u{1d1a9}]|[\u{1d1ae}-\u{1d1dd}]|[\u{1d400}-\u{1d454}]|\
+[\u{1d456}-\u{1d49c}]|[\u{1d49e}-\u{1d49f}]|\u{1d4a2}|\
+[\u{1d4a5}-\u{1d4a6}]|[\u{1d4a9}-\u{1d4ac}]|[\u{1d4ae}-\u{1d4b9}]|\
+\u{1d4bb}|[\u{1d4bd}-\u{1d4c0}]|[\u{1d4c2}-\u{1d4c3}]|\
+[\u{1d4c5}-\u{1d505}]|[\u{1d507}-\u{1d50a}]|[\u{1d50d}-\u{1d514}]|\
+[\u{1d516}-\u{1d51c}]|[\u{1d51e}-\u{1d539}]|[\u{1d53b}-\u{1d53e}]|\
+[\u{1d540}-\u{1d544}]|\u{1d546}|[\u{1d54a}-\u{1d550}]|\
+[\u{1d552}-\u{1d6a3}]|[\u{1d6a8}-\u{1d7c9}]|[\u{20000}-\u{2a6d6}]|\
+[\u{2f800}-\u{2fa1d}]|[\u{f0000}-\u{ffffd}]|[\u{100000}-\u{10fffd}]",
+};
+
+// Generates a random nonce and returns a base64 encoded string.
+// aLength in bytes.
+function createNonce(aLength) {
+ // RFC 5802 (5.1): Printable ASCII except ",".
+ // We guarantee a valid nonce value using base64 encoding.
+ return btoa(CryptoUtils.generateRandomBytes(aLength));
+}
+
+// Parses the string of server's response (aChallenge) into an object.
+function parseChallenge(aChallenge) {
+ let attributes = {};
+ aChallenge.split(",").forEach(value => {
+ let match = /^(\w)=([\s\S]*)$/.exec(value);
+ if (match) {
+ attributes[match[1]] = match[2];
+ }
+ });
+ return attributes;
+}
+
+// RFC 4013 and RFC 3454: Stringprep Profile for User Names and Passwords.
+export function saslPrep(aString) {
+ // RFC 4013 2.1: non-ASCII space characters (RFC 3454 C.1.2) mapped to space.
+ let retVal = aString.replace(new RegExp(RFC3454.C12, "u"), " ");
+
+ // RFC 4013 2.1: RFC 3454 3.1, B.1: Map certain codepoints to nothing.
+ retVal = retVal.replace(new RegExp(RFC3454.B1, "u"), "");
+
+ // RFC 4013 2.2 asks for Unicode normalization form KC, which corresponds to
+ // RFC 3454 B.2.
+ retVal = retVal.normalize("NFKC");
+
+ // RFC 4013 2.3: Prohibited Output and 2.5: Unassigned Code Points.
+ let matchStr =
+ RFC3454.C12 +
+ "|" +
+ RFC3454.C21 +
+ "|" +
+ RFC3454.C22 +
+ "|" +
+ RFC3454.C3 +
+ "|" +
+ RFC3454.C4 +
+ "|" +
+ RFC3454.C5 +
+ "|" +
+ RFC3454.C6 +
+ "|" +
+ RFC3454.C7 +
+ "|" +
+ RFC3454.C8 +
+ "|" +
+ RFC3454.C9 +
+ "|" +
+ RFC3454.A1;
+ let match = new RegExp(matchStr, "u").test(retVal);
+ if (match) {
+ throw new Error("String contains prohibited characters");
+ }
+
+ // RFC 4013 2.4: Bidirectional Characters.
+ let r = new RegExp(RFC3454.D1, "u").test(retVal);
+ let l = new RegExp(RFC3454.D2, "u").test(retVal);
+ if (l && r) {
+ throw new Error(
+ "String must not contain LCat and RandALCat characters together"
+ );
+ } else if (r) {
+ let matchFirst = new RegExp("^(" + RFC3454.D1 + ")", "u").test(retVal);
+ let matchLast = new RegExp("(" + RFC3454.D1 + ")$", "u").test(retVal);
+ if (!matchFirst || !matchLast) {
+ throw new Error(
+ "A RandALCat character must be the first and the last character"
+ );
+ }
+ }
+
+ return retVal;
+}
+
+// Converts aName to saslname.
+function saslName(aName) {
+ // RFC 5802 (5.1): the client SHOULD prepare the username using the "SASLprep".
+ // The characters ’,’ or ’=’ in usernames are sent as ’=2C’ and
+ // ’=3D’ respectively.
+ let saslName = saslPrep(aName).replace(/=/g, "=3D").replace(/,/g, "=2C");
+ if (!saslName) {
+ throw new Error("Name is not valid");
+ }
+
+ return saslName;
+}
+
+// Converts aMessage to array of bytes then apply hashing.
+function bytesAndHash(aMessage, aHash) {
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(hasher[aHash]);
+
+ return CryptoUtils.digestBytes(aMessage, hasher);
+}
+
+/**
+ * PBKDF2 password stretching with hmac.
+ *
+ * This is a copy of CryptoUtils.pbkdf2Generate, but with an additional argument to take the hash type.
+ *
+ * @param {string} passphrase Passphrase as an octet string.
+ * @param {string} salt Salt as an octet string.
+ * @param {string} iterations Number of iterations, a positive integer.
+ * @param {string} len Desired output length in bytes.
+ * @param {string} hash The desired hash algorithm (e.g. SHA-1 or SHA-256).
+ * @returns {Uint8Array}
+ */
+async function pbkdf2Generate(passphrase, salt, iterations, len, hash) {
+ passphrase = CommonUtils.byteStringToArrayBuffer(passphrase);
+ salt = CommonUtils.byteStringToArrayBuffer(salt);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ passphrase,
+ { name: "PBKDF2" },
+ false,
+ ["deriveBits"]
+ );
+ const output = await crypto.subtle.deriveBits(
+ {
+ name: "PBKDF2",
+ hash,
+ salt,
+ iterations,
+ },
+ key,
+ len * 8
+ );
+ return new Uint8Array(output);
+}
+
+/*
+ * Given hash functions return a generator to be used as an XMPP authentication
+ * mechanism.
+ *
+ * @param {string} aHashFunctionName The name of a hash, e.g. SHA-1 or SHA-256.
+ * @param {string} aDigestLength The length of a hash digest, e.g. 20 for SHA-1 or 32 for SHA-256.
+ */
+function generateScramAuth(aHashFunctionName, aDigestLength) {
+ function* scramAuth(aUsername, aPassword, aDomain, aNonce) {
+ // The hash function name, without the '-' in it (e.g. convert SHA-1 to SHA1).
+ const hashFunctionProp = aHashFunctionName.replace("-", "");
+
+ // RFC 5802 (5): SCRAM Authentication Exchange.
+ const gs2Header = "n,,";
+ // If a hard-coded nonce was given (e.g. for testing), use it.
+ let cNonce = aNonce ? aNonce : createNonce(32);
+
+ let clientFirstMessageBare = "n=" + saslName(aUsername) + ",r=" + cNonce;
+ let clientFirstMessage = gs2Header + clientFirstMessageBare;
+
+ let receivedStanza = yield {
+ send: Stanza.node(
+ "auth",
+ Stanza.NS.sasl,
+ { mechanism: "SCRAM-" + aHashFunctionName },
+ btoa(clientFirstMessage)
+ ),
+ };
+
+ if (receivedStanza.localName != "challenge") {
+ throw new Error("Not authorized");
+ }
+
+ // RFC 5802 (3): SCRAM Algorithm Overview.
+ let decodedChallenge = atob(receivedStanza.innerText);
+
+ // Expected to contain the user’s iteration count (i) and the user’s
+ // salt (s), and the server appends its own nonce to the client-specified
+ // one (r).
+ let attributes = parseChallenge(decodedChallenge);
+ if (attributes.hasOwnProperty("e")) {
+ throw new Error("Authentication failed: " + attributes.e);
+ } else if (
+ !attributes.hasOwnProperty("i") ||
+ !attributes.hasOwnProperty("s") ||
+ !attributes.hasOwnProperty("r")
+ ) {
+ throw new Error("Unexpected response: " + decodedChallenge);
+ }
+ if (!attributes.r.startsWith(cNonce)) {
+ throw new Error("Nonce is not correct");
+ }
+
+ let clientFinalMessageWithoutProof =
+ "c=" + btoa(gs2Header) + ",r=" + attributes.r;
+
+ // The server signature is calculated below, but needs to escape back to the main scope.
+ let serverSignature;
+
+ // Once the promise resolves, continue with the handshake.
+ receivedStanza = yield (async () => {
+ // SaltedPassword := Hi(Normalize(password), salt, i)
+ // Normalize using saslPrep.
+ // dkLen MUST be equal to the SHA digest size.
+ let saltedPassword = await pbkdf2Generate(
+ saslPrep(aPassword),
+ atob(attributes.s),
+ parseInt(attributes.i),
+ aDigestLength,
+ aHashFunctionName
+ );
+
+ // Calculate ClientProof.
+
+ // ClientKey := HMAC(SaltedPassword, "Client Key")
+ let clientKeyBuffer = await CryptoUtils.hmac(
+ aHashFunctionName,
+ saltedPassword,
+ CommonUtils.byteStringToArrayBuffer("Client Key")
+ );
+ let clientKey = CommonUtils.arrayBufferToByteString(clientKeyBuffer);
+
+ // StoredKey := H(ClientKey)
+ let storedKey = bytesAndHash(clientKey, hashFunctionProp);
+
+ let authMessage = CommonUtils.byteStringToArrayBuffer(
+ clientFirstMessageBare +
+ "," +
+ decodedChallenge +
+ "," +
+ clientFinalMessageWithoutProof
+ );
+
+ // ClientSignature := HMAC(StoredKey, AuthMessage)
+ let clientSignatureBuffer = await CryptoUtils.hmac(
+ aHashFunctionName,
+ CommonUtils.byteStringToArrayBuffer(storedKey),
+ authMessage
+ );
+ let clientSignature = CommonUtils.arrayBufferToByteString(
+ clientSignatureBuffer
+ );
+ // ClientProof := ClientKey XOR ClientSignature
+ let clientProof = CryptoUtils.xor(clientKey, clientSignature);
+
+ // Calculate ServerSignature.
+
+ // ServerKey := HMAC(SaltedPassword, "Server Key")
+ let serverKeyBuffer = await CryptoUtils.hmac(
+ aHashFunctionName,
+ saltedPassword,
+ CommonUtils.byteStringToArrayBuffer("Server Key")
+ );
+
+ // ServerSignature := HMAC(ServerKey, AuthMessage)
+ let serverSignatureBuffer = await CryptoUtils.hmac(
+ aHashFunctionName,
+ serverKeyBuffer,
+ authMessage
+ );
+ serverSignature = CommonUtils.arrayBufferToByteString(
+ serverSignatureBuffer
+ );
+
+ let clientFinalMessage =
+ clientFinalMessageWithoutProof + ",p=" + btoa(clientProof);
+
+ return {
+ send: Stanza.node(
+ "response",
+ Stanza.NS.sasl,
+ null,
+ btoa(clientFinalMessage)
+ ),
+ log: "<response/> (base64 encoded SCRAM response containing password not logged)",
+ };
+ })();
+
+ // Only check server signature if we succeed to authenticate.
+ if (receivedStanza.localName != "success") {
+ throw new Error("Didn't receive the expected auth success stanza.");
+ }
+
+ let decodedResponse = atob(receivedStanza.innerText);
+
+ // Expected to contain a base64-encoded ServerSignature (v).
+ attributes = parseChallenge(decodedResponse);
+ if (!attributes.hasOwnProperty("v")) {
+ throw new Error("Unexpected response: " + decodedResponse);
+ }
+
+ // Compare ServerSignature with our ServerSignature which we calculated in
+ // _generateResponse.
+ let serverSignatureResponse = atob(attributes.v);
+ if (serverSignature != serverSignatureResponse) {
+ throw new Error("Server signature does not match");
+ }
+ }
+
+ return scramAuth;
+}
+
+export var XMPPAuthMechanisms = {
+ PLAIN: PlainAuth,
+ "SCRAM-SHA-1": generateScramAuth("SHA-1", 20),
+ "SCRAM-SHA-256": generateScramAuth("SHA-256", 32),
+};
diff --git a/comm/chat/protocols/xmpp/xmpp-base.sys.mjs b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs
new file mode 100644
index 0000000000..f7e0ccd98e
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs
@@ -0,0 +1,3421 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { Status } from "resource:///modules/imStatusUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ executeSoon,
+ nsSimpleEnumerator,
+ EmptyEnumerator,
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericAccountBuddyPrototype,
+ GenericConvIMPrototype,
+ GenericConvChatPrototype,
+ GenericConversationPrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs";
+import {
+ Stanza,
+ SupportedFeatures,
+} from "resource:///modules/xmpp-xml.sys.mjs";
+import { XMPPSession } from "resource:///modules/xmpp-session.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "imgTools",
+ "@mozilla.org/image/tools;1",
+ "imgITools"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTxt => cs.scanTXT(aTxt, cs.kEntities);
+});
+
+// Parses the status from a presence stanza into an object of statusType,
+// statusText and idleSince.
+function parseStatus(aStanza) {
+ let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE;
+ let show = aStanza.getElement(["show"]);
+ if (show) {
+ show = show.innerText;
+ if (show == "away") {
+ statusType = Ci.imIStatusInfo.STATUS_AWAY;
+ } else if (show == "chat") {
+ statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; // FIXME
+ } else if (show == "dnd") {
+ statusType = Ci.imIStatusInfo.STATUS_UNAVAILABLE;
+ } else if (show == "xa") {
+ statusType = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+ }
+
+ let idleSince = 0;
+ let date = _getDelay(aStanza);
+ if (date) {
+ idleSince = date.getTime();
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (query && query.uri == Stanza.NS.last) {
+ let now = Math.floor(Date.now() / 1000);
+ idleSince = now - parseInt(query.attributes.seconds, 10);
+ statusType = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+
+ let status = aStanza.getElement(["status"]);
+ status = status ? status.innerText : "";
+
+ return { statusType, statusText: status, idleSince };
+}
+
+// Returns a Date object for the delay value (stamp) in aStanza if it exists,
+// otherwise returns undefined.
+function _getDelay(aStanza) {
+ // XEP-0203: Delayed Delivery.
+ let date;
+ let delay = aStanza.getElement(["delay"]);
+ if (delay && delay.uri == Stanza.NS.delay) {
+ if (delay.attributes.stamp) {
+ date = new Date(delay.attributes.stamp);
+ }
+ }
+ if (date && isNaN(date.getTime())) {
+ return undefined;
+ }
+
+ return date;
+}
+
+// Writes aMsg in aConv as an outgoing message with optional date as the
+// message may be sent from another client.
+function _displaySentMsg(aConv, aMsg, aDate) {
+ let who;
+ if (aConv._account._connection) {
+ who = aConv._account._connection._jid.jid;
+ }
+ if (!who) {
+ who = aConv._account.name;
+ }
+
+ let flags = { outgoing: true };
+ flags._alias = aConv.account.alias || aConv.account.statusInfo.displayName;
+
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ aConv.writeMessage(who, aMsg, flags);
+}
+
+// The timespan after which we consider roomInfo to be stale.
+var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours.
+
+/* This is an ordered list, used to determine chat buddy flags:
+ * index = member -> voiced
+ * moderator -> moderator
+ * admin -> admin
+ * owner -> founder
+ */
+var kRoles = [
+ "outcast",
+ "visitor",
+ "participant",
+ "member",
+ "moderator",
+ "admin",
+ "owner",
+];
+
+function MUCParticipant(aNick, aJid, aPresenceStanza) {
+ this._jid = aJid;
+ this.name = aNick;
+ this.onPresenceStanza(aPresenceStanza);
+}
+MUCParticipant.prototype = {
+ __proto__: ClassInfo("prplIConvChatBuddy", "XMPP ConvChatBuddy object"),
+
+ buddy: false,
+
+ // The occupant jid of the participant which is of the form room@domain/nick.
+ _jid: null,
+
+ // The real jid of the participant which is of the form local@domain/resource.
+ accountJid: null,
+
+ statusType: null,
+ statusText: null,
+ get alias() {
+ return this.name;
+ },
+
+ role: 2, // "participant" by default
+
+ // Called when a presence stanza is received for this participant.
+ onPresenceStanza(aStanza) {
+ let statusInfo = parseStatus(aStanza);
+ this.statusType = statusInfo.statusType;
+ this.statusText = statusInfo.statusText;
+
+ let x = aStanza.children.filter(
+ child => child.localName == "x" && child.uri == Stanza.NS.muc_user
+ );
+ if (x.length == 0) {
+ return;
+ }
+
+ // XEP-0045 (7.2.3): We only expect a single <x/> element of this namespace,
+ // so we ignore any others.
+ x = x[0];
+
+ let item = x.getElement(["item"]);
+ if (!item) {
+ return;
+ }
+
+ this.role = Math.max(
+ kRoles.indexOf(item.attributes.role),
+ kRoles.indexOf(item.attributes.affiliation)
+ );
+
+ let accountJid = item.attributes.jid;
+ if (accountJid) {
+ this.accountJid = accountJid;
+ }
+ },
+
+ get voiced() {
+ /* FIXME: The "voiced" role corresponds to users that can send messages to
+ * the room. If the chat is unmoderated, this should include everyone, not
+ * just members. */
+ return this.role == kRoles.indexOf("member");
+ },
+ get moderator() {
+ return this.role == kRoles.indexOf("moderator");
+ },
+ get admin() {
+ return this.role == kRoles.indexOf("admin");
+ },
+ get founder() {
+ return this.role == kRoles.indexOf("owner");
+ },
+ typing: false,
+};
+
+// MUC (Multi-User Chat)
+export var XMPPMUCConversationPrototype = {
+ __proto__: GenericConvChatPrototype,
+ // By default users are not in a MUC.
+ _left: true,
+
+ // Tracks all received messages to avoid possible duplication if the server
+ // sends us the last few messages again when we rejoin a room.
+ _messageIds: new Set(),
+
+ _init(aAccount, aJID, aNick) {
+ this._messageIds = new Set();
+ GenericConvChatPrototype._init.call(this, aAccount, aJID, aNick);
+ },
+
+ _targetResource: "",
+
+ // True while we are rejoining a room previously parted by the user.
+ _rejoined: false,
+
+ get topic() {
+ return this._topic;
+ },
+ set topic(aTopic) {
+ // XEP-0045 (8.1): Modifying the room subject.
+ let subject = Stanza.node("subject", null, null, aTopic.trim());
+ let s = Stanza.message(
+ this.name,
+ null,
+ null,
+ { type: "groupchat" },
+ subject
+ );
+ let notAuthorized = lazy._(
+ "conversation.error.changeTopicFailedNotAuthorized"
+ );
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ forbidden: notAuthorized,
+ notAcceptable: notAuthorized,
+ itemNotFound: notAuthorized,
+ },
+ this
+ )
+ );
+ },
+ get topicSettable() {
+ return true;
+ },
+
+ /* Called when the user enters a chat message */
+ dispatchMessage(aMsg, aAction = false) {
+ if (aAction) {
+ // XEP-0245: The /me Command.
+ // We need to prepend "/me " as the first four characters of the message
+ // body.
+ aMsg = "/me " + aMsg;
+ }
+ // XEP-0045 (7.4): Sending a message to all occupants in a room.
+ let s = Stanza.message(this.name, aMsg, null, { type: "groupchat" });
+ let notInRoom = lazy._(
+ "conversation.error.sendFailedAsNotInRoom",
+ this.name,
+ aMsg
+ );
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ itemNotFound: notInRoom,
+ notAcceptable: notInRoom,
+ },
+ this
+ )
+ );
+ },
+
+ /* Called by the account when a presence stanza is received for this muc */
+ onPresenceStanza(aStanza) {
+ let from = aStanza.attributes.from;
+ let nick = this._account._parseJID(from).resource;
+ let jid = this._account.normalize(from);
+ let x = aStanza
+ .getElements(["x"])
+ .find(
+ e => e.uri == Stanza.NS.muc_user || e.uri == Stanza.NS.vcard_update
+ );
+
+ // Check if the join failed.
+ if (this.left && aStanza.attributes.type == "error") {
+ let error = this._account.parseError(aStanza);
+ let message;
+ switch (error.condition) {
+ case "not-authorized":
+ case "registration-required":
+ // XEP-0045 (7.2.7): Members-Only Rooms.
+ message = lazy._("conversation.error.joinFailedNotAuthorized");
+ break;
+ case "not-allowed":
+ message = lazy._("conversation.error.creationFailedNotAllowed");
+ break;
+ case "remote-server-not-found":
+ message = lazy._(
+ "conversation.error.joinFailedRemoteServerNotFound",
+ this.name
+ );
+ break;
+ case "forbidden":
+ // XEP-0045 (7.2.8): Banned users.
+ message = lazy._("conversation.error.joinForbidden", this.name);
+ break;
+ default:
+ message = lazy._("conversation.error.joinFailed", this.name);
+ this.ERROR("Failed to join MUC: " + aStanza.convertToString());
+ break;
+ }
+ this.writeMessage(this.name, message, { system: true, error: true });
+ this.joining = false;
+ return;
+ }
+
+ if (!x) {
+ this.WARN(
+ "Received a MUC presence stanza without an x element or " +
+ "with a namespace we don't handle."
+ );
+ return;
+ }
+ // Handle a MUC resource avatar
+ if (
+ x.uri == Stanza.NS.vcard_update &&
+ aStanza.attributes.from == this.normalizedName
+ ) {
+ let photo = aStanza.getElement(["x", "photo"]);
+ if (photo && photo.uri == Stanza.NS.vcard_update) {
+ let hash = photo.innerText;
+ if (hash && hash != this._photoHash) {
+ this._account._addVCardRequest(this.normalizedName);
+ } else if (!hash && this._photoHash) {
+ delete this._photoHash;
+ this.convIconFilename = "";
+ }
+ }
+ return;
+ }
+ let codes = x.getElements(["status"]).map(elt => elt.attributes.code);
+ let item = x.getElement(["item"]);
+
+ // Changes the nickname of a participant for this muc.
+ let changeNick = () => {
+ if (!item || !item.attributes.nick) {
+ this.WARN(
+ "Received a MUC presence code 303 or 210 stanza without an " +
+ "item element or a nick attribute."
+ );
+ return;
+ }
+ let newNick = item.attributes.nick;
+ this.updateNick(nick, newNick, nick == this.nick);
+ };
+
+ if (aStanza.attributes.type == "unavailable") {
+ if (!this._participants.has(nick)) {
+ this.WARN(
+ "received unavailable presence for an unknown MUC participant: " +
+ from
+ );
+ return;
+ }
+ if (codes.includes("303")) {
+ // XEP-0045 (7.6): Changing Nickname.
+ // Service Updates Nick for user.
+ changeNick();
+ return;
+ }
+ if (item && item.attributes.role == "none") {
+ // XEP-0045: an occupant has left the room.
+ this.removeParticipant(nick);
+
+ // Who caused the participant to leave the room.
+ let actor = item.getElement(["actor"]);
+ let actorNick = actor ? actor.attributes.nick : "";
+ let isActor = actorNick ? ".actor" : "";
+
+ // Why the participant left.
+ let reasonNode = item.getElement(["reason"]);
+ let reason = reasonNode ? reasonNode.innerText : "";
+ let isReason = reason ? ".reason" : "";
+
+ let isYou = nick == this.nick ? ".you" : "";
+ let affectedNick = isYou ? "" : nick;
+ if (isYou) {
+ this.left = true;
+ }
+
+ let message;
+ if (codes.includes("301")) {
+ // XEP-0045 (9.1): Banning a User.
+ message = "conversation.message.banned";
+ } else if (codes.includes("307")) {
+ // XEP-0045 (8.2): Kicking an Occupant.
+ message = "conversation.message.kicked";
+ } else if (codes.includes("322") || codes.includes("321")) {
+ // XEP-0045: Inform user that he or she is being removed from the
+ // room because the room has been changed to members-only and the
+ // user is not a member.
+ message = "conversation.message.removedNonMember";
+ } else if (codes.includes("332")) {
+ // XEP-0045: Inform user that he or she is being removed from the
+ // room because the MUC service is being shut down.
+ message = "conversation.message.mucShutdown";
+
+ // The reason here just duplicates what's in the system message.
+ reason = isReason = "";
+ } else {
+ // XEP-0045 (7.14): Received when the user parts a room.
+ message = "conversation.message.parted";
+
+ // The reason is in a status element in this case.
+ reasonNode = aStanza.getElement(["status"]);
+ reason = reasonNode ? reasonNode.innerText : "";
+ isReason = reason ? ".reason" : "";
+ }
+
+ if (message) {
+ let messageID = message + isYou + isActor + isReason;
+ let params = [actorNick, affectedNick, reason].filter(s => s);
+ this.writeMessage(this.name, lazy._(messageID, ...params), {
+ system: true,
+ });
+ }
+ } else {
+ this.WARN("Unhandled type==unavailable MUC presence stanza.");
+ }
+ return;
+ }
+
+ if (codes.includes("201")) {
+ // XEP-0045 (10.1): Creating room.
+ // Service Acknowledges Room Creation
+ // and Room is awaiting configuration.
+ // XEP-0045 (10.1.2): Instant room.
+ let query = Stanza.node(
+ "query",
+ Stanza.NS.muc_owner,
+ null,
+ Stanza.node("x", Stanza.NS.xdata, { type: "submit" })
+ );
+ let s = Stanza.iq("set", null, jid, query);
+ this._account.sendStanza(s, aStanzaReceived => {
+ if (aStanzaReceived.attributes.type != "result") {
+ return false;
+ }
+
+ // XEP-0045: Service Informs New Room Owner of Success
+ // for instant and reserved rooms.
+ this.left = false;
+ this.joining = false;
+ return true;
+ });
+ } else if (codes.includes("210")) {
+ // XEP-0045 (7.6): Changing Nickname.
+ // Service modifies this user's nickname in accordance with local service
+ // policies.
+ changeNick();
+ return;
+ } else if (codes.includes("110")) {
+ // XEP-0045: Room exists and joined successfully.
+ this.left = false;
+ this.joining = false;
+ // TODO (Bug 1172350): Implement Service Discovery Extensions (XEP-0128) to obtain
+ // configuration of this room.
+ } else if (codes.includes("104") && nick == this.name) {
+ // https://xmpp.org/extensions/inbox/muc-avatars.html (XEP-XXXX)
+ this._account._addVCardRequest(this.normalizedName);
+ }
+
+ if (!this._participants.get(nick)) {
+ let participant = new MUCParticipant(nick, from, aStanza);
+ this._participants.set(nick, participant);
+ this.notifyObservers(
+ new nsSimpleEnumerator([participant]),
+ "chat-buddy-add"
+ );
+ if (this.nick != nick && !this.joining) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.message.join", nick),
+ {
+ system: true,
+ }
+ );
+ } else if (this.nick == nick && this._rejoined) {
+ this.writeMessage(this.name, lazy._("conversation.message.rejoined"), {
+ system: true,
+ });
+ this._rejoined = false;
+ }
+ } else {
+ this._participants.get(nick).onPresenceStanza(aStanza);
+ this.notifyObservers(this._participants.get(nick), "chat-buddy-update");
+ }
+ },
+
+ /* Called by the account when a message is received for this muc */
+ incomingMessage(aMsg, aStanza, aDate) {
+ let from = this._account._parseJID(aStanza.attributes.from).resource;
+ let id = aStanza.attributes.id;
+ let flags = {};
+ if (!from) {
+ flags.system = true;
+ from = this.name;
+ } else if (aStanza.attributes.type == "error") {
+ aMsg = lazy._("conversation.error.notDelivered", aMsg);
+ flags.system = true;
+ flags.error = true;
+ } else if (from == this._nick) {
+ flags.outgoing = true;
+ } else {
+ flags.incoming = true;
+ }
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ if (id) {
+ // Checks if a message exists in conversation to avoid duplication.
+ if (this._messageIds.has(id)) {
+ return;
+ }
+ this._messageIds.add(id);
+ }
+ this.writeMessage(from, aMsg, flags);
+ },
+
+ getNormalizedChatBuddyName(aNick) {
+ return this._account.normalizeFullJid(this.name + "/" + aNick);
+ },
+
+ // Leaves MUC conversation.
+ part(aMsg = null) {
+ let s = Stanza.presence(
+ { to: this.name + "/" + this._nick, type: "unavailable" },
+ aMsg ? Stanza.node("status", null, null, aMsg.trim()) : null
+ );
+ this._account.sendStanza(s);
+ delete this.chatRoomFields;
+ },
+
+ // Invites a user to MUC conversation.
+ invite(aJID, aMsg = null) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.2): Mediated Invitation.
+ let invite = Stanza.node(
+ "invite",
+ null,
+ { to: aJID },
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let x = Stanza.node("x", Stanza.NS.muc_user, null, invite);
+ let s = Stanza.node("message", null, { to: this.name }, x);
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ forbidden: lazy._("conversation.error.inviteFailedForbidden"),
+ // ejabberd uses error not-allowed to indicate that this account does not
+ // have the required privileges to invite users instead of forbidden error,
+ // and this is not mentioned in the spec (XEP-0045).
+ notAllowed: lazy._("conversation.error.inviteFailedForbidden"),
+ itemNotFound: lazy._("conversation.error.failedJIDNotFound", aJID),
+ },
+ this
+ )
+ );
+ },
+
+ // Bans a participant from MUC conversation.
+ ban(aNickName, aMsg = null) {
+ // XEP-0045 (9.1): Banning a User.
+ let participant = this._participants.get(aNickName);
+ if (!participant) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.error.nickNotInRoom", aNickName),
+ { system: true }
+ );
+ return;
+ }
+ if (!participant.accountJid) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.error.banCommandAnonymousRoom"),
+ { system: true }
+ );
+ return;
+ }
+
+ let attributes = { affiliation: "outcast", jid: participant.accountJid };
+ let item = Stanza.node(
+ "item",
+ null,
+ attributes,
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let s = Stanza.iq(
+ "set",
+ null,
+ this.name,
+ Stanza.node("query", Stanza.NS.muc_admin, null, item)
+ );
+ this._account.sendStanza(s, this._banKickHandler, this);
+ },
+
+ // Kicks a participant from MUC conversation.
+ kick(aNickName, aMsg = null) {
+ // XEP-0045 (8.2): Kicking an Occupant.
+ let attributes = { role: "none", nick: aNickName };
+ let item = Stanza.node(
+ "item",
+ null,
+ attributes,
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let s = Stanza.iq(
+ "set",
+ null,
+ this.name,
+ Stanza.node("query", Stanza.NS.muc_admin, null, item)
+ );
+ this._account.sendStanza(s, this._banKickHandler, this);
+ },
+
+ // Callback for ban and kick commands.
+ _banKickHandler(aStanza) {
+ return this._account._handleResult(
+ {
+ notAllowed: lazy._("conversation.error.banKickCommandNotAllowed"),
+ conflict: lazy._("conversation.error.banKickCommandConflict"),
+ },
+ this
+ )(aStanza);
+ },
+
+ // Changes nick in MUC conversation to a new one.
+ setNick(aNewNick) {
+ // XEP-0045 (7.6): Changing Nickname.
+ let s = Stanza.presence({ to: this.name + "/" + aNewNick }, null);
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ // XEP-0045 (7.6): Changing Nickname (example 53).
+ // TODO: We should discover if the user has a reserved nickname (maybe
+ // before joining a room), cf. XEP-0045 (7.12).
+ notAcceptable: lazy._(
+ "conversation.error.changeNickFailedNotAcceptable",
+ aNewNick
+ ),
+ // XEP-0045 (7.2.9): Nickname Conflict.
+ conflict: lazy._(
+ "conversation.error.changeNickFailedConflict",
+ aNewNick
+ ),
+ },
+ this
+ )
+ );
+ },
+
+ // Called by the account when a message stanza is received for this muc and
+ // needs to be handled.
+ onMessageStanza(aStanza) {
+ let x = aStanza.getElement(["x"]);
+ let decline = x.getElement(["decline"]);
+ if (decline) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.2): Mediated Invitation.
+ let invitee = decline.attributes.jid;
+ let reasonNode = decline.getElement(["reason"]);
+ let reason = reasonNode ? reasonNode.innerText : "";
+ let msg;
+ if (reason) {
+ msg = lazy._(
+ "conversation.message.invitationDeclined.reason",
+ invitee,
+ reason
+ );
+ } else {
+ msg = lazy._("conversation.message.invitationDeclined", invitee);
+ }
+
+ this.writeMessage(this.name, msg, { system: true });
+ } else {
+ this.WARN("Unhandled message stanza.");
+ }
+ },
+
+ /* Called when the user closed the conversation */
+ close() {
+ if (!this.left) {
+ this.part();
+ }
+ GenericConvChatPrototype.close.call(this);
+ },
+ unInit() {
+ this._account.removeConversation(this.name);
+ GenericConvChatPrototype.unInit.call(this);
+ },
+
+ _photoHash: null,
+ _saveIcon(aPhotoNode) {
+ this._account._saveResourceIcon(aPhotoNode, this).then(
+ url => {
+ this.convIconFilename = url;
+ },
+ error => {
+ this._account.WARN(
+ "Error while loading conversation icon for " +
+ this.normalizedName +
+ ": " +
+ error.message
+ );
+ }
+ );
+ },
+};
+
+function XMPPMUCConversation(aAccount, aJID, aNick) {
+ this._init(aAccount, aJID, aNick);
+}
+XMPPMUCConversation.prototype = XMPPMUCConversationPrototype;
+
+/* Helper class for buddy conversations */
+export var XMPPConversationPrototype = {
+ __proto__: GenericConvIMPrototype,
+
+ _typingTimer: null,
+ supportChatStateNotifications: true,
+ _typingState: "active",
+
+ // Indicates that current conversation is with a MUC participant and the
+ // recipient jid (stored in the userName) is of the form room@domain/nick.
+ _isMucParticipant: false,
+
+ get buddy() {
+ return this._account._buddies.get(this.name);
+ },
+ get title() {
+ return this.contactDisplayName;
+ },
+ get contactDisplayName() {
+ return this.buddy ? this.buddy.contactDisplayName : this.name;
+ },
+ get userName() {
+ return this.buddy ? this.buddy.userName : this.name;
+ },
+
+ // Returns jid (room@domain/nick) if it is with a MUC participant, and the
+ // name of conversation otherwise.
+ get normalizedName() {
+ if (this._isMucParticipant) {
+ return this._account.normalizeFullJid(this.name);
+ }
+ return this._account.normalize(this.name);
+ },
+
+ // Used to avoid showing full jids in typing notifications.
+ get shortName() {
+ if (this.buddy) {
+ return this.buddy.contactDisplayName;
+ }
+
+ let jid = this._account._parseJID(this.name);
+ if (!jid) {
+ return this.name;
+ }
+
+ // Returns nick of the recipient if conversation is with a participant of
+ // a MUC we are in as jid of the recipient is of the form room@domain/nick.
+ if (this._isMucParticipant) {
+ return jid.resource;
+ }
+
+ return jid.node;
+ },
+
+ get shouldSendTypingNotifications() {
+ return (
+ this.supportChatStateNotifications &&
+ Services.prefs.getBoolPref("purple.conversations.im.send_typing")
+ );
+ },
+
+ /* Called when the user is typing a message
+ * aString - the currently typed message
+ * Returns the number of characters that can still be typed */
+ sendTyping(aString) {
+ if (!this.shouldSendTypingNotifications) {
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ }
+
+ this._cancelTypingTimer();
+ if (aString.length) {
+ this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000);
+ }
+
+ this._setTypingState(aString.length ? "composing" : "active");
+
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ },
+
+ finishedComposing() {
+ if (!this.shouldSendTypingNotifications) {
+ return;
+ }
+
+ this._setTypingState("paused");
+ },
+
+ _setTypingState(aNewState) {
+ if (this._typingState == aNewState) {
+ return;
+ }
+
+ let s = Stanza.message(this.to, null, aNewState);
+
+ // We don't care about errors in response to typing notifications
+ // (e.g. because the user has left the room when talking to a MUC
+ // participant).
+ this._account.sendStanza(s, () => true);
+
+ this._typingState = aNewState;
+ },
+ _cancelTypingTimer() {
+ if (this._typingTimer) {
+ clearTimeout(this._typingTimer);
+ delete this._typingTimer;
+ }
+ },
+
+ // Holds the resource of user that you are currently talking to, but if the
+ // user is a participant of a MUC we are in, holds the nick of user you are
+ // talking to.
+ _targetResource: "",
+
+ get to() {
+ if (!this._targetResource || this._isMucParticipant) {
+ return this.userName;
+ }
+ return this.userName + "/" + this._targetResource;
+ },
+
+ /* Called when the user enters a chat message */
+ dispatchMessage(aMsg, aAction = false) {
+ if (aAction) {
+ // XEP-0245: The /me Command.
+ // We need to prepend "/me " as the first four characters of the message
+ // body.
+ aMsg = "/me" + aMsg;
+ }
+ this._cancelTypingTimer();
+ let cs = this.shouldSendTypingNotifications ? "active" : null;
+ let s = Stanza.message(this.to, aMsg, cs);
+ this._account.sendStanza(s);
+ _displaySentMsg(this, aMsg);
+ delete this._typingState;
+ },
+
+ // Invites the contact to a MUC room.
+ invite(aRoomJid, aPassword = null) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.1) and XEP-0249: Direct Invitation.
+ let x = Stanza.node("x", Stanza.NS.conference, {
+ jid: aRoomJid,
+ password: aPassword,
+ });
+ this._account.sendStanza(Stanza.node("message", null, { to: this.to }, x));
+ },
+
+ // Query the user for its Software Version.
+ // XEP-0092: Software Version.
+ getVersion() {
+ // TODO: Use Service Discovery to determine if the user's client supports
+ // jabber:iq:version protocol.
+
+ let s = Stanza.iq(
+ "get",
+ null,
+ this.to,
+ Stanza.node("query", Stanza.NS.version)
+ );
+ this._account.sendStanza(s, aStanza => {
+ // TODO: handle other errors that can result from querying
+ // user for its software version.
+ if (
+ this._account.handleErrors(
+ {
+ default: lazy._("conversation.error.version.unknown"),
+ },
+ this
+ )(aStanza)
+ ) {
+ return;
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (!query || query.uri != Stanza.NS.version) {
+ this.WARN(
+ "Received a response to version query which does not " +
+ "contain query element or 'jabber:iq:version' namespace."
+ );
+ return;
+ }
+
+ let name = query.getElement(["name"]);
+ let version = query.getElement(["version"]);
+ if (!name || !version) {
+ // XEP-0092: name and version elements are REQUIRED.
+ this.WARN(
+ "Received a response to version query which does not " +
+ "contain name or version."
+ );
+ return;
+ }
+
+ let messageID = "conversation.message.version";
+ let params = [this.shortName, name.innerText, version.innerText];
+
+ // XEP-0092: os is OPTIONAL.
+ let os = query.getElement(["os"]);
+ if (os) {
+ params.push(os.innerText);
+ messageID += "WithOS";
+ }
+
+ this.writeMessage(this.name, lazy._(messageID, ...params), {
+ system: true,
+ });
+ });
+ },
+
+ /* Perform entity escaping before displaying the message. We assume incoming
+ messages have already been escaped, and will otherwise be filtered. */
+ prepareForDisplaying(aMsg) {
+ if (aMsg.outgoing && !aMsg.system) {
+ aMsg.displayMessage = lazy.TXTToHTML(aMsg.displayMessage);
+ }
+ GenericConversationPrototype.prepareForDisplaying.apply(this, arguments);
+ },
+
+ /* Called by the account when a message is received from the buddy */
+ incomingMessage(aMsg, aStanza, aDate) {
+ let from = aStanza.attributes.from;
+ this._targetResource = this._account._parseJID(from).resource;
+ let flags = {};
+ let error = this._account.parseError(aStanza);
+ if (error) {
+ let norm = this._account.normalize(from);
+ let muc = this._account._mucs.get(norm);
+
+ if (!aMsg) {
+ // Failed outgoing message.
+ switch (error.condition) {
+ case "remote-server-not-found":
+ aMsg = lazy._("conversation.error.remoteServerNotFound");
+ break;
+ case "service-unavailable":
+ aMsg = lazy._(
+ "conversation.error.sendServiceUnavailable",
+ this.shortName
+ );
+ break;
+ default:
+ aMsg = lazy._("conversation.error.unknownSendError");
+ break;
+ }
+ } else if (
+ this._isMucParticipant &&
+ muc &&
+ !muc.left &&
+ error.condition == "item-not-found"
+ ) {
+ // XEP-0045 (7.5): MUC private messages.
+ // If we try to send to participant not in a room we are in.
+ aMsg = lazy._(
+ "conversation.error.sendFailedAsRecipientNotInRoom",
+ this._targetResource,
+ aMsg
+ );
+ } else if (
+ this._isMucParticipant &&
+ (error.condition == "item-not-found" ||
+ error.condition == "not-acceptable")
+ ) {
+ // If we left a room and try to send to a participant in it or the
+ // room is removed.
+ aMsg = lazy._(
+ "conversation.error.sendFailedAsNotInRoom",
+ this._account.normalize(from),
+ aMsg
+ );
+ } else {
+ aMsg = lazy._("conversation.error.notDelivered", aMsg);
+ }
+ flags.system = true;
+ flags.error = true;
+ } else {
+ flags = { incoming: true, _alias: this.contactDisplayName };
+ // XEP-0245: The /me Command.
+ if (aMsg.startsWith("/me ")) {
+ flags.action = true;
+ aMsg = aMsg.slice(4);
+ }
+ }
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ this.writeMessage(from, aMsg, flags);
+ },
+
+ /* Called when the user closed the conversation */
+ close() {
+ // TODO send the stanza indicating we have left the conversation?
+ GenericConvIMPrototype.close.call(this);
+ },
+ unInit() {
+ this._account.removeConversation(this.normalizedName);
+ GenericConvIMPrototype.unInit.call(this);
+ },
+};
+
+// Creates XMPP conversation.
+function XMPPConversation(aAccount, aNormalizedName, aMucParticipant) {
+ this._init(aAccount, aNormalizedName);
+ if (aMucParticipant) {
+ this._isMucParticipant = true;
+ }
+}
+XMPPConversation.prototype = XMPPConversationPrototype;
+
+/* Helper class for buddies */
+export var XMPPAccountBuddyPrototype = {
+ __proto__: GenericAccountBuddyPrototype,
+
+ subscription: "none",
+ // Returns a list of TooltipInfo objects to be displayed when the user
+ // hovers over the buddy.
+ getTooltipInfo() {
+ if (!this._account.connected) {
+ return null;
+ }
+
+ let tooltipInfo = [];
+ if (this._resources) {
+ for (let r in this._resources) {
+ let status = this._resources[r];
+ let statusString = Status.toLabel(status.statusType);
+ if (
+ status.statusType == Ci.imIStatusInfo.STATUS_IDLE &&
+ status.idleSince
+ ) {
+ let now = Math.floor(Date.now() / 1000);
+ let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(
+ now - status.idleSince
+ );
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ statusString += " (" + valuesAndUnits.join(" ") + ")";
+ }
+ if (status.statusText) {
+ statusString += " - " + status.statusText;
+ }
+ let label = r
+ ? lazy._("tooltip.status", r)
+ : lazy._("tooltip.statusNoResource");
+ tooltipInfo.push(new TooltipInfo(label, statusString));
+ }
+ }
+
+ // The subscription value is interesting to display only in unusual cases.
+ if (this.subscription != "both") {
+ tooltipInfo.push(
+ new TooltipInfo(lazy._("tooltip.subscription"), this.subscription)
+ );
+ }
+
+ return tooltipInfo;
+ },
+
+ // _rosterAlias is the value stored in the roster on the XMPP
+ // server. For most servers we will be read/write.
+ _rosterAlias: "",
+ set rosterAlias(aNewAlias) {
+ let old = this.displayName;
+ this._rosterAlias = aNewAlias;
+ if (old != this.displayName) {
+ this._notifyObservers("display-name-changed", old);
+ }
+ },
+ _vCardReceived: false,
+ // _vCardFormattedName is the display name the contact has set for
+ // himself in his vCard. It's read-only from our point of view.
+ _vCardFormattedName: "",
+ set vCardFormattedName(aNewFormattedName) {
+ let old = this.displayName;
+ this._vCardFormattedName = aNewFormattedName;
+ if (old != this.displayName) {
+ this._notifyObservers("display-name-changed", old);
+ }
+ },
+
+ // _serverAlias is set by jsProtoHelper to the value we cached in sqlite.
+ // Use it only if we have neither of the other two values; usually because
+ // we haven't connected to the server yet.
+ get serverAlias() {
+ return this._rosterAlias || this._vCardFormattedName || this._serverAlias;
+ },
+ set serverAlias(aNewAlias) {
+ if (!this._rosterItem) {
+ this.ERROR(
+ "attempting to update the server alias of an account buddy " +
+ "for which we haven't received a roster item."
+ );
+ return;
+ }
+
+ let item = this._rosterItem;
+ if (aNewAlias) {
+ item.attributes.name = aNewAlias;
+ } else if ("name" in item.attributes) {
+ delete item.attributes.name;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster, null, item)
+ );
+ this._account.sendStanza(s);
+
+ // If we are going to change the alias on the server, discard the cached
+ // value that we got from our local sqlite storage at startup.
+ delete this._serverAlias;
+ },
+
+ /* Display name of the buddy */
+ get contactDisplayName() {
+ return this.buddy.contact.displayName || this.displayName;
+ },
+
+ get tag() {
+ return this._tag;
+ },
+ set tag(aNewTag) {
+ let oldTag = this._tag;
+ if (oldTag.name == aNewTag.name) {
+ this.ERROR("attempting to set the tag to the same value");
+ return;
+ }
+
+ this._tag = aNewTag;
+ IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag);
+
+ if (!this._rosterItem) {
+ this.ERROR(
+ "attempting to change the tag of an account buddy without roster item"
+ );
+ return;
+ }
+
+ let item = this._rosterItem;
+ let oldXML = item.getXML();
+ // Remove the old tag if it was listed in the roster item.
+ item.children = item.children.filter(
+ c => c.qName != "group" || c.innerText != oldTag.name
+ );
+ // Ensure the new tag is listed.
+ let newTagName = aNewTag.name;
+ if (!item.getChildren("group").some(g => g.innerText == newTagName)) {
+ item.addChild(Stanza.node("group", null, null, newTagName));
+ }
+ // Avoid sending anything to the server if the roster item hasn't changed.
+ // It's possible that the roster item hasn't changed if the roster
+ // item had several groups and the user moved locally the contact
+ // to another group where it already was on the server.
+ if (item.getXML() == oldXML) {
+ return;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster, null, item)
+ );
+ this._account.sendStanza(s);
+ },
+
+ remove() {
+ if (!this._account.connected) {
+ return;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node(
+ "query",
+ Stanza.NS.roster,
+ null,
+ Stanza.node("item", null, {
+ jid: this.normalizedName,
+ subscription: "remove",
+ })
+ )
+ );
+ this._account.sendStanza(s);
+ },
+
+ _photoHash: null,
+ _saveIcon(aPhotoNode) {
+ this._account._saveResourceIcon(aPhotoNode, this).then(
+ url => {
+ this.buddyIconFilename = url;
+ },
+ error => {
+ this._account.WARN(
+ "Error loading buddy icon for " +
+ this.normalizedName +
+ ": " +
+ error.message
+ );
+ }
+ );
+ },
+
+ _preferredResource: undefined,
+ _resources: null,
+ onAccountDisconnected() {
+ delete this._preferredResource;
+ delete this._resources;
+ },
+ // Called by the account when a presence stanza is received for this buddy.
+ onPresenceStanza(aStanza) {
+ let preferred = this._preferredResource;
+
+ // Facebook chat's XMPP server doesn't send resources, let's
+ // replace undefined resources with empty resources.
+ let resource =
+ this._account._parseJID(aStanza.attributes.from).resource || "";
+
+ let type = aStanza.attributes.type;
+
+ // Reset typing status if the buddy is in a conversation and becomes unavailable.
+ let conv = this._account._conv.get(this.normalizedName);
+ if (type == "unavailable" && conv) {
+ conv.updateTyping(Ci.prplIConvIM.NOT_TYPING, this.contactDisplayName);
+ }
+
+ if (type == "unavailable" || type == "error") {
+ if (!this._resources || !(resource in this._resources)) {
+ // Ignore for already offline resources.
+ return;
+ }
+ delete this._resources[resource];
+ if (preferred == resource) {
+ preferred = undefined;
+ }
+ } else {
+ let statusInfo = parseStatus(aStanza);
+ let priority = aStanza.getElement(["priority"]);
+ priority = priority ? parseInt(priority.innerText, 10) : 0;
+
+ if (!this._resources) {
+ this._resources = {};
+ }
+ this._resources[resource] = {
+ statusType: statusInfo.statusType,
+ statusText: statusInfo.statusText,
+ idleSince: statusInfo.idleSince,
+ priority,
+ stanza: aStanza,
+ };
+ }
+
+ let photo = aStanza.getElement(["x", "photo"]);
+ if (photo && photo.uri == Stanza.NS.vcard_update) {
+ let hash = photo.innerText;
+ if (hash && hash != this._photoHash) {
+ this._account._addVCardRequest(this.normalizedName);
+ } else if (!hash && this._photoHash) {
+ delete this._photoHash;
+ this.buddyIconFilename = "";
+ }
+ }
+
+ for (let r in this._resources) {
+ if (
+ preferred === undefined ||
+ this._resources[r].statusType > this._resources[preferred].statusType
+ ) {
+ // FIXME also compare priorities...
+ preferred = r;
+ }
+ }
+ if (
+ preferred != undefined &&
+ preferred == this._preferredResource &&
+ resource != preferred
+ ) {
+ // The presence information change is only for an unused resource,
+ // only potential buddy tooltips need to be refreshed.
+ this._notifyObservers("status-detail-changed");
+ return;
+ }
+
+ // Presence info has changed enough that if we are having a
+ // conversation with one resource of this buddy, we should send
+ // the next message to all resources.
+ // FIXME: the test here isn't exactly right...
+ if (
+ this._preferredResource != preferred &&
+ this._account._conv.has(this.normalizedName)
+ ) {
+ delete this._account._conv.get(this.normalizedName)._targetResource;
+ }
+
+ this._preferredResource = preferred;
+ if (preferred === undefined) {
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ if (type == "unavailable") {
+ statusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ this.setStatus(statusType, "");
+ } else {
+ preferred = this._resources[preferred];
+ this.setStatus(preferred.statusType, preferred.statusText);
+ }
+ },
+
+ /* Can send messages to buddies who appear offline */
+ get canSendMessage() {
+ return this.account.connected;
+ },
+
+ /* Called when the user wants to chat with the buddy */
+ createConversation() {
+ return this._account.createConversation(this.normalizedName);
+ },
+};
+
+function XMPPAccountBuddy(aAccount, aBuddy, aTag, aUserName) {
+ this._init(aAccount, aBuddy, aTag, aUserName);
+}
+XMPPAccountBuddy.prototype = XMPPAccountBuddyPrototype;
+
+var XMPPRoomInfoPrototype = {
+ __proto__: ClassInfo("prplIRoomInfo", "XMPP RoomInfo Object"),
+ get topic() {
+ return "";
+ },
+ get participantCount() {
+ return Ci.prplIRoomInfo.NO_PARTICIPANT_COUNT;
+ },
+ get chatRoomFieldValues() {
+ let roomJid = this._account._roomList.get(this.name);
+ return this._account.getChatRoomDefaultFieldValues(roomJid);
+ },
+};
+function XMPPRoomInfo(aName, aAccount) {
+ this.name = aName;
+ this._account = aAccount;
+}
+XMPPRoomInfo.prototype = XMPPRoomInfoPrototype;
+
+/* Helper class for account */
+export var XMPPAccountPrototype = {
+ __proto__: GenericAccountPrototype,
+
+ _jid: null, // parsed Jabber ID: node, domain, resource
+ _connection: null, // XMPPSession socket
+ authMechanisms: null, // hook to let prpls tweak the list of auth mechanisms
+
+ // Contains the domain of MUC service which is obtained using service
+ // discovery.
+ _mucService: null,
+
+ // Maps room names to room jid.
+ _roomList: new Map(),
+
+ // Callbacks used when roomInfo is available.
+ _roomInfoCallbacks: new Set(),
+
+ // Determines if roomInfo that we have is expired or not.
+ _lastListTime: 0,
+ get isRoomInfoStale() {
+ return Date.now() - this._lastListTime > kListRefreshInterval;
+ },
+
+ // If true, we are waiting for replies.
+ _pendingList: false,
+
+ // An array of jids for which we still need to request vCards.
+ _pendingVCardRequests: [],
+
+ // XEP-0280: Message Carbons.
+ // If true, message carbons are currently enabled.
+ _isCarbonsEnabled: false,
+
+ /* Generate unique id for a stanza. Using id and unique sid is defined in
+ * RFC 6120 (Section 8.2.3, 4.7.3).
+ */
+ generateId: () => Services.uuid.generateUUID().toString().slice(1, -1),
+
+ _init(aProtoInstance, aImAccount) {
+ GenericAccountPrototype._init.call(this, aProtoInstance, aImAccount);
+
+ // Ongoing conversations.
+ // The keys of this._conv are assumed to be normalized like account@domain
+ // for normal conversations and like room@domain/nick for MUC participant
+ // convs.
+ this._conv = new NormalizedMap(this.normalizeFullJid.bind(this));
+
+ this._buddies = new NormalizedMap(this.normalize.bind(this));
+ this._mucs = new NormalizedMap(this.normalize.bind(this));
+
+ this._pendingVCardRequests = [];
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ room: {
+ get label() {
+ return lazy._("chatRoomField.room");
+ },
+ required: true,
+ },
+ server: {
+ get label() {
+ return lazy._("chatRoomField.server");
+ },
+ required: true,
+ },
+ nick: {
+ get label() {
+ return lazy._("chatRoomField.nick");
+ },
+ required: true,
+ },
+ password: {
+ get label() {
+ return lazy._("chatRoomField.password");
+ },
+ isPassword: true,
+ },
+ },
+ parseDefaultChatName(aDefaultChatName) {
+ if (!aDefaultChatName) {
+ return { nick: this._jid.node };
+ }
+
+ let params = aDefaultChatName.trim().split(/\s+/);
+ let jid = this._parseJID(params[0]);
+
+ // We swap node and domain as domain is required for parseJID, but node and
+ // resource are optional. In MUC join command, Node is required as it
+ // represents a room, but domain and resource are optional as we get muc
+ // domain from service discovery.
+ if (!jid.node && jid.domain) {
+ [jid.node, jid.domain] = [jid.domain, jid.node];
+ }
+
+ let chatFields = {
+ room: jid.node,
+ server: jid.domain || this._mucService,
+ nick: jid.resource || this._jid.node,
+ };
+ if (params.length > 1) {
+ chatFields.password = params[1];
+ }
+ return chatFields;
+ },
+ getChatRoomDefaultFieldValues(aDefaultChatName) {
+ let rv = GenericAccountPrototype.getChatRoomDefaultFieldValues.call(
+ this,
+ aDefaultChatName
+ );
+ if (!rv.values.nick) {
+ rv.values.nick = this._jid.node;
+ }
+ if (!rv.values.server && this._mucService) {
+ rv.values.server = this._mucService;
+ }
+
+ return rv;
+ },
+
+ // XEP-0045: Requests joining room if it exists or
+ // creating room if it does not exist.
+ joinChat(aComponents) {
+ let jid =
+ aComponents.getValue("room") + "@" + aComponents.getValue("server");
+ let nick = aComponents.getValue("nick");
+
+ let muc = this._mucs.get(jid);
+ if (muc) {
+ if (!muc.left) {
+ // We are already in this conversation.
+ return muc;
+ } else if (!muc.chatRoomFields) {
+ // We are rejoining a room that was parted by the user.
+ muc._rejoined = true;
+ }
+ } else {
+ muc = new this._MUCConversationConstructor(this, jid, nick);
+ this._mucs.set(jid, muc);
+ }
+
+ // Store the prplIChatRoomFieldValues to enable later reconnections.
+ muc.chatRoomFields = aComponents;
+ muc.joining = true;
+ muc.removeAllParticipants();
+
+ let password = aComponents.getValue("password");
+ let x = Stanza.node(
+ "x",
+ Stanza.NS.muc,
+ null,
+ password ? Stanza.node("password", null, null, password) : null
+ );
+ let logString;
+ if (password) {
+ logString =
+ "<presence .../> (Stanza containing password to join MUC " +
+ jid +
+ "/" +
+ nick +
+ " not logged)";
+ }
+ this.sendStanza(
+ Stanza.presence({ to: jid + "/" + nick }, x),
+ undefined,
+ undefined,
+ logString
+ );
+ return muc;
+ },
+
+ _idleSince: 0,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "idle-time-changed") {
+ let idleTime = parseInt(aData, 10);
+ if (idleTime) {
+ this._idleSince = Math.floor(Date.now() / 1000) - idleTime;
+ } else {
+ delete this._idleSince;
+ }
+ this._shouldSendPresenceForIdlenessChange = true;
+ executeSoon(
+ function () {
+ if ("_shouldSendPresenceForIdlenessChange" in this) {
+ this._sendPresence();
+ }
+ }.bind(this)
+ );
+ } else if (aTopic == "status-changed") {
+ this._sendPresence();
+ } else if (aTopic == "user-icon-changed") {
+ delete this._cachedUserIcon;
+ this._forceUserIconUpdate = true;
+ this._sendVCard();
+ } else if (aTopic == "user-display-name-changed") {
+ this._forceUserDisplayNameUpdate = true;
+ }
+ this._sendVCard();
+ },
+
+ /* GenericAccountPrototype events */
+ /* Connect to the server */
+ connect() {
+ this._jid = this._parseJID(this.name);
+
+ // For the resource, if the user has edited the option, always use that.
+ if (this.prefs.prefHasUserValue("resource")) {
+ let resource = this.getString("resource");
+
+ // this._jid needs to be updated. This value is however never used
+ // because while connected it's the jid of the session that's
+ // interesting.
+ this._jid = this._setJID(this._jid.domain, this._jid.node, resource);
+ } else if (this._jid.resource) {
+ // If there is a resource in the account name (inherited from libpurple),
+ // migrate it to the pref so it appears correctly in the advanced account
+ // options next time.
+ this.prefs.setStringPref("resource", this._jid.resource);
+ }
+
+ this._connection = new XMPPSession(
+ this.getString("server") || this._jid.domain,
+ this.getInt("port") || 5222,
+ this.getString("connection_security"),
+ this._jid,
+ this.imAccount.password,
+ this
+ );
+ },
+
+ remove() {
+ this._conv.forEach(conv => conv.close());
+ this._mucs.forEach(muc => muc.close());
+ this._buddies.forEach((buddy, jid) => this._forgetRosterItem(jid));
+ },
+
+ unInit() {
+ if (this._connection) {
+ this._disconnect(undefined, undefined, true);
+ }
+ delete this._jid;
+ delete this._conv;
+ delete this._buddies;
+ delete this._mucs;
+ },
+
+ /* Disconnect from the server */
+ disconnect() {
+ this._disconnect();
+ },
+
+ addBuddy(aTag, aName) {
+ if (!this._connection) {
+ throw new Error("The account isn't connected");
+ }
+
+ let jid = this.normalize(aName);
+ if (!jid || !jid.includes("@")) {
+ throw new Error("Invalid username");
+ }
+
+ if (this._buddies.has(jid)) {
+ let subscription = this._buddies.get(jid).subscription;
+ if (subscription && (subscription == "both" || subscription == "to")) {
+ this.DEBUG("not re-adding an existing buddy");
+ return;
+ }
+ } else {
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node(
+ "query",
+ Stanza.NS.roster,
+ null,
+ Stanza.node(
+ "item",
+ null,
+ { jid },
+ Stanza.node("group", null, null, aTag.name)
+ )
+ )
+ );
+ this.sendStanza(
+ s,
+ this._handleResult({
+ default: aError => {
+ this.WARN(
+ "Unable to add a roster item due to " + aError + " error."
+ );
+ },
+ })
+ );
+ }
+ this.sendStanza(Stanza.presence({ to: jid, type: "subscribe" }));
+ },
+
+ /* Loads a buddy from the local storage.
+ * Called for each buddy locally stored before connecting
+ * to the server. */
+ loadBuddy(aBuddy, aTag) {
+ let buddy = new this._accountBuddyConstructor(this, aBuddy, aTag);
+ this._buddies.set(buddy.normalizedName, buddy);
+ return buddy;
+ },
+
+ /* Replies to a buddy request in order to accept it or deny it. */
+ replyToBuddyRequest(aReply, aRequest) {
+ if (!this._connection) {
+ return;
+ }
+ let s = Stanza.presence({ to: aRequest.userName, type: aReply });
+ this.sendStanza(s);
+ this.removeBuddyRequest(aRequest);
+ },
+
+ requestBuddyInfo(aJid) {
+ if (!this.connected) {
+ Services.obs.notifyObservers(EmptyEnumerator, "user-info-received", aJid);
+ return;
+ }
+
+ let userName;
+ let tooltipInfo = [];
+ let jid = this._parseJID(aJid);
+ let muc = this._mucs.get(jid.node + "@" + jid.domain);
+ let participant;
+ if (muc) {
+ participant = muc._participants.get(jid.resource);
+ if (participant) {
+ if (participant.accountJid) {
+ userName = participant.accountJid;
+ }
+ if (!muc.left) {
+ let statusType = participant.statusType;
+ let statusText = participant.statusText;
+ tooltipInfo.push(
+ new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status)
+ );
+
+ if (participant.buddyIconFilename) {
+ tooltipInfo.push(
+ new TooltipInfo(
+ null,
+ participant.buddyIconFilename,
+ Ci.prplITooltipInfo.icon
+ )
+ );
+ }
+ }
+ }
+ }
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ aJid
+ );
+
+ let iq = Stanza.iq(
+ "get",
+ null,
+ aJid,
+ Stanza.node("vCard", Stanza.NS.vcard)
+ );
+ this.sendStanza(iq, aStanza => {
+ let vCardInfo = {};
+ let vCardNode = aStanza.getElement(["vCard"]);
+
+ // In the case of an error response, we just notify the observers with
+ // what info we already have.
+ if (aStanza.attributes.type == "result" && vCardNode) {
+ vCardInfo = this.parseVCard(vCardNode);
+ }
+
+ // The real jid of participant which is of the form local@domain/resource.
+ // We consider the jid is provided by server is more correct than jid is
+ // set by the user.
+ if (userName) {
+ vCardInfo.userName = userName;
+ }
+
+ // vCard fields we want to display in the tooltip.
+ const kTooltipFields = [
+ "userName",
+ "fullName",
+ "nickname",
+ "title",
+ "organization",
+ "email",
+ "birthday",
+ "locality",
+ "country",
+ "telephone",
+ ];
+
+ let tooltipInfo = [];
+ for (let field of kTooltipFields) {
+ if (vCardInfo.hasOwnProperty(field)) {
+ tooltipInfo.push(
+ new TooltipInfo(lazy._("tooltip." + field), vCardInfo[field])
+ );
+ }
+ }
+ if (vCardInfo.photo) {
+ let dataURI = this._getPhotoURI(vCardInfo.photo);
+
+ // Store the photo URI for this participant.
+ if (participant) {
+ participant.buddyIconFilename = dataURI;
+ }
+
+ tooltipInfo.push(
+ new TooltipInfo(null, dataURI, Ci.prplITooltipInfo.icon)
+ );
+ }
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ aJid
+ );
+ });
+ },
+
+ // Parses the photo node of a received vCard if exists and returns string of
+ // data URI, otherwise returns null.
+ _getPhotoURI(aPhotoNode) {
+ if (!aPhotoNode) {
+ return null;
+ }
+
+ let type = aPhotoNode.getElement(["TYPE"]);
+ let value = aPhotoNode.getElement(["BINVAL"]);
+ if (!type || !value) {
+ return null;
+ }
+
+ return "data:" + type.innerText + ";base64," + value.innerText;
+ },
+
+ // Parses the vCard into the properties of the returned object.
+ parseVCard(aVCardNode) {
+ // XEP-0054: vcard-temp.
+ let aResult = {};
+ for (let node of aVCardNode.children.filter(
+ child => child.type == "node"
+ )) {
+ let localName = node.localName;
+ let innerText = node.innerText;
+ if (innerText) {
+ if (localName == "FN") {
+ aResult.fullName = innerText;
+ } else if (localName == "NICKNAME") {
+ aResult.nickname = innerText;
+ } else if (localName == "TITLE") {
+ aResult.title = innerText;
+ } else if (localName == "BDAY") {
+ aResult.birthday = innerText;
+ } else if (localName == "JABBERID") {
+ aResult.userName = innerText;
+ }
+ }
+ if (localName == "ORG") {
+ let organization = node.getElement(["ORGNAME"]);
+ if (organization && organization.innerText) {
+ aResult.organization = organization.innerText;
+ }
+ } else if (localName == "EMAIL") {
+ let userID = node.getElement(["USERID"]);
+ if (userID && userID.innerText) {
+ aResult.email = userID.innerText;
+ }
+ } else if (localName == "ADR") {
+ let locality = node.getElement(["LOCALITY"]);
+ if (locality && locality.innerText) {
+ aResult.locality = locality.innerText;
+ }
+
+ let country = node.getElement(["CTRY"]);
+ if (country && country.innerText) {
+ aResult.country = country.innerText;
+ }
+ } else if (localName == "PHOTO") {
+ aResult.photo = node;
+ } else if (localName == "TEL") {
+ let number = node.getElement(["NUMBER"]);
+ if (number && number.innerText) {
+ aResult.telephone = number.innerText;
+ }
+ }
+ // TODO: Parse the other fields of vCard and display it in system messages
+ // in response to /whois.
+ }
+ return aResult;
+ },
+
+ // Returns undefined if not an error stanza, and an object
+ // describing the error otherwise:
+ parseError(aStanza) {
+ if (aStanza.attributes.type != "error") {
+ return undefined;
+ }
+
+ let retval = { stanza: aStanza };
+ let error = aStanza.getElement(["error"]);
+
+ // RFC 6120 Section 8.3.2: Type must be one of
+ // auth -- retry after providing credentials
+ // cancel -- do not retry (the error cannot be remedied)
+ // continue -- proceed (the condition was only a warning)
+ // modify -- retry after changing the data sent
+ // wait -- retry after waiting (the error is temporary).
+ retval.type = error.attributes.type;
+
+ // RFC 6120 Section 8.3.3.
+ const kDefinedConditions = [
+ "bad-request",
+ "conflict",
+ "feature-not-implemented",
+ "forbidden",
+ "gone",
+ "internal-server-error",
+ "item-not-found",
+ "jid-malformed",
+ "not-acceptable",
+ "not-allowed",
+ "not-authorized",
+ "policy-violation",
+ "recipient-unavailable",
+ "redirect",
+ "registration-required",
+ "remote-server-not-found",
+ "remote-server-timeout",
+ "resource-constraint",
+ "service-unavailable",
+ "subscription-required",
+ "undefined-condition",
+ "unexpected-request",
+ ];
+ let condition = kDefinedConditions.find(c => error.getElement([c]));
+ if (!condition) {
+ // RFC 6120 Section 8.3.2.
+ this.WARN(
+ "Nonstandard or missing defined-condition element in error stanza."
+ );
+ condition = "undefined-condition";
+ }
+ retval.condition = condition;
+
+ let errortext = error.getElement(["text"]);
+ if (errortext) {
+ retval.text = errortext.innerText;
+ }
+
+ return retval;
+ },
+
+ // Returns an error-handling callback for use with sendStanza generated
+ // from aHandlers, an object defining the error handlers.
+ // If the stanza passed to the callback is an error stanza, it checks if
+ // aHandlers contains a property with the name of the defined condition
+ // of the error.
+ // * If the property is a function, it is called with the parsed error
+ // as its argument, bound to aThis (if provided).
+ // It should return true if the error was handled.
+ // * If the property is a string, it is displayed as a system message
+ // in the conversation given by aThis.
+ handleErrors(aHandlers, aThis) {
+ return aStanza => {
+ if (!aHandlers) {
+ return false;
+ }
+
+ let error = this.parseError(aStanza);
+ if (!error) {
+ return false;
+ }
+
+ let toCamelCase = aStr => {
+ // Turn defined condition string into a valid camelcase
+ // JS property name.
+ let capitalize = s => s[0].toUpperCase() + s.slice(1);
+ let uncapitalize = s => s[0].toLowerCase() + s.slice(1);
+ return uncapitalize(aStr.split("-").map(capitalize).join(""));
+ };
+ let condition = toCamelCase(error.condition);
+ // Check if we have a handler property for this kind of error or a
+ // default handler.
+ if (!(condition in aHandlers) && !("default" in aHandlers)) {
+ return false;
+ }
+
+ // Try to get the handler for condition, if we cannot get it, try to get
+ // the default handler.
+ let handler = aHandlers[condition];
+ if (!handler) {
+ handler = aHandlers.default;
+ }
+
+ if (typeof handler == "string") {
+ // The string is an error message to be displayed in the conversation.
+ if (!aThis || !aThis.writeMessage) {
+ this.ERROR(
+ "HandleErrors was passed an error message string, but " +
+ "no conversation to display it in:\n" +
+ handler
+ );
+ return true;
+ }
+ aThis.writeMessage(aThis.name, handler, { system: true, error: true });
+ return true;
+ } else if (typeof handler == "function") {
+ // If we're given a function, call this error handler.
+ return handler.call(aThis, error);
+ }
+
+ // If this happens, there's a bug somewhere.
+ this.ERROR(
+ "HandleErrors was passed a handler for '" +
+ condition +
+ "'' which is neither a function nor a string."
+ );
+ return false;
+ };
+ },
+
+ // Returns a callback suitable for use in sendStanza, to handle type==result
+ // responses. aHandlers and aThis are passed on to handleErrors for error
+ // handling.
+ _handleResult(aHandlers, aThis) {
+ return aStanza => {
+ if (aStanza.attributes.type == "result") {
+ return true;
+ }
+ return this.handleErrors(aHandlers, aThis)(aStanza);
+ };
+ },
+
+ /* XMPPSession events */
+
+ /* Called when the XMPP session is started */
+ onConnection() {
+ // Request the roster. The account will be marked as connected when this is
+ // complete.
+ this.reportConnecting(lazy._("connection.downloadingRoster"));
+ let s = Stanza.iq(
+ "get",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster)
+ );
+ this.sendStanza(s, this.onRoster, this);
+
+ // XEP-0030 and XEP-0045 (6): Service Discovery.
+ // Queries Server for Associated Services.
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._jid.domain,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onServiceDiscovery, this);
+
+ // XEP-0030: Service Discovery Information Features.
+ iq = Stanza.iq(
+ "get",
+ null,
+ this._jid.domain,
+ Stanza.node("query", Stanza.NS.disco_info)
+ );
+ this.sendStanza(iq, this.onServiceDiscoveryInfo, this);
+ },
+
+ /* Called whenever a stanza is received */
+ onXmppStanza(aStanza) {},
+
+ /* Called when a iq stanza is received */
+ onIQStanza(aStanza) {
+ let type = aStanza.attributes.type;
+ if (type == "set") {
+ for (let query of aStanza.getChildren("query")) {
+ if (query.uri != Stanza.NS.roster) {
+ continue;
+ }
+
+ // RFC 6121 2.1.6 (Roster push):
+ // A receiving client MUST ignore the stanza unless it has no 'from'
+ // attribute (i.e., implicitly from the bare JID of the user's
+ // account) or it has a 'from' attribute whose value matches the
+ // user's bare JID <user@domainpart>.
+ let from = aStanza.attributes.from;
+ if (from && from != this._jid.node + "@" + this._jid.domain) {
+ this.WARN("Ignoring potentially spoofed roster push.");
+ return;
+ }
+
+ for (let item of query.getChildren("item")) {
+ this._onRosterItem(item, true);
+ }
+ return;
+ }
+ } else if (type == "get") {
+ let id = aStanza.attributes.id;
+ let from = aStanza.attributes.from;
+
+ // XEP-0199: XMPP server-to-client ping (XEP-0199)
+ let ping = aStanza.getElement(["ping"]);
+ if (ping && ping.uri == Stanza.NS.ping) {
+ this.sendStanza(Stanza.iq("result", id, from));
+ return;
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (query && query.uri == Stanza.NS.version) {
+ // XEP-0092: Software Version.
+ let children = [];
+ children.push(Stanza.node("name", null, null, Services.appinfo.name));
+ children.push(
+ Stanza.node("version", null, null, Services.appinfo.version)
+ );
+ let versionQuery = Stanza.node(
+ "query",
+ Stanza.NS.version,
+ null,
+ children
+ );
+ this.sendStanza(Stanza.iq("result", id, from, versionQuery));
+ return;
+ }
+ if (query && query.uri == Stanza.NS.disco_info) {
+ // XEP-0030: Service Discovery.
+ let children = [];
+ if (aStanza.attributes.node == Stanza.NS.muc_rooms) {
+ // XEP-0045 (6.7): Room query.
+ // TODO: Currently, we return an empty <query/> element, but we
+ // should return non-private rooms.
+ } else {
+ children = SupportedFeatures.map(feature =>
+ Stanza.node("feature", null, { var: feature })
+ );
+ children.unshift(
+ Stanza.node("identity", null, {
+ category: "client",
+ type: "pc",
+ name: Services.appinfo.name,
+ })
+ );
+ }
+ let discoveryQuery = Stanza.node(
+ "query",
+ Stanza.NS.disco_info,
+ null,
+ children
+ );
+ this.sendStanza(Stanza.iq("result", id, from, discoveryQuery));
+ return;
+ }
+ }
+ this.WARN(`Unhandled IQ ${type} stanza.`);
+ if (type == "get" || type == "set") {
+ // RFC 6120 (section 8.2.3): An entity that receives an IQ request of
+ // type "get" or "set" MUST reply with an IQ response of type "result"
+ // or "error".
+ let id = aStanza.attributes.id;
+ let from = aStanza.attributes.from;
+ let condition = Stanza.node("service-unavailable", Stanza.NS.stanzas, {
+ type: "cancel",
+ });
+ let error = Stanza.node("error", null, { type: "cancel" }, condition);
+ this.sendStanza(Stanza.iq("error", id, from, error));
+ }
+ },
+
+ /* Called when a presence stanza is received */
+ onPresenceStanza(aStanza) {
+ let from = aStanza.attributes.from;
+ this.DEBUG("Received presence stanza for " + from);
+
+ let jid = this.normalize(from);
+ let type = aStanza.attributes.type;
+ if (type == "subscribe") {
+ this.addBuddyRequest(
+ jid,
+ this.replyToBuddyRequest.bind(this, "subscribed"),
+ this.replyToBuddyRequest.bind(this, "unsubscribed")
+ );
+ } else if (
+ type == "unsubscribe" ||
+ type == "unsubscribed" ||
+ type == "subscribed"
+ ) {
+ // Nothing useful to do for these presence stanzas, as we will also
+ // receive a roster push containing more or less the same information
+ } else if (this._buddies.has(jid)) {
+ this._buddies.get(jid).onPresenceStanza(aStanza);
+ } else if (this._mucs.has(jid)) {
+ this._mucs.get(jid).onPresenceStanza(aStanza);
+ } else if (jid != this.normalize(this._connection._jid.jid)) {
+ this.WARN("received presence stanza for unknown buddy " + from);
+ } else if (
+ jid == this._jid.node + "@" + this._jid.domain &&
+ this._connection._resource != this._parseJID(from).resource
+ ) {
+ // Ignore presence stanzas for another resource.
+ } else {
+ this.WARN("Unhandled presence stanza.");
+ }
+ },
+
+ // XEP-0030: Discovering services and their features that are supported by
+ // the server.
+ onServiceDiscovery(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_items
+ ) {
+ this.LOG("Could not get services for this server: " + this._jid.domain);
+ return true;
+ }
+
+ // Discovering the Features that are Supported by each service.
+ query.getElements(["item"]).forEach(item => {
+ let jid = item.attributes.jid;
+ if (!jid) {
+ return;
+ }
+ let iq = Stanza.iq(
+ "get",
+ null,
+ jid,
+ Stanza.node("query", Stanza.NS.disco_info)
+ );
+ this.sendStanza(iq, receivedStanza => {
+ let query = receivedStanza.getElement(["query"]);
+ let from = receivedStanza.attributes.from;
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_info
+ ) {
+ this.LOG("Could not get features for this service: " + from);
+ return true;
+ }
+ let features = query
+ .getElements(["feature"])
+ .map(elt => elt.attributes.var);
+ let identity = query.getElement(["identity"]);
+ if (
+ identity &&
+ identity.attributes.category == "conference" &&
+ identity.attributes.type == "text" &&
+ features.includes(Stanza.NS.muc)
+ ) {
+ // XEP-0045 (6.2): this feature is for a MUC Service.
+ // XEP-0045 (15.2): Service Discovery Category/Type.
+ this._mucService = from;
+ }
+ // TODO: Handle other services that are supported by XMPP through
+ // their features.
+
+ return true;
+ });
+ });
+ return true;
+ },
+
+ // XEP-0030: Discovering Service Information and its features that are
+ // supported by the server.
+ onServiceDiscoveryInfo(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_info
+ ) {
+ this.LOG("Could not get features for this server: " + this._jid.domain);
+ return true;
+ }
+
+ let features = query
+ .getElements(["feature"])
+ .map(elt => elt.attributes.var);
+ if (features.includes(Stanza.NS.carbons)) {
+ // XEP-0280: Message Carbons.
+ // Enabling Carbons on server, as it's disabled by default on server.
+ if (Services.prefs.getBoolPref("chat.xmpp.messageCarbons")) {
+ let iqStanza = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("enable", Stanza.NS.carbons)
+ );
+ this.sendStanza(iqStanza, aStanza => {
+ let error = this.parseError(aStanza);
+ if (error) {
+ this.WARN(
+ "Unable to enable message carbons due to " +
+ error.condition +
+ " error."
+ );
+ return true;
+ }
+
+ let type = aStanza.attributes.type;
+ if (type != "result") {
+ this.WARN(
+ "Received unexpected stanza with " +
+ type +
+ " type " +
+ "while enabling message carbons."
+ );
+ return true;
+ }
+
+ this.LOG("Message carbons enabled.");
+ this._isCarbonsEnabled = true;
+ return true;
+ });
+ }
+ }
+ // TODO: Handle other features that are supported by the server.
+ return true;
+ },
+
+ requestRoomInfo(aCallback) {
+ if (this._roomInfoCallbacks.has(aCallback)) {
+ return;
+ }
+
+ if (this.isRoomInfoStale && !this._pendingList) {
+ this._roomList = new Map();
+ this._lastListTime = Date.now();
+ this._roomInfoCallback = aCallback;
+ this._pendingList = true;
+
+ // XEP-0045 (6.3): Discovering Rooms.
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._mucService,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onRoomDiscovery, this);
+ } else {
+ let rooms = [...this._roomList.keys()];
+ aCallback.onRoomInfoAvailable(rooms, !this._pendingList);
+ }
+
+ if (this._pendingList) {
+ this._roomInfoCallbacks.add(aCallback);
+ }
+ },
+
+ onRoomDiscovery(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (!query || query.uri != Stanza.NS.disco_items) {
+ this.LOG("Could not get rooms for this server: " + this._jid.domain);
+ return;
+ }
+
+ // XEP-0059: Result Set Management.
+ let set = query.getElement(["set"]);
+ let last = set ? set.getElement(["last"]) : null;
+ if (last) {
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._mucService,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onRoomDiscovery, this);
+ } else {
+ this._pendingList = false;
+ }
+
+ let rooms = [];
+ query.getElements(["item"]).forEach(item => {
+ let jid = this._parseJID(item.attributes.jid);
+ if (!jid) {
+ return;
+ }
+
+ let name = item.attributes.name;
+ if (!name) {
+ name = jid.node ? jid.node : jid.jid;
+ }
+
+ this._roomList.set(name, jid.jid);
+ rooms.push(name);
+ });
+
+ this._roomInfoCallback.onRoomInfoAvailable(rooms, !this._pendingList);
+ },
+
+ getRoomInfo(aName) {
+ return new XMPPRoomInfo(aName, this);
+ },
+
+ // Returns null if not an invitation stanza, and an object
+ // describing the invitation otherwise.
+ parseInvitation(aStanza) {
+ let x = aStanza.getElement(["x"]);
+ if (!x) {
+ return null;
+ }
+ let retVal = {
+ shouldDecline: false,
+ };
+
+ // XEP-0045. Direct Invitation (7.8.1)
+ // Described in XEP-0249.
+ // jid (chatroom) is required.
+ // Password, reason, continue and thread are optional.
+ if (x.uri == Stanza.NS.conference) {
+ if (!x.attributes.jid) {
+ this.WARN("Received an invitation with missing MUC jid.");
+ return null;
+ }
+ retVal.mucJid = this.normalize(x.attributes.jid);
+ retVal.from = this.normalize(aStanza.attributes.from);
+ retVal.password = x.attributes.password;
+ retVal.reason = x.attributes.reason;
+ retVal.continue = x.attributes.continue;
+ retVal.thread = x.attributes.thread;
+ return retVal;
+ }
+
+ // XEP-0045. Mediated Invitation (7.8.2)
+ // Sent by the chatroom on behalf of someone in the chatroom.
+ // jid (chatroom) and from (inviter) are required.
+ // password and reason are optional.
+ if (x.uri == Stanza.NS.muc_user) {
+ let invite = x.getElement(["invite"]);
+ if (!invite || !invite.attributes.from) {
+ this.WARN("Received an invitation with missing MUC invite or from.");
+ return null;
+ }
+ retVal.mucJid = this.normalize(aStanza.attributes.from);
+ retVal.from = this.normalize(invite.attributes.from);
+ retVal.shouldDecline = true;
+ let continueElement = invite.getElement(["continue"]);
+ retVal.continue = !!continueElement;
+ if (continueElement) {
+ retVal.thread = continueElement.attributes.thread;
+ }
+ if (x.getElement(["password"])) {
+ retVal.password = x.getElement(["password"]).innerText;
+ }
+ if (invite.getElement(["reason"])) {
+ retVal.reason = invite.getElement(["reason"]).innerText;
+ }
+ return retVal;
+ }
+
+ return null;
+ },
+
+ /* Called when a message stanza is received */
+ onMessageStanza(aStanza) {
+ // XEP-0280: Message Carbons.
+ // Sending and Receiving Messages.
+ // Indicates that the forwarded message was sent or received.
+ let isSent = false;
+ let carbonStanza =
+ aStanza.getElement(["sent"]) || aStanza.getElement(["received"]);
+ if (carbonStanza) {
+ if (carbonStanza.uri != Stanza.NS.carbons) {
+ this.WARN(
+ "Received a forwarded message which does not '" +
+ Stanza.NS.carbons +
+ "' namespace."
+ );
+ return;
+ }
+
+ isSent = carbonStanza.localName == "sent";
+ carbonStanza = carbonStanza.getElement(["forwarded", "message"]);
+ if (this._isCarbonsEnabled) {
+ aStanza = carbonStanza;
+ } else {
+ this.WARN(
+ "Received an unexpected forwarded message while message " +
+ "carbons are not enabled."
+ );
+ return;
+ }
+ }
+
+ // For forwarded sent messages, we need to use "to" attribute to
+ // get the right conversation as from in this case is this account.
+ let convJid = isSent ? aStanza.attributes.to : aStanza.attributes.from;
+
+ let normConvJid = this.normalize(convJid);
+ let isMuc = this._mucs.has(normConvJid);
+
+ let type = aStanza.attributes.type;
+ let x = aStanza.getElement(["x"]);
+ let body;
+ let b = aStanza.getElement(["body"]);
+ if (b) {
+ // If there's a <body> child we have more than just typing notifications.
+ // Prefer HTML (in <html><body>) and use plain text (<body>) as fallback.
+ let htmlBody = aStanza.getElement(["html", "body"]);
+ if (htmlBody) {
+ body = htmlBody.innerXML;
+ } else {
+ // Even if the message is in plain text, the prplIMessage
+ // should contain a string that's correctly escaped for
+ // insertion in an HTML document.
+ body = lazy.TXTToHTML(b.innerText);
+ }
+ }
+
+ let subject = aStanza.getElement(["subject"]);
+ // Ignore subject when !isMuc. We're being permissive about subject changes
+ // in the comment below, so we need to be careful about where that makes
+ // sense. Psi+'s OTR plugin includes a subject and body in its message
+ // stanzas.
+ if (subject && isMuc) {
+ // XEP-0045 (7.2.16): Check for a subject element in the stanza and update
+ // the topic if it exists.
+ // We are breaking the spec because only a message that contains a
+ // <subject/> but no <body/> element shall be considered a subject change
+ // for MUC, but we ignore that to be compatible with ejabberd versions
+ // before 15.06.
+ let muc = this._mucs.get(normConvJid);
+ let nick = this._parseJID(convJid).resource;
+ // TODO There can be multiple subject elements with different xml:lang
+ // attributes.
+ muc.setTopic(subject.innerText, nick);
+ return;
+ }
+
+ let invitation = this.parseInvitation(aStanza);
+ if (invitation) {
+ let messageID;
+ if (invitation.reason) {
+ messageID = "conversation.muc.invitationWithReason2";
+ } else {
+ messageID = "conversation.muc.invitationWithoutReason";
+ }
+ if (invitation.password) {
+ messageID += ".password";
+ }
+ let params = [
+ invitation.from,
+ invitation.mucJid,
+ invitation.password,
+ invitation.reason,
+ ].filter(s => s);
+ let message = lazy._(messageID, ...params);
+
+ this.addChatRequest(
+ invitation.mucJid,
+ () => {
+ let chatRoomFields = this.getChatRoomDefaultFieldValues(
+ invitation.mucJid
+ );
+ if (invitation.password) {
+ chatRoomFields.setValue("password", invitation.password);
+ }
+ let muc = this.joinChat(chatRoomFields);
+ muc.writeMessage(muc.name, message, { system: true });
+ },
+ (request, tryToDeny) => {
+ // Only mediated invitations (XEP-0045) can explicitly decline.
+ if (invitation.shouldDecline && tryToDeny) {
+ let decline = Stanza.node(
+ "decline",
+ null,
+ { from: invitation.from },
+ null
+ );
+ let x = Stanza.node("x", Stanza.NS.muc_user, null, decline);
+ let s = Stanza.node("message", null, { to: invitation.mucJid }, x);
+ this.sendStanza(s);
+ }
+ // Always show invite reason or password, even if the invite wasn't
+ // automatically declined based on the setting.
+ if (!request || invitation.reason || invitation.password) {
+ let conv = this.createConversation(invitation.from);
+ if (conv) {
+ conv.writeMessage(invitation.from, message, { system: true });
+ }
+ }
+ }
+ );
+ }
+
+ if (body) {
+ let date = _getDelay(aStanza);
+ if (
+ type == "groupchat" ||
+ (type == "error" && isMuc && !this._conv.has(convJid))
+ ) {
+ if (!isMuc) {
+ this.WARN(
+ "Received a groupchat message for unknown MUC " + normConvJid
+ );
+ return;
+ }
+ let muc = this._mucs.get(normConvJid);
+ muc.incomingMessage(body, aStanza, date);
+ return;
+ }
+
+ let conv = this.createConversation(convJid);
+ if (!conv) {
+ return;
+ }
+
+ if (isSent) {
+ _displaySentMsg(conv, body, date);
+ return;
+ }
+ conv.incomingMessage(body, aStanza, date);
+ } else if (type == "error") {
+ let conv = this.createConversation(convJid);
+ if (conv) {
+ conv.incomingMessage(null, aStanza);
+ }
+ } else if (x && x.uri == Stanza.NS.muc_user) {
+ let muc = this._mucs.get(normConvJid);
+ if (!muc) {
+ this.WARN(
+ "Received a groupchat message for unknown MUC " + normConvJid
+ );
+ return;
+ }
+ muc.onMessageStanza(aStanza);
+ return;
+ }
+
+ // If this is a sent message carbon, the user is typing on another client.
+ if (isSent) {
+ return;
+ }
+
+ // Don't create a conversation to only display the typing notifications.
+ if (!this._conv.has(normConvJid) && !this._conv.has(convJid)) {
+ return;
+ }
+
+ // Ignore errors while delivering typing notifications.
+ if (type == "error") {
+ return;
+ }
+
+ let typingState = Ci.prplIConvIM.NOT_TYPING;
+ let state;
+ let s = aStanza.getChildrenByNS(Stanza.NS.chatstates);
+ if (s.length > 0) {
+ state = s[0].localName;
+ }
+ if (state) {
+ this.DEBUG(state);
+ if (state == "composing") {
+ typingState = Ci.prplIConvIM.TYPING;
+ } else if (state == "paused") {
+ typingState = Ci.prplIConvIM.TYPED;
+ }
+ }
+ let convName = normConvJid;
+
+ // If the bare JID is a MUC that we have joined, use the full JID as this
+ // is a private message to a MUC participant.
+ if (isMuc) {
+ convName = convJid;
+ }
+
+ let conv = this._conv.get(convName);
+ if (!conv) {
+ return;
+ }
+ conv.updateTyping(typingState, conv.shortName);
+ conv.supportChatStateNotifications = !!state;
+ },
+
+ /** Called when there is an error in the XMPP session */
+ onError(aError, aException) {
+ if (aError === null || aError === undefined) {
+ aError = Ci.prplIAccount.ERROR_OTHER_ERROR;
+ }
+ this._disconnect(aError, aException.toString());
+ },
+
+ onVCard(aStanza) {
+ let jid = this._pendingVCardRequests.shift();
+ this._requestNextVCard();
+ if (!this._buddies.has(jid) && !this._mucs.has(jid)) {
+ this.WARN("Received a vCard for unknown buddy " + jid);
+ return;
+ }
+
+ let vCard = aStanza.getElement(["vCard"]);
+ let error = this.parseError(aStanza);
+ if (
+ (error &&
+ (error.condition == "item-not-found" ||
+ error.condition == "service-unavailable")) ||
+ !vCard ||
+ !vCard.children.length
+ ) {
+ this.LOG("No vCard exists (or the user does not exist) for " + jid);
+ return;
+ } else if (error) {
+ this.WARN("Received unexpected vCard error " + error.condition);
+ return;
+ }
+
+ let stanzaJid = this.normalize(aStanza.attributes.from);
+ if (jid && jid != stanzaJid) {
+ this.ERROR(
+ "Received vCard for a different jid (" +
+ stanzaJid +
+ ") " +
+ "than the requested " +
+ jid
+ );
+ }
+
+ let foundFormattedName = false;
+ let vCardInfo = this.parseVCard(vCard);
+ if (this._mucs.has(jid)) {
+ const conv = this._mucs.get(jid);
+ if (vCardInfo.photo) {
+ conv._saveIcon(vCardInfo.photo);
+ }
+ return;
+ }
+ let buddy = this._buddies.get(jid);
+ if (vCardInfo.fullName) {
+ buddy.vCardFormattedName = vCardInfo.fullName;
+ foundFormattedName = true;
+ }
+ if (vCardInfo.photo) {
+ buddy._saveIcon(vCardInfo.photo);
+ }
+ if (!foundFormattedName && buddy._vCardFormattedName) {
+ buddy.vCardFormattedName = "";
+ }
+ buddy._vCardReceived = true;
+ },
+
+ /**
+ * Save the icon for a resource to the local file system.
+ *
+ * @param photo - The vcard photo node representing the icon.
+ * @param {prplIChatBuddy|prplIConversation} resource - Resource the icon is for.
+ * @returns {Promise<string>} Resolves with the file:// URI to the local icon file.
+ */
+ _saveResourceIcon(photo, resource) {
+ // Some servers seem to send a photo node without a type declared.
+ let type = photo.getElement(["TYPE"]);
+ if (!type) {
+ return Promise.reject(new Error("Missing image type"));
+ }
+ type = type.innerText;
+ const kExt = {
+ "image/gif": "gif",
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ };
+ if (!kExt.hasOwnProperty(type)) {
+ return Promise.reject(new Error("Unknown image type"));
+ }
+
+ let content = "",
+ data = "";
+ // Strip all characters not allowed in base64 before parsing.
+ let parseBase64 = aBase => atob(aBase.replace(/[^A-Za-z0-9\+\/\=]/g, ""));
+ for (let line of photo.getElement(["BINVAL"]).innerText.split("\n")) {
+ data += line;
+ // Mozilla's atob() doesn't handle padding with "=" or "=="
+ // unless it's at the end of the string, so we have to work around that.
+ if (line.endsWith("=")) {
+ content += parseBase64(data);
+ data = "";
+ }
+ }
+ content += parseBase64(data);
+
+ // Store a sha1 hash of the photo we have just received.
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ ch.init(ch.SHA1);
+ let dataArray = Object.keys(content).map(i => content.charCodeAt(i));
+ ch.update(dataArray, dataArray.length);
+ let hash = ch.finish(false);
+ function toHexString(charCode) {
+ return charCode.toString(16).padStart(2, "0");
+ }
+ resource._photoHash = Object.keys(hash)
+ .map(i => toHexString(hash.charCodeAt(i)))
+ .join("");
+
+ let istream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ istream.setData(content, content.length);
+
+ let fileName = resource._photoHash + "." + kExt[type];
+ let file = lazy.FileUtils.getFile("ProfD", [
+ "icons",
+ this.protocol.normalizedName,
+ this.normalizedName,
+ fileName,
+ ]);
+ let ostream = lazy.FileUtils.openSafeFileOutputStream(file);
+ return new Promise(resolve => {
+ lazy.NetUtil.asyncCopy(istream, ostream, rc => {
+ if (Components.isSuccessCode(rc)) {
+ resolve(Services.io.newFileURI(file).spec);
+ }
+ });
+ });
+ },
+
+ _requestNextVCard() {
+ if (!this._pendingVCardRequests.length) {
+ return;
+ }
+ let s = Stanza.iq(
+ "get",
+ null,
+ this._pendingVCardRequests[0],
+ Stanza.node("vCard", Stanza.NS.vcard)
+ );
+ this.sendStanza(s, this.onVCard, this);
+ },
+
+ _addVCardRequest(aJID) {
+ let requestPending = !!this._pendingVCardRequests.length;
+ this._pendingVCardRequests.push(aJID);
+ if (!requestPending) {
+ this._requestNextVCard();
+ }
+ },
+
+ // XEP-0029 (Section 2) and RFC 6122 (Section 2): The node and domain are
+ // lowercase, while resources are case sensitive and can contain spaces.
+ normalizeFullJid(aJID) {
+ return this._parseJID(aJID.trim()).jid;
+ },
+
+ // Standard normalization for XMPP removes the resource part of jids.
+ normalize(aJID) {
+ return aJID
+ .trim()
+ .split("/", 1)[0] // up to first slash
+ .toLowerCase();
+ },
+
+ // RFC 6122 (Section 2): [ localpart "@" ] domainpart [ "/" resourcepart ] is
+ // the form of jid.
+ // Localpart is parsed as node and optional.
+ // Domainpart is parsed as domain and required.
+ // resourcepart is parsed as resource and optional.
+ _parseJID(aJid) {
+ let match = /^(?:([^"&'/:<>@]+)@)?([^@/<>'\"]+)(?:\/(.*))?$/.exec(
+ aJid.trim()
+ );
+ if (!match) {
+ return null;
+ }
+
+ let result = {
+ node: match[1],
+ domain: match[2].toLowerCase(),
+ resource: match[3],
+ };
+ return this._setJID(result.domain, result.node, result.resource);
+ },
+
+ // Constructs jid as an object from domain, node and resource parts.
+ // The object has properties (node, domain, resource and jid).
+ // aDomain is required, but aNode and aResource are optional.
+ _setJID(aDomain, aNode = null, aResource = null) {
+ if (!aDomain) {
+ throw new Error("aDomain must have a value");
+ }
+
+ let result = {
+ node: aNode,
+ domain: aDomain.toLowerCase(),
+ resource: aResource,
+ };
+ let jid = result.domain;
+ if (result.node) {
+ result.node = result.node.toLowerCase();
+ jid = result.node + "@" + jid;
+ }
+ if (result.resource) {
+ jid += "/" + result.resource;
+ }
+ result.jid = jid;
+ return result;
+ },
+
+ _onRosterItem(aItem, aNotifyOfUpdates) {
+ let jid = aItem.attributes.jid;
+ if (!jid) {
+ this.WARN("Received a roster item without jid: " + aItem.getXML());
+ return "";
+ }
+ jid = this.normalize(jid);
+
+ let subscription = "";
+ if ("subscription" in aItem.attributes) {
+ subscription = aItem.attributes.subscription;
+ }
+ if (subscription == "remove") {
+ this._forgetRosterItem(jid);
+ return "";
+ }
+
+ let buddy;
+ if (this._buddies.has(jid)) {
+ buddy = this._buddies.get(jid);
+ let groups = aItem.getChildren("group");
+ if (groups.length) {
+ // If the server specified at least one group, ensure the group we use
+ // as the account buddy's tag is still a group on the server...
+ let tagName = buddy.tag.name;
+ if (!groups.some(g => g.innerText == tagName)) {
+ // ... otherwise we need to move our account buddy to a new group.
+ tagName = groups[0].innerText;
+ if (tagName) {
+ // Should always be true, but check just in case...
+ let oldTag = buddy.tag;
+ buddy._tag = IMServices.tags.createTag(tagName);
+ IMServices.contacts.accountBuddyMoved(buddy, oldTag, buddy._tag);
+ }
+ }
+ }
+ } else {
+ let tag;
+ for (let group of aItem.getChildren("group")) {
+ let name = group.innerText;
+ if (name) {
+ tag = IMServices.tags.createTag(name);
+ break; // TODO we should create an accountBuddy per group,
+ // but this._buddies would probably not like that...
+ }
+ }
+ buddy = new this._accountBuddyConstructor(
+ this,
+ null,
+ tag || IMServices.tags.defaultTag,
+ jid
+ );
+ }
+
+ // We request the vCard only if we haven't received it yet and are
+ // subscribed to presence for that contact.
+ if (
+ (subscription == "both" || subscription == "to") &&
+ !buddy._vCardReceived
+ ) {
+ this._addVCardRequest(jid);
+ }
+
+ let alias = "name" in aItem.attributes ? aItem.attributes.name : "";
+ if (alias) {
+ if (aNotifyOfUpdates && this._buddies.has(jid)) {
+ buddy.rosterAlias = alias;
+ } else {
+ buddy._rosterAlias = alias;
+ }
+ } else if (buddy._rosterAlias) {
+ buddy.rosterAlias = "";
+ }
+
+ if (subscription) {
+ buddy.subscription = subscription;
+ }
+ if (!this._buddies.has(jid)) {
+ this._buddies.set(jid, buddy);
+ IMServices.contacts.accountBuddyAdded(buddy);
+ } else if (aNotifyOfUpdates) {
+ buddy._notifyObservers("status-detail-changed");
+ }
+
+ // Keep the xml nodes of the item so that we don't have to
+ // recreate them when changing something (eg. the alias) in it.
+ buddy._rosterItem = aItem;
+
+ return jid;
+ },
+ _forgetRosterItem(aJID) {
+ IMServices.contacts.accountBuddyRemoved(this._buddies.get(aJID));
+ this._buddies.delete(aJID);
+ },
+
+ /* When the roster is received */
+ onRoster(aStanza) {
+ // For the first element that is a roster stanza.
+ for (let qe of aStanza.getChildren("query")) {
+ if (qe.uri != Stanza.NS.roster) {
+ continue;
+ }
+
+ // Find all the roster items in the new message.
+ let newRoster = new Set();
+ for (let item of qe.getChildren("item")) {
+ let jid = this._onRosterItem(item);
+ if (jid) {
+ newRoster.add(jid);
+ }
+ }
+ // If an item was in the old roster, but not in the new, forget it.
+ for (let jid of this._buddies.keys()) {
+ if (!newRoster.has(jid)) {
+ this._forgetRosterItem(jid);
+ }
+ }
+ break;
+ }
+
+ this._sendPresence();
+ this._buddies.forEach(b => {
+ if (b.subscription == "both" || b.subscription == "to") {
+ b.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, "");
+ }
+ });
+ this.reportConnected();
+ this._sendVCard();
+ },
+
+ /* Public methods */
+
+ sendStanza(aStanza, aCallback, aThis, aLogString) {
+ return this._connection.sendStanza(aStanza, aCallback, aThis, aLogString);
+ },
+
+ // Variations of the XMPP protocol can change these default constructors:
+ _conversationConstructor: XMPPConversation,
+ _MUCConversationConstructor: XMPPMUCConversation,
+ _accountBuddyConstructor: XMPPAccountBuddy,
+
+ /* Create a new conversation */
+ createConversation(aName) {
+ let convName = this.normalize(aName);
+
+ // Checks if conversation is with a participant of a MUC we are in. We do
+ // not want to strip the resource as it is of the form room@domain/nick.
+ let isMucParticipant = this._mucs.has(convName);
+ if (isMucParticipant) {
+ convName = this.normalizeFullJid(aName);
+ }
+
+ // Checking that the aName can be parsed and is not broken.
+ let jid = this._parseJID(convName);
+ if (
+ !jid ||
+ !jid.domain ||
+ (isMucParticipant && (!jid.node || !jid.resource))
+ ) {
+ this.ERROR("Could not create conversation as jid is broken: " + convName);
+ throw new Error("Invalid JID");
+ }
+
+ if (!this._conv.has(convName)) {
+ this._conv.set(
+ convName,
+ new this._conversationConstructor(this, convName, isMucParticipant)
+ );
+ }
+
+ return this._conv.get(convName);
+ },
+
+ /* Remove an existing conversation */
+ removeConversation(aNormalizedName) {
+ if (this._conv.has(aNormalizedName)) {
+ this._conv.delete(aNormalizedName);
+ } else if (this._mucs.has(aNormalizedName)) {
+ this._mucs.delete(aNormalizedName);
+ }
+ },
+
+ /* Private methods */
+
+ /**
+ * Disconnect from the server
+ *
+ * @param {number} aError - The error reason, passed to reportDisconnecting.
+ * @param {string} aErrorMessage - The error message, passed to reportDisconnecting.
+ * @param {boolean} aQuiet - True to avoid sending status change notifications
+ * during the uninitialization of the account.
+ */
+ _disconnect(
+ aError = Ci.prplIAccount.NO_ERROR,
+ aErrorMessage = "",
+ aQuiet = false
+ ) {
+ if (!this._connection) {
+ return;
+ }
+
+ this.reportDisconnecting(aError, aErrorMessage);
+
+ this._buddies.forEach(b => {
+ if (!aQuiet) {
+ b.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, "");
+ }
+ b.onAccountDisconnected();
+ });
+
+ this._mucs.forEach(muc => {
+ muc.joining = false; // In case we never finished joining.
+ muc.left = true;
+ });
+
+ this._connection.disconnect();
+ delete this._connection;
+
+ // We won't receive "user-icon-changed" notifications while the
+ // account isn't connected, so clear the cache to avoid keeping an
+ // obsolete icon.
+ delete this._cachedUserIcon;
+ // Also clear the cached user vCard, as we will want to redownload it
+ // after reconnecting.
+ delete this._userVCard;
+
+ // Clear vCard requests.
+ this._pendingVCardRequests = [];
+
+ this.reportDisconnected();
+ },
+
+ /* Set the user status on the server */
+ _sendPresence() {
+ delete this._shouldSendPresenceForIdlenessChange;
+
+ if (!this._connection) {
+ return;
+ }
+
+ let si = this.imAccount.statusInfo;
+ let statusType = si.statusType;
+ let show = "";
+ if (statusType == Ci.imIStatusInfo.STATUS_UNAVAILABLE) {
+ show = "dnd";
+ } else if (
+ statusType == Ci.imIStatusInfo.STATUS_AWAY ||
+ statusType == Ci.imIStatusInfo.STATUS_IDLE
+ ) {
+ show = "away";
+ }
+ let children = [];
+ if (show) {
+ children.push(Stanza.node("show", null, null, show));
+ }
+ let statusText = si.statusText;
+ if (statusText) {
+ children.push(Stanza.node("status", null, null, statusText));
+ }
+ if (this._idleSince) {
+ let time = Math.floor(Date.now() / 1000) - this._idleSince;
+ children.push(Stanza.node("query", Stanza.NS.last, { seconds: time }));
+ }
+ if (this.prefs.prefHasUserValue("priority")) {
+ let priority = Math.max(-128, Math.min(127, this.getInt("priority")));
+ if (priority) {
+ children.push(Stanza.node("priority", null, null, priority.toString()));
+ }
+ }
+ this.sendStanza(
+ Stanza.presence({ "xml:lang": "en" }, children),
+ aStanza => {
+ // As we are implicitly subscribed to our own presence (rfc6121#4), we
+ // will receive the presence stanza mirrored back to us. We don't need
+ // to do anything with this response.
+ return true;
+ }
+ );
+ },
+
+ _downloadingUserVCard: false,
+ _downloadUserVCard() {
+ // If a download is already in progress, don't start another one.
+ if (this._downloadingUserVCard) {
+ return;
+ }
+ this._downloadingUserVCard = true;
+ let s = Stanza.iq("get", null, null, Stanza.node("vCard", Stanza.NS.vcard));
+ this.sendStanza(s, this.onUserVCard, this);
+ },
+
+ onUserVCard(aStanza) {
+ delete this._downloadingUserVCard;
+ let userVCard = aStanza.getElement(["vCard"]) || null;
+ if (userVCard) {
+ // Strip any server-specific namespace off the incoming vcard
+ // before storing it.
+ this._userVCard = Stanza.node(
+ "vCard",
+ Stanza.NS.vcard,
+ null,
+ userVCard.children
+ );
+ }
+
+ // If a user icon exists in the vCard we received from the server,
+ // we need to ensure the line breaks in its binval are exactly the
+ // same as those we would include if we sent the icon, and that
+ // there isn't any other whitespace.
+ if (this._userVCard) {
+ let binval = this._userVCard.getElement(["PHOTO", "BINVAL"]);
+ if (binval && binval.children.length) {
+ binval = binval.children[0];
+ binval.text = binval.text
+ .replace(/[^A-Za-z0-9\+\/\=]/g, "")
+ .replace(/.{74}/g, "$&\n");
+ }
+ } else {
+ // Downloading the vCard failed.
+ if (
+ this.handleErrors({
+ itemNotFound: () => false, // OK, no vCard exists yet.
+ default: () => true,
+ })(aStanza)
+ ) {
+ this.WARN(
+ "Unexpected error retrieving the user's vcard, " +
+ "so we won't attempt to set it either."
+ );
+ return;
+ }
+ // Set this so that we don't get into an infinite loop trying to download
+ // the vcard again. The check in sendVCard is for hasOwnProperty.
+ this._userVCard = null;
+ }
+ this._sendVCard();
+ },
+
+ _cachingUserIcon: false,
+ _cacheUserIcon() {
+ if (this._cachingUserIcon) {
+ return;
+ }
+
+ let userIcon = this.imAccount.statusInfo.getUserIcon();
+ if (!userIcon) {
+ this._cachedUserIcon = null;
+ this._sendVCard();
+ return;
+ }
+
+ this._cachingUserIcon = true;
+ let channel = lazy.NetUtil.newChannel({
+ uri: userIcon,
+ loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ securityFlags:
+ Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE,
+ });
+ lazy.NetUtil.asyncFetch(channel, (inputStream, resultCode) => {
+ if (!Components.isSuccessCode(resultCode)) {
+ return;
+ }
+ try {
+ let type = channel.contentType;
+ let buffer = lazy.NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available()
+ );
+ let readImage = lazy.imgTools.decodeImageFromBuffer(
+ buffer,
+ buffer.length,
+ type
+ );
+ let scaledImage;
+ if (readImage.width <= 96 && readImage.height <= 96) {
+ scaledImage = lazy.imgTools.encodeImage(readImage, type);
+ } else {
+ if (type != "image/jpeg") {
+ type = "image/png";
+ }
+ scaledImage = lazy.imgTools.encodeScaledImage(
+ readImage,
+ type,
+ 64,
+ 64
+ );
+ }
+
+ let bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ bstream.setInputStream(scaledImage);
+
+ let data = bstream.readBytes(bstream.available());
+ this._cachedUserIcon = {
+ type,
+ binval: btoa(data).replace(/.{74}/g, "$&\n"),
+ };
+ } catch (e) {
+ console.error(e);
+ this._cachedUserIcon = null;
+ }
+ delete this._cachingUserIcon;
+ this._sendVCard();
+ });
+ },
+ _sendVCard() {
+ if (!this._connection) {
+ return;
+ }
+
+ // We have to download the user's existing vCard before updating it.
+ // This lets us preserve the fields that we don't change or don't know.
+ // Some servers may reject a new vCard if we don't do this first.
+ if (!this.hasOwnProperty("_userVCard")) {
+ // The download of the vCard is asynchronous and will call _sendVCard back
+ // when the user's vCard has been received.
+ this._downloadUserVCard();
+ return;
+ }
+
+ // Read the local user icon asynchronously from the disk.
+ // _cacheUserIcon will call _sendVCard back once the icon is ready.
+ if (!this.hasOwnProperty("_cachedUserIcon")) {
+ this._cacheUserIcon();
+ return;
+ }
+
+ // If the user currently doesn't have any vCard on the server or
+ // the download failed, an empty new one.
+ if (!this._userVCard) {
+ this._userVCard = Stanza.node("vCard", Stanza.NS.vcard);
+ }
+
+ // Keep a serialized copy of the existing user vCard so that we
+ // can avoid resending identical data to the server.
+ let existingVCard = this._userVCard.getXML();
+
+ let fn = this._userVCard.getElement(["FN"]);
+ let displayName = this.imAccount.statusInfo.displayName;
+ if (displayName) {
+ // If a display name is set locally, update or add an FN field to the vCard.
+ if (!fn) {
+ this._userVCard.addChild(
+ Stanza.node("FN", Stanza.NS.vcard, null, displayName)
+ );
+ } else if (fn.children.length) {
+ fn.children[0].text = displayName;
+ } else {
+ fn.addText(displayName);
+ }
+ } else if ("_forceUserDisplayNameUpdate" in this) {
+ // We remove a display name stored on the server without replacing
+ // it with a new value only if this _sendVCard call is the result of
+ // a user action. This is to avoid removing data from the server each
+ // time the user connects from a new profile.
+ this._userVCard.children = this._userVCard.children.filter(
+ n => n.qName != "FN"
+ );
+ }
+ delete this._forceUserDisplayNameUpdate;
+
+ if (this._cachedUserIcon) {
+ // If we have a local user icon, update or add it in the PHOTO field.
+ let photoChildren = [
+ Stanza.node("TYPE", Stanza.NS.vcard, null, this._cachedUserIcon.type),
+ Stanza.node(
+ "BINVAL",
+ Stanza.NS.vcard,
+ null,
+ this._cachedUserIcon.binval
+ ),
+ ];
+ let photo = this._userVCard.getElement(["PHOTO"]);
+ if (photo) {
+ photo.children = photoChildren;
+ } else {
+ this._userVCard.addChild(
+ Stanza.node("PHOTO", Stanza.NS.vcard, null, photoChildren)
+ );
+ }
+ } else if ("_forceUserIconUpdate" in this) {
+ // Like for the display name, we remove a photo without
+ // replacing it only if the call is caused by a user action.
+ this._userVCard.children = this._userVCard.children.filter(
+ n => n.qName != "PHOTO"
+ );
+ }
+ delete this._forceUserIconUpdate;
+
+ // Send the vCard only if it has really changed.
+ // We handle the result response from the server (it does not require
+ // any further action).
+ if (this._userVCard.getXML() != existingVCard) {
+ this.sendStanza(
+ Stanza.iq("set", null, null, this._userVCard),
+ this._handleResult()
+ );
+ } else {
+ this.LOG(
+ "Not sending the vCard because the server stored vCard is identical."
+ );
+ }
+ },
+};
diff --git a/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs b/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs
new file mode 100644
index 0000000000..fc02f3bc0e
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs
@@ -0,0 +1,347 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+
+// Get conversation object.
+function getConv(aConv) {
+ return aConv.wrappedJSObject;
+}
+
+// Get account object.
+function getAccount(aConv) {
+ return getConv(aConv)._account;
+}
+
+function getMUC(aConv) {
+ let conv = getConv(aConv);
+ if (conv.left) {
+ conv.writeMessage(
+ conv.name,
+ lazy._("conversation.error.commandFailedNotInRoom"),
+ { system: true }
+ );
+ return null;
+ }
+ return conv;
+}
+
+// Trims the string and splits it in two parts on the first space
+// if there is one. Returns the non-empty parts in an array.
+function splitInput(aString) {
+ let params = aString.trim();
+ if (!params) {
+ return [];
+ }
+
+ let splitParams = [];
+ let offset = params.indexOf(" ");
+ if (offset != -1) {
+ splitParams.push(params.slice(0, offset));
+ splitParams.push(params.slice(offset + 1).trimLeft());
+ } else {
+ splitParams.push(params);
+ }
+ return splitParams;
+}
+
+// Trims the string and splits it in two parts (The first part is a nickname
+// and the second part is the rest of string) based on nicknames of current
+// participants. Returns the non-empty parts in an array.
+function splitByNick(aString, aConv) {
+ let params = aString.trim();
+ if (!params) {
+ return [];
+ }
+
+ // Match trimmed-string with the longest prefix of participant's nickname.
+ let nickName = "";
+ for (let participant of aConv._participants.keys()) {
+ if (
+ params.startsWith(participant + " ") &&
+ participant.length > nickName.length
+ ) {
+ nickName = participant;
+ }
+ }
+ if (!nickName) {
+ let offset = params.indexOf(" ");
+ let expectedNickName = offset != -1 ? params.slice(0, offset) : params;
+ aConv.writeMessage(
+ aConv.name,
+ lazy._("conversation.error.nickNotInRoom", expectedNickName),
+ { system: true }
+ );
+ return [];
+ }
+
+ let splitParams = [];
+ splitParams.push(nickName);
+
+ let msg = params.substring(nickName.length);
+ if (msg) {
+ splitParams.push(msg.trimLeft());
+ }
+ return splitParams;
+}
+
+// Splits aMsg in two entries and checks the first entry is a valid jid, then
+// passes it to aConv.invite().
+// Returns false if aMsg is empty, otherwise returns true.
+function invite(aMsg, aConv) {
+ let params = splitInput(aMsg);
+ if (!params.length) {
+ return false;
+ }
+
+ // Check user's jid is valid.
+ let account = getAccount(aConv);
+ let jid = account._parseJID(params[0]);
+ if (!jid) {
+ aConv.writeMessage(
+ aConv.name,
+ lazy._("conversation.error.invalidJID", params[0]),
+ { system: true }
+ );
+ return true;
+ }
+
+ aConv.invite(...params);
+ return true;
+}
+
+export var commands = [
+ {
+ name: "join",
+ get helpString() {
+ return lazy._("command.join3", "join");
+ },
+ run(aMsg, aConv, aReturnedConv) {
+ let account = getAccount(aConv);
+ let params = aMsg.trim();
+ let conv;
+
+ if (!params) {
+ conv = getConv(aConv);
+ if (!conv.isChat) {
+ return false;
+ }
+ if (!conv.left) {
+ return true;
+ }
+
+ // Rejoin the current conversation. If the conversation was explicitly
+ // parted by the user, chatRoomFields will have been deleted.
+ // Otherwise, make use of it.
+ if (conv.chatRoomFields) {
+ account.joinChat(conv.chatRoomFields);
+ return true;
+ }
+
+ params = conv.name;
+ }
+ let chatRoomFields = account.getChatRoomDefaultFieldValues(params);
+ conv = account.joinChat(chatRoomFields);
+
+ if (aReturnedConv) {
+ aReturnedConv.value = conv;
+ }
+ return true;
+ },
+ },
+ {
+ name: "part",
+ get helpString() {
+ return lazy._("command.part2", "part");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let conv = getConv(aConv);
+ if (!conv.left) {
+ conv.part(aMsg);
+ }
+ return true;
+ },
+ },
+ {
+ name: "topic",
+ get helpString() {
+ return lazy._("command.topic", "topic");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let conv = getMUC(aConv);
+ if (!conv) {
+ return true;
+ }
+ conv.topic = aMsg;
+ return true;
+ },
+ },
+ {
+ name: "ban",
+ get helpString() {
+ return lazy._("command.ban", "ban");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let params = splitInput(aMsg);
+ if (!params.length) {
+ return false;
+ }
+
+ let conv = getMUC(aConv);
+ if (conv) {
+ conv.ban(...params);
+ }
+ return true;
+ },
+ },
+ {
+ name: "kick",
+ get helpString() {
+ return lazy._("command.kick", "kick");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let conv = getMUC(aConv);
+ if (!conv) {
+ return true;
+ }
+
+ let params = splitByNick(aMsg, conv);
+ if (!params.length) {
+ return false;
+ }
+ conv.kick(...params);
+ return true;
+ },
+ },
+ {
+ name: "invite",
+ get helpString() {
+ return lazy._("command.invite", "invite");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let conv = getMUC(aConv);
+ if (!conv) {
+ return true;
+ }
+
+ return invite(aMsg, conv);
+ },
+ },
+ {
+ name: "inviteto",
+ get helpString() {
+ return lazy._("command.inviteto", "inviteto");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_IM,
+ run: (aMsg, aConv) => invite(aMsg, getConv(aConv)),
+ },
+ {
+ name: "me",
+ get helpString() {
+ return lazy._("command.me", "me");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let params = aMsg.trim();
+ if (!params) {
+ return false;
+ }
+
+ let conv = getConv(aConv);
+ conv.sendMsg(params, true);
+
+ return true;
+ },
+ },
+ {
+ name: "nick",
+ get helpString() {
+ return lazy._("command.nick", "nick");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv) {
+ let params = aMsg.trim().split(/\s+/);
+ if (!params[0]) {
+ return false;
+ }
+
+ let conv = getMUC(aConv);
+ if (conv) {
+ conv.setNick(params[0]);
+ }
+ return true;
+ },
+ },
+ {
+ name: "msg",
+ get helpString() {
+ return lazy._("command.msg", "msg");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_CHAT,
+ run(aMsg, aConv, aReturnedConv) {
+ let conv = getMUC(aConv);
+ if (!conv) {
+ return true;
+ }
+
+ let params = splitByNick(aMsg, conv);
+ if (params.length != 2) {
+ return false;
+ }
+ let [nickName, msg] = params;
+
+ let account = getAccount(aConv);
+ let privateConv = account.createConversation(conv.name + "/" + nickName);
+ if (!privateConv) {
+ return true;
+ }
+ privateConv.sendMsg(msg.trim());
+
+ if (aReturnedConv) {
+ aReturnedConv.value = privateConv;
+ }
+ return true;
+ },
+ },
+ {
+ name: "version",
+ get helpString() {
+ return lazy._("command.version", "version");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_IM,
+ run(aMsg, aConv, aReturnedConv) {
+ let conv = getConv(aConv);
+ if (conv.left) {
+ return true;
+ }
+
+ // We do not have user's resource.
+ if (!conv._targetResource) {
+ conv.writeMessage(
+ conv.name,
+ lazy._("conversation.error.resourceNotAvailable", conv.shortName),
+ {
+ system: true,
+ }
+ );
+ return true;
+ }
+
+ conv.getVersion();
+ return true;
+ },
+ },
+];
diff --git a/comm/chat/protocols/xmpp/xmpp-session.sys.mjs b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs
new file mode 100644
index 0000000000..ca2fd4eebb
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs
@@ -0,0 +1,764 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { Socket } from "resource:///modules/socket.sys.mjs";
+import { Stanza, XMPPParser } from "resource:///modules/xmpp-xml.sys.mjs";
+import { XMPPAuthMechanisms } from "resource:///modules/xmpp-authmechs.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+
+export function XMPPSession(
+ aHost,
+ aPort,
+ aSecurity,
+ aJID,
+ aPassword,
+ aAccount
+) {
+ this._host = aHost;
+ this._port = aPort;
+
+ this._connectionSecurity = aSecurity;
+ if (this._connectionSecurity == "old_ssl") {
+ this._security = ["ssl"];
+ } else if (this._connectionSecurity != "none") {
+ this._security = [aPort == 5223 || aPort == 443 ? "ssl" : "starttls"];
+ }
+
+ if (!aJID.node) {
+ aAccount.reportDisconnecting(
+ Ci.prplIAccount.ERROR_INVALID_USERNAME,
+ lazy._("connection.error.invalidUsername")
+ );
+ aAccount.reportDisconnected();
+ return;
+ }
+ this._jid = aJID;
+ this._domain = aJID.domain;
+ this._password = aPassword;
+ this._account = aAccount;
+ this._resource = aJID.resource;
+ this._handlers = new Map();
+ this._account.reportConnecting();
+
+ // The User has specified a certain server or port, so we should not do
+ // DNS SRV lookup or the preference of disabling DNS SRV part and use
+ // normal connect is set.
+ // RFC 6120 (Section 3.2.3): When Not to Use SRV.
+ if (
+ Services.prefs.getBoolPref("chat.dns.srv.disable") ||
+ this._account.prefs.prefHasUserValue("server") ||
+ this._account.prefs.prefHasUserValue("port")
+ ) {
+ this.connect(this._host, this._port, this._security);
+ return;
+ }
+
+ // RFC 6120 (Section 3.2.1): SRV lookup.
+ this._account.reportConnecting(lazy._("connection.srvLookup"));
+ DNS.srv("_xmpp-client._tcp." + this._host)
+ .then(aResult => this._handleSrvQuery(aResult))
+ .catch(aError => {
+ if (aError === this.SRV_ERROR_XMPP_NOT_SUPPORTED) {
+ this.LOG("SRV: XMPP is not supported on this domain.");
+
+ // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): Abort as the
+ // service is decidedly not available at this domain.
+ this._account.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("connection.error.XMPPNotSupported")
+ );
+ this._account.reportDisconnected();
+ return;
+ }
+
+ this.ERROR("Error during SRV lookup:", aError);
+
+ // Since we don't receive a response to SRV query, we SHOULD attempt the
+ // fallback process (use normal connect without SRV lookup).
+ this.connect(this._host, this._port, this._security);
+ });
+}
+
+XMPPSession.prototype = {
+ /* for the socket.jsm helper */
+ __proto__: Socket,
+ connectTimeout: 60,
+ readWriteTimeout: 300,
+
+ // Contains the remaining SRV records if we failed to connect the current one.
+ _srvRecords: [],
+
+ sendPing() {
+ this.sendStanza(
+ Stanza.iq("get", null, null, Stanza.node("ping", Stanza.NS.ping)),
+ this.cancelDisconnectTimer,
+ this
+ );
+ },
+ _lastReceiveTime: 0,
+ _lastSendTime: 0,
+ checkPingTimer(aJustSentSomething = false) {
+ // Don't start a ping timer if we're not fully connected yet.
+ if (this.onXmppStanza != this.stanzaListeners.accountListening) {
+ return;
+ }
+ let now = Date.now();
+ if (aJustSentSomething) {
+ this._lastSendTime = now;
+ } else {
+ this._lastReceiveTime = now;
+ }
+ // We only cancel the ping timer if we've both received and sent
+ // something in the last two minutes. This is because Openfire
+ // servers will disconnect us if we don't send anything for a
+ // couple of minutes.
+ if (
+ Math.min(this._lastSendTime, this._lastReceiveTime) >
+ now - this.kTimeBeforePing
+ ) {
+ this.resetPingTimer();
+ }
+ },
+
+ get DEBUG() {
+ return this._account.DEBUG;
+ },
+ get LOG() {
+ return this._account.LOG;
+ },
+ get WARN() {
+ return this._account.WARN;
+ },
+ get ERROR() {
+ return this._account.ERROR;
+ },
+
+ _security: null,
+ _encrypted: false,
+
+ // DNS SRV errors in XMPP.
+ SRV_ERROR_XMPP_NOT_SUPPORTED: -2,
+
+ // Handles result of DNS SRV query and saves sorted results if it's OK in _srvRecords,
+ // otherwise throws error.
+ _handleSrvQuery(aResult) {
+ this.LOG("SRV lookup: " + JSON.stringify(aResult));
+ if (aResult.length == 0) {
+ // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): No SRV records,
+ // try to login with the given domain name.
+ this.connect(this._host, this._port, this._security);
+ return;
+ } else if (aResult.length == 1 && aResult[0].host == ".") {
+ throw this.SRV_ERROR_XMPP_NOT_SUPPORTED;
+ }
+
+ // Sort results: Lower priority is more preferred and higher weight is
+ // more preferred in equal priorities.
+ aResult.sort(function (a, b) {
+ return a.prio - b.prio || b.weight - a.weight;
+ });
+
+ this._srvRecords = aResult;
+ this._connectNextRecord();
+ },
+
+ _connectNextRecord() {
+ if (!this._srvRecords.length) {
+ this.ERROR(
+ "_connectNextRecord is called and there are no more records " +
+ "to connect."
+ );
+ return;
+ }
+
+ let record = this._srvRecords.shift();
+
+ // RFC 3920 (Section 5.1): Certificates MUST be checked against the
+ // hostname as provided by the initiating entity (e.g. user).
+ this.connect(
+ this._domain,
+ this._port,
+ this._security,
+ null,
+ record.host,
+ record.port
+ );
+ },
+
+ /* Disconnect from the server */
+ disconnect() {
+ if (this.onXmppStanza == this.stanzaListeners.accountListening) {
+ this.send("</stream:stream>");
+ }
+ delete this.onXmppStanza;
+ Socket.disconnect.call(this);
+ if (this._parser) {
+ this._parser.destroy();
+ delete this._parser;
+ }
+ this.cancelDisconnectTimer();
+ },
+
+ /* Report errors to the account */
+ onError(aError, aException) {
+ // If we're trying to connect to SRV entries, then keep trying until a
+ // successful connection occurs or we run out of SRV entries to try.
+ if (this._srvRecords.length) {
+ this._connectNextRecord();
+ return;
+ }
+
+ this._account.onError(aError, aException);
+ },
+
+ /* Send a text message to the server */
+ send(aMsg, aLogString) {
+ this.sendString(aMsg, "UTF-8", aLogString);
+ },
+
+ /* Send a stanza to the server.
+ * Can set a callback if required, which will be called when the server
+ * responds to the stanza with a stanza of the same id. The callback should
+ * return true if the stanza was handled, false if not. Note that an
+ * undefined return value is treated as true.
+ */
+ sendStanza(aStanza, aCallback, aThis, aLogString) {
+ if (!aStanza.attributes.hasOwnProperty("id")) {
+ aStanza.attributes.id = this._account.generateId();
+ }
+ if (aCallback) {
+ this._handlers.set(aStanza.attributes.id, aCallback.bind(aThis));
+ }
+ this.send(aStanza.getXML(), aLogString);
+ this.checkPingTimer(true);
+ return aStanza.attributes.id;
+ },
+
+ /* This method handles callbacks for specific ids. */
+ execHandler(aId, aStanza) {
+ let handler = this._handlers.get(aId);
+ if (!handler) {
+ return false;
+ }
+ let isHandled = handler(aStanza);
+ // Treat undefined return values as handled.
+ if (isHandled === undefined) {
+ isHandled = true;
+ }
+ this._handlers.delete(aId);
+ return isHandled;
+ },
+
+ /* Start the XMPP stream */
+ startStream() {
+ if (this._parser) {
+ this._parser.destroy();
+ }
+ this._parser = new XMPPParser(this);
+ this.send(
+ '<?xml version="1.0"?><stream:stream to="' +
+ this._domain +
+ '" xmlns="jabber:client" xmlns:stream="http://etherx.jabber.org/streams" version="1.0">'
+ );
+ },
+
+ startSession() {
+ this.sendStanza(
+ Stanza.iq("set", null, null, Stanza.node("session", Stanza.NS.session)),
+ aStanza => aStanza.attributes.type == "result"
+ );
+ this.onXmppStanza = this.stanzaListeners.sessionStarted;
+ },
+
+ /* XEP-0078: Non-SASL Authentication */
+ startLegacyAuth() {
+ if (!this._encrypted && this._connectionSecurity == "require_tls") {
+ this.onError(
+ Ci.prplIAccount.ERROR_ENCRYPTION_ERROR,
+ lazy._("connection.error.startTLSNotSupported")
+ );
+ return;
+ }
+
+ this.onXmppStanza = this.stanzaListeners.legacyAuth;
+ let s = Stanza.iq(
+ "get",
+ null,
+ this._domain,
+ Stanza.node(
+ "query",
+ Stanza.NS.auth,
+ null,
+ Stanza.node("username", null, null, this._jid.node)
+ )
+ );
+ this.sendStanza(s);
+ },
+
+ // If aResource is null, it will request to bind a server-generated
+ // resourcepart, otherwise request to bind a client-submitted resourcepart.
+ _requestBind(aResource) {
+ let resourceNode = aResource
+ ? Stanza.node("resource", null, null, aResource)
+ : null;
+ this.sendStanza(
+ Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("bind", Stanza.NS.bind, null, resourceNode)
+ )
+ );
+ },
+
+ /* Socket events */
+ /* The connection is established */
+ onConnection() {
+ if (this._security.includes("ssl")) {
+ this.onXmppStanza = this.stanzaListeners.startAuth;
+ this._encrypted = true;
+ } else {
+ this.onXmppStanza = this.stanzaListeners.initStream;
+ }
+
+ // Clear SRV results since we have connected.
+ this._srvRecords = [];
+
+ this._account.reportConnecting(lazy._("connection.initializingStream"));
+ this.startStream();
+ },
+
+ /* When incoming data is available to be parsed */
+ onDataReceived(aData) {
+ this.checkPingTimer();
+ this._lastReceivedData = aData;
+ try {
+ this._parser.onDataAvailable(aData);
+ } catch (e) {
+ console.error(e);
+ this.onXMLError("parser-exception", e);
+ }
+ delete this._lastReceivedData;
+ },
+
+ /* The connection got disconnected without us closing it. */
+ onConnectionClosed() {
+ this._networkError(lazy._("connection.error.serverClosedConnection"));
+ },
+ onConnectionSecurityError(aTLSError, aNSSErrorMessage) {
+ let error = this._account.handleConnectionSecurityError(this);
+ this.onError(error, aNSSErrorMessage);
+ },
+ onConnectionReset() {
+ this._networkError(lazy._("connection.error.resetByPeer"));
+ },
+ onConnectionTimedOut() {
+ this._networkError(lazy._("connection.error.timedOut"));
+ },
+ _networkError(aMessage) {
+ this.onError(Ci.prplIAccount.ERROR_NETWORK_ERROR, aMessage);
+ },
+
+ /* Methods called by the XMPPParser instance */
+ onXMLError(aError, aException) {
+ if (aError == "parsing-characters") {
+ this.WARN(aError + ": " + aException + "\n" + this._lastReceivedData);
+ } else {
+ this.ERROR(aError + ": " + aException + "\n" + this._lastReceivedData);
+ }
+ if (aError != "parse-warning" && aError != "parsing-characters") {
+ this._networkError(lazy._("connection.error.receivedUnexpectedData"));
+ }
+ },
+
+ // All the functions in stanzaListeners are used as onXmppStanza
+ // implementations at various steps of establishing the session.
+ stanzaListeners: {
+ initStream(aStanza) {
+ if (aStanza.localName != "features") {
+ this.ERROR(
+ "Unexpected stanza " + aStanza.localName + ", expected 'features'"
+ );
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ let starttls = aStanza.getElement(["starttls"]);
+ if (starttls && this._security.includes("starttls")) {
+ this._account.reportConnecting(
+ lazy._("connection.initializingEncryption")
+ );
+ this.sendStanza(Stanza.node("starttls", Stanza.NS.tls));
+ this.onXmppStanza = this.stanzaListeners.startTLS;
+ return;
+ }
+ if (starttls && starttls.children.some(c => c.localName == "required")) {
+ this.onError(
+ Ci.prplIAccount.ERROR_ENCRYPTION_ERROR,
+ lazy._("connection.error.startTLSRequired")
+ );
+ return;
+ }
+ if (!starttls && this._connectionSecurity == "require_tls") {
+ this.onError(
+ Ci.prplIAccount.ERROR_ENCRYPTION_ERROR,
+ lazy._("connection.error.startTLSNotSupported")
+ );
+ return;
+ }
+
+ // If we aren't starting TLS, jump to the auth step.
+ this.onXmppStanza = this.stanzaListeners.startAuth;
+ this.onXmppStanza(aStanza);
+ },
+ startTLS(aStanza) {
+ if (aStanza.localName != "proceed") {
+ this._networkError(lazy._("connection.error.failedToStartTLS"));
+ return;
+ }
+
+ this.startTLS();
+ this._encrypted = true;
+ this.startStream();
+ this.onXmppStanza = this.stanzaListeners.startAuth;
+ },
+ startAuth(aStanza) {
+ if (aStanza.localName != "features") {
+ this.ERROR(
+ "Unexpected stanza " + aStanza.localName + ", expected 'features'"
+ );
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ let mechs = aStanza.getElement(["mechanisms"]);
+ if (!mechs) {
+ let auth = aStanza.getElement(["auth"]);
+ if (auth && auth.uri == Stanza.NS.auth_feature) {
+ this.startLegacyAuth();
+ } else {
+ this._networkError(lazy._("connection.error.noAuthMec"));
+ }
+ return;
+ }
+
+ // Select the auth mechanism we will use. PLAIN will be treated
+ // a bit differently as we want to avoid it over an unencrypted
+ // connection, except if the user has explicitly allowed that
+ // behavior.
+ let authMechanisms = this._account.authMechanisms || XMPPAuthMechanisms;
+ let selectedMech = "";
+ let canUsePlain = false;
+ mechs = mechs.getChildren("mechanism");
+ for (let m of mechs) {
+ let mech = m.innerText;
+ if (mech == "PLAIN" && !this._encrypted) {
+ // If PLAIN is proposed over an unencrypted connection,
+ // remember that it's a possibility but don't bother
+ // checking if the user allowed it until we have verified
+ // that nothing more secure is available.
+ canUsePlain = true;
+ } else if (authMechanisms.hasOwnProperty(mech)) {
+ selectedMech = mech;
+ break;
+ }
+ }
+ if (!selectedMech && canUsePlain) {
+ if (this._connectionSecurity == "allow_unencrypted_plain_auth") {
+ selectedMech = "PLAIN";
+ } else {
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.notSendingPasswordInClear")
+ );
+ return;
+ }
+ }
+ if (!selectedMech) {
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.noCompatibleAuthMec")
+ );
+ return;
+ }
+ let authMec = authMechanisms[selectedMech](
+ this._jid.node,
+ this._password,
+ this._domain
+ );
+ this._password = null;
+
+ this._account.reportConnecting(lazy._("connection.authenticating"));
+ this.onXmppStanza = this.stanzaListeners.authDialog.bind(this, authMec);
+ this.onXmppStanza(null); // the first auth step doesn't read anything
+ },
+ authDialog(aAuthMec, aStanza) {
+ if (aStanza && aStanza.localName == "failure") {
+ let errorMsg = "authenticationFailure";
+ if (
+ aStanza.getElement(["not-authorized"]) ||
+ aStanza.getElement(["bad-auth"])
+ ) {
+ errorMsg = "notAuthorized";
+ }
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error." + errorMsg)
+ );
+ return;
+ }
+
+ let result;
+ try {
+ result = aAuthMec.next(aStanza);
+ } catch (e) {
+ this.ERROR("Error in auth mechanism: " + e);
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.authenticationFailure")
+ );
+ return;
+ }
+
+ // The authentication mechanism can yield a promise which must resolve
+ // before sending data. If it rejects, abort.
+ if (result.value) {
+ Promise.resolve(result.value).then(
+ value => {
+ // Send the XML stanza that is returned.
+ if (value.send) {
+ this.send(value.send.getXML(), value.log);
+ }
+ },
+ e => {
+ this.ERROR("Error resolving auth mechanism result: " + e);
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.authenticationFailure")
+ );
+ }
+ );
+ }
+ if (result.done) {
+ this.startStream();
+ this.onXmppStanza = this.stanzaListeners.startBind;
+ }
+ },
+ startBind(aStanza) {
+ if (!aStanza.getElement(["bind"])) {
+ this.ERROR("Unexpected lack of the bind feature");
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ this._account.reportConnecting(lazy._("connection.gettingResource"));
+ this._requestBind(this._resource);
+ this.onXmppStanza = this.stanzaListeners.bindResult;
+ },
+ bindResult(aStanza) {
+ if (aStanza.attributes.type == "error") {
+ let error = this._account.parseError(aStanza);
+ let message;
+ switch (error.condition) {
+ case "resource-constraint":
+ // RFC 6120 (7.6.2.1): Resource Constraint.
+ // The account has reached a limit on the number of simultaneous
+ // connected resources allowed.
+ message = "connection.error.failedMaxResourceLimit";
+ break;
+ case "bad-request":
+ // RFC 6120 (7.7.2.1): Bad Request.
+ // The provided resourcepart cannot be processed by the server.
+ message = "connection.error.failedResourceNotValid";
+ break;
+ case "conflict":
+ // RFC 6120 (7.7.2.2): Conflict.
+ // The provided resourcepart is already in use and the server
+ // disallowed the resource binding attempt.
+ this._requestBind();
+ return;
+ default:
+ this.WARN(`Unhandled bind result error ${error.condition}.`);
+ message = "connection.error.failedToGetAResource";
+ }
+ this._networkError(lazy._(message));
+ return;
+ }
+
+ let jid = aStanza.getElement(["bind", "jid"]);
+ if (!jid) {
+ this._networkError(lazy._("connection.error.failedToGetAResource"));
+ return;
+ }
+ jid = jid.innerText;
+ this.DEBUG("jid = " + jid);
+ this._jid = this._account._parseJID(jid);
+ this._resource = this._jid.resource;
+ this.startSession();
+ },
+ legacyAuth(aStanza) {
+ if (aStanza.attributes.type == "error") {
+ let error = aStanza.getElement(["error"]);
+ if (!error) {
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ let code = parseInt(error.attributes.code, 10);
+ if (code == 401) {
+ // Failed Authentication (Incorrect Credentials)
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.notAuthorized")
+ );
+ return;
+ } else if (code == 406) {
+ // Failed Authentication (Required Information Not Provided)
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED,
+ lazy._("connection.error.authenticationFailure")
+ );
+ return;
+ }
+ // else if (code == 409) {
+ // Failed Authentication (Resource Conflict)
+ // XXX Flo The spec in XEP-0078 defines this error code, but
+ // I've yet to find a server sending it. The server I tested
+ // with just closed the first connection when a second
+ // connection was attempted with the same resource.
+ // libpurple's jabber prpl doesn't support this code either.
+ // }
+ }
+
+ if (aStanza.attributes.type != "result") {
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ if (aStanza.children.length == 0) {
+ // Success!
+ this._password = null;
+ this.startSession();
+ return;
+ }
+
+ let query = aStanza.getElement(["query"]);
+ let values = {};
+ for (let c of query.children) {
+ values[c.qName] = c.innerText;
+ }
+
+ if (!("username" in values) || !("resource" in values)) {
+ this._networkError(lazy._("connection.error.incorrectResponse"));
+ return;
+ }
+
+ // If the resource is empty, we will fallback to brandShortName as
+ // resource is REQUIRED.
+ if (!this._resource) {
+ this._resource = Services.strings
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName");
+ this._jid = this._setJID(
+ this._jid.domain,
+ this._jid.node,
+ this._resource
+ );
+ }
+
+ let children = [
+ Stanza.node("username", null, null, this._jid.node),
+ Stanza.node("resource", null, null, this._resource),
+ ];
+
+ let logString;
+ if ("digest" in values && this._streamId) {
+ let hashBase = this._streamId + this._password;
+
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ ch.init(ch.SHA1);
+ // Non-US-ASCII characters MUST be encoded as UTF-8 since the
+ // SHA-1 hashing algorithm operates on byte arrays.
+ let data = [...new TextEncoder().encode(hashBase)];
+ ch.update(data, data.length);
+ let hash = ch.finish(false);
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+ let digest = Object.keys(hash)
+ .map(i => toHexString(hash.charCodeAt(i)))
+ .join("");
+
+ children.push(Stanza.node("digest", null, null, digest));
+ logString =
+ "legacyAuth stanza containing SHA-1 hash of the password not logged";
+ } else if ("password" in values) {
+ if (
+ !this._encrypted &&
+ this._connectionSecurity != "allow_unencrypted_plain_auth"
+ ) {
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.notSendingPasswordInClear")
+ );
+ return;
+ }
+ children.push(Stanza.node("password", null, null, this._password));
+ logString = "legacyAuth stanza containing password not logged";
+ } else {
+ this.onError(
+ Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE,
+ lazy._("connection.error.noCompatibleAuthMec")
+ );
+ return;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ this._domain,
+ Stanza.node("query", Stanza.NS.auth, null, children)
+ );
+ this.sendStanza(
+ s,
+ undefined,
+ undefined,
+ `<iq type="set".../> (${logString})`
+ );
+ },
+ sessionStarted(aStanza) {
+ this.resetPingTimer();
+ this._account.onConnection();
+ this.LOG("Account successfully connected.");
+ this.onXmppStanza = this.stanzaListeners.accountListening;
+ },
+ accountListening(aStanza) {
+ let id = aStanza.attributes.id;
+ if (id && this.execHandler(id, aStanza)) {
+ return;
+ }
+
+ this._account.onXmppStanza(aStanza);
+ let name = aStanza.qName;
+ if (name == "presence") {
+ this._account.onPresenceStanza(aStanza);
+ } else if (name == "message") {
+ this._account.onMessageStanza(aStanza);
+ } else if (name == "iq") {
+ this._account.onIQStanza(aStanza);
+ }
+ },
+ },
+ onXmppStanza(aStanza) {
+ this.ERROR("should not be reached\n");
+ },
+};
diff --git a/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs b/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs
new file mode 100644
index 0000000000..9d8c4ca523
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs
@@ -0,0 +1,508 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { SAX } from "resource:///modules/sax.sys.mjs";
+
+var NS = {
+ xml: "http://www.w3.org/XML/1998/namespace",
+ xhtml: "http://www.w3.org/1999/xhtml",
+ xhtml_im: "http://jabber.org/protocol/xhtml-im",
+
+ // auth
+ client: "jabber:client",
+ streams: "http://etherx.jabber.org/streams",
+ stream: "urn:ietf:params:xml:ns:xmpp-streams",
+ sasl: "urn:ietf:params:xml:ns:xmpp-sasl",
+ tls: "urn:ietf:params:xml:ns:xmpp-tls",
+ bind: "urn:ietf:params:xml:ns:xmpp-bind",
+ session: "urn:ietf:params:xml:ns:xmpp-session",
+ auth: "jabber:iq:auth",
+ auth_feature: "http://jabber.org/features/iq-auth",
+ http_bind: "http://jabber.org/protocol/httpbind",
+ http_auth: "http://jabber.org/protocol/http-auth",
+ xbosh: "urn:xmpp:xbosh",
+
+ private: "jabber:iq:private",
+ xdata: "jabber:x:data",
+
+ // roster
+ roster: "jabber:iq:roster",
+ roster_versioning: "urn:xmpp:features:rosterver",
+ roster_delimiter: "roster:delimiter",
+
+ // privacy lists
+ privacy: "jabber:iq:privacy",
+
+ // discovering
+ disco_info: "http://jabber.org/protocol/disco#info",
+ disco_items: "http://jabber.org/protocol/disco#items",
+ caps: "http://jabber.org/protocol/caps",
+
+ // addressing
+ address: "http://jabber.org/protocol/address",
+
+ muc_user: "http://jabber.org/protocol/muc#user",
+ muc_owner: "http://jabber.org/protocol/muc#owner",
+ muc_admin: "http://jabber.org/protocol/muc#admin",
+ muc_rooms: "http://jabber.org/protocol/muc#rooms",
+ conference: "jabber:x:conference",
+ muc: "http://jabber.org/protocol/muc",
+ register: "jabber:iq:register",
+ delay: "urn:xmpp:delay",
+ delay_legacy: "jabber:x:delay",
+ bookmarks: "storage:bookmarks",
+ chatstates: "http://jabber.org/protocol/chatstates",
+ event: "jabber:x:event",
+ stanzas: "urn:ietf:params:xml:ns:xmpp-stanzas",
+ vcard: "vcard-temp",
+ vcard_update: "vcard-temp:x:update",
+ ping: "urn:xmpp:ping",
+ carbons: "urn:xmpp:carbons:2",
+
+ geoloc: "http://jabber.org/protocol/geoloc",
+ geoloc_notify: "http://jabber.org/protocol/geoloc+notify",
+ mood: "http://jabber.org/protocol/mood",
+ tune: "http://jabber.org/protocol/tune",
+ nick: "http://jabber.org/protocol/nick",
+ nick_notify: "http://jabber.org/protocol/nick+notify",
+ activity: "http://jabber.org/protocol/activity",
+ rsm: "http://jabber.org/protocol/rsm",
+ last: "jabber:iq:last",
+ version: "jabber:iq:version",
+ avatar_data: "urn:xmpp:avatar:data",
+ avatar_data_notify: "urn:xmpp:avatar:data+notify",
+ avatar_metadata: "urn:xmpp:avatar:metadata",
+ avatar_metadata_notify: "urn:xmpp:avatar:metadata+notify",
+ pubsub: "http://jabber.org/protocol/pubsub",
+ pubsub_event: "http://jabber.org/protocol/pubsub#event",
+};
+
+var TOP_LEVEL_ELEMENTS = {
+ message: "jabber:client",
+ presence: "jabber:client",
+ iq: "jabber:client",
+ "stream:features": "http://etherx.jabber.org/streams",
+ proceed: "urn:ietf:params:xml:ns:xmpp-tls",
+ failure: [
+ "urn:ietf:params:xml:ns:xmpp-tls",
+ "urn:ietf:params:xml:ns:xmpp-sasl",
+ ],
+ success: "urn:ietf:params:xml:ns:xmpp-sasl",
+ challenge: "urn:ietf:params:xml:ns:xmpp-sasl",
+ error: "urn:ietf:params:xml:ns:xmpp-streams",
+};
+
+// Features that we support in XMPP.
+// Don't forget to add your new features here.
+export var SupportedFeatures = [
+ NS.chatstates,
+ NS.conference,
+ NS.disco_info,
+ NS.last,
+ NS.muc,
+ NS.ping,
+ NS.vcard,
+ NS.version,
+];
+
+/* Stanza Builder */
+export var Stanza = {
+ NS,
+
+ /* Create a presence stanza */
+ presence: (aAttr, aData) => Stanza.node("presence", null, aAttr, aData),
+
+ /* Create a message stanza */
+ message(aTo, aMsg, aState, aAttr = {}, aData = []) {
+ aAttr.to = aTo;
+ if (!("type" in aAttr)) {
+ aAttr.type = "chat";
+ }
+
+ if (aMsg) {
+ aData.push(Stanza.node("body", null, null, aMsg));
+ }
+
+ if (aState) {
+ aData.push(Stanza.node(aState, Stanza.NS.chatstates));
+ }
+
+ return Stanza.node("message", null, aAttr, aData);
+ },
+
+ /* Create a iq stanza */
+ iq(aType, aId, aTo, aData) {
+ let attrs = { type: aType };
+ if (aId) {
+ attrs.id = aId;
+ }
+ if (aTo) {
+ attrs.to = aTo;
+ }
+ return this.node("iq", null, attrs, aData);
+ },
+
+ /* Create a XML node */
+ node(aName, aNs, aAttr, aData) {
+ let node = new XMLNode(null, aNs, aName, aName, aAttr);
+ if (aData) {
+ if (!Array.isArray(aData)) {
+ aData = [aData];
+ }
+ for (let child of aData) {
+ node[typeof child == "string" ? "addText" : "addChild"](child);
+ }
+ }
+
+ return node;
+ },
+};
+
+/* Text node
+ * Contains a text */
+function TextNode(aText) {
+ this.text = aText;
+}
+TextNode.prototype = {
+ get type() {
+ return "text";
+ },
+
+ append(aText) {
+ this.text += aText;
+ },
+
+ /* For debug purposes, returns an indented (unencoded) string */
+ convertToString(aIndent) {
+ return aIndent + this.text + "\n";
+ },
+
+ /* Returns the encoded XML */
+ getXML() {
+ return Cc["@mozilla.org/txttohtmlconv;1"]
+ .getService(Ci.mozITXTToHTMLConv)
+ .scanTXT(this.text, Ci.mozITXTToHTMLConv.kEntities);
+ },
+
+ /* To read the unencoded data. */
+ get innerText() {
+ return this.text;
+ },
+};
+
+/* XML node */
+/* https://www.w3.org/TR/2008/REC-xml-20081126 */
+/* aUri is the namespace. */
+/* aLocalName must have value, otherwise throws. */
+/* aAttr is an object */
+/* Example: <f:a xmlns:f='g' d='1'> is parsed to
+ uri/namespace='g', localName='a', qName='f:a', attributes={d='1'} */
+function XMLNode(
+ aParentNode,
+ aUri,
+ aLocalName,
+ aQName = aLocalName,
+ aAttr = {}
+) {
+ if (!aLocalName) {
+ throw new Error("aLocalName must have value");
+ }
+
+ this._parentNode = aParentNode; // Used only for parsing
+ this.uri = aUri;
+ this.localName = aLocalName;
+ this.qName = aQName;
+ this.attributes = {};
+ this.children = [];
+
+ for (let attributeName in aAttr) {
+ // Each attribute specification has a name and a value.
+ if (aAttr[attributeName]) {
+ this.attributes[attributeName] = aAttr[attributeName];
+ }
+ }
+}
+XMLNode.prototype = {
+ get type() {
+ return "node";
+ },
+
+ /* Add a new child node */
+ addChild(aNode) {
+ this.children.push(aNode);
+ },
+
+ /* Add text node */
+ addText(aText) {
+ let lastIndex = this.children.length - 1;
+ if (lastIndex >= 0 && this.children[lastIndex] instanceof TextNode) {
+ this.children[lastIndex].append(aText);
+ } else {
+ this.children.push(new TextNode(aText));
+ }
+ },
+
+ /* Get child elements by namespace */
+ getChildrenByNS(aNS) {
+ return this.children.filter(c => c.uri == aNS);
+ },
+
+ /* Get the first element anywhere inside the node (including child nodes)
+ that matches the query.
+ A query consists of an array of localNames. */
+ getElement(aQuery) {
+ if (aQuery.length == 0) {
+ return this;
+ }
+
+ let nq = aQuery.slice(1);
+ for (let child of this.children) {
+ if (child.type == "text" || child.localName != aQuery[0]) {
+ continue;
+ }
+ let n = child.getElement(nq);
+ if (n) {
+ return n;
+ }
+ }
+
+ return null;
+ },
+
+ /* Get all elements of the node (including child nodes) that match the query.
+ A query consists of an array of localNames. */
+ getElements(aQuery) {
+ if (aQuery.length == 0) {
+ return [this];
+ }
+
+ let c = this.getChildren(aQuery[0]);
+ let nq = aQuery.slice(1);
+ let res = [];
+ for (let child of c) {
+ let n = child.getElements(nq);
+ res = res.concat(n);
+ }
+
+ return res;
+ },
+
+ /* Get immediate children by the node name */
+ getChildren(aName) {
+ return this.children.filter(c => c.type != "text" && c.localName == aName);
+ },
+
+ // Test if the node is a stanza and its namespace is valid.
+ isXmppStanza() {
+ if (!TOP_LEVEL_ELEMENTS.hasOwnProperty(this.qName)) {
+ return false;
+ }
+ let ns = TOP_LEVEL_ELEMENTS[this.qName];
+ return ns == this.uri || (Array.isArray(ns) && ns.includes(this.uri));
+ },
+
+ /* Returns indented XML */
+ convertToString(aIndent = "") {
+ let s =
+ aIndent + "<" + this.qName + this._getXmlns() + this._getAttributeText();
+ let content = "";
+ for (let child of this.children) {
+ content += child.convertToString(aIndent + " ");
+ }
+ return (
+ s +
+ (content ? ">\n" + content + aIndent + "</" + this.qName : "/") +
+ ">\n"
+ );
+ },
+
+ /* Returns the XML */
+ getXML() {
+ let s = "<" + this.qName + this._getXmlns() + this._getAttributeText();
+ let innerXML = this.innerXML;
+ return s + (innerXML ? ">" + innerXML + "</" + this.qName : "/") + ">";
+ },
+
+ get innerXML() {
+ return this.children.map(c => c.getXML()).join("");
+ },
+ get innerText() {
+ return this.children.map(c => c.innerText).join("");
+ },
+
+ /* Private methods */
+ _getXmlns() {
+ return this.uri ? ' xmlns="' + this.uri + '"' : "";
+ },
+ _getAttributeText() {
+ let s = "";
+ for (let name in this.attributes) {
+ s += " " + name + '="' + this.attributes[name] + '"';
+ }
+ return s;
+ },
+};
+
+export function XMPPParser(aListener) {
+ this._listener = aListener;
+
+ // We only get tagName from onclosetag callback, but we need more, so save the
+ // opening tags.
+ let tagStack = [];
+ this._parser = SAX.parser(true, { xmlns: true, lowercase: true });
+ this._parser.onopentag = node => {
+ if (this._parser.error) {
+ // sax-js doesn't stop on error, but we want to.
+ return;
+ }
+ let attrs = {};
+ for (let [name, attr] of Object.entries(node.attributes)) {
+ if (name == "xmlns") {
+ continue;
+ }
+ attrs[name] = attr.value;
+ }
+ this.startElement(node.uri, node.local, node.name, attrs);
+ tagStack.push(node);
+ };
+ this._parser.onclosetag = tagName => {
+ if (this._parser.error) {
+ return;
+ }
+ let node = tagStack.pop();
+ if (tagName == node.name) {
+ this.endElement(node.uri, node.local, node.name);
+ } else {
+ this.error(`Unexpected </${tagName}>, expecting </${node.name}>`);
+ }
+ };
+ this._parser.ontext = t => {
+ if (this._parser.error) {
+ return;
+ }
+ this.characters(t);
+ };
+ this._parser.onerror = this.error;
+}
+
+XMPPParser.prototype = {
+ _decoder: new TextDecoder(),
+ _destroyPending: false,
+ destroy() {
+ delete this._listener;
+
+ try {
+ this._parser.close();
+ } catch (e) {}
+ delete this._parser;
+ },
+
+ _logReceivedData(aData) {
+ this._listener.LOG("received:\n" + aData);
+ },
+ /**
+ * Decodes the byte string to UTF-8 (via byte array) before feeding it to the
+ * SAXML parser.
+ *
+ * @param {string} data - Raw XML byte string.
+ */
+ onDataAvailable(data) {
+ let bytes = new Uint8Array(data.length);
+ for (let i = 0; i < data.length; i++) {
+ bytes[i] = data.charCodeAt(i);
+ }
+ let utf8Data = this._decoder.decode(bytes);
+ this._parser.write(utf8Data);
+ },
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ if (aQName == "stream:stream") {
+ let node = new XMLNode(null, aUri, aLocalName, aQName, aAttributes);
+ // The node we created doesn't have children, but
+ // <stream:stream> isn't closed, so avoid displaying /> at the end.
+ this._logReceivedData(node.convertToString().slice(0, -3) + ">\n");
+
+ if ("_node" in this) {
+ this._listener.onXMLError(
+ "unexpected-stream-start",
+ "stream:stream inside an already started stream"
+ );
+ return;
+ }
+
+ this._listener._streamId = node.attributes.id;
+ if (!("version" in node.attributes)) {
+ this._listener.startLegacyAuth();
+ }
+
+ this._node = null;
+ return;
+ }
+
+ let node = new XMLNode(this._node, aUri, aLocalName, aQName, aAttributes);
+ if (this._node) {
+ this._node.addChild(node);
+ }
+
+ this._node = node;
+ },
+
+ characters(aCharacters) {
+ if (!this._node) {
+ // Ignore whitespace received on the stream to keep the connection alive.
+ if (aCharacters.trim()) {
+ this._listener.onXMLError(
+ "parsing-characters",
+ "No parent for characters: " + aCharacters
+ );
+ }
+ return;
+ }
+
+ this._node.addText(aCharacters);
+ },
+
+ endElement(aUri, aLocalName, aQName) {
+ if (aQName == "stream:stream") {
+ this._logReceivedData("</stream:stream>");
+ delete this._node;
+ return;
+ }
+
+ if (!this._node) {
+ this._listener.onXMLError(
+ "parsing-node",
+ "No parent for node : " + aLocalName
+ );
+ return;
+ }
+
+ // RFC 6120 (8): XML Stanzas.
+ // Checks if the node is the root and it's valid.
+ if (!this._node._parentNode) {
+ if (this._node.isXmppStanza()) {
+ this._logReceivedData(this._node.convertToString());
+ try {
+ this._listener.onXmppStanza(this._node);
+ } catch (e) {
+ console.error(e);
+ dump(e + "\n");
+ }
+ } else {
+ this._listener.onXMLError(
+ "parsing-node",
+ "Root node " + aLocalName + " is not valid."
+ );
+ }
+ }
+
+ this._node = this._node._parentNode;
+ },
+
+ error(aError) {
+ if (this._listener) {
+ this._listener.onXMLError("parse-error", aError);
+ }
+ },
+};
diff --git a/comm/chat/protocols/xmpp/xmpp.sys.mjs b/comm/chat/protocols/xmpp/xmpp.sys.mjs
new file mode 100644
index 0000000000..08fdcd5629
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp.sys.mjs
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ XMPPAccountPrototype: "resource:///modules/xmpp-base.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "XMPPAccount", () => {
+ function XMPPAccount(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+ }
+ XMPPAccount.prototype = lazy.XMPPAccountPrototype;
+ return XMPPAccount;
+});
+
+export function XMPPProtocol() {
+ this.commands = ChromeUtils.importESModule(
+ "resource:///modules/xmpp-commands.sys.mjs"
+ ).commands;
+ this.registerCommands();
+}
+
+XMPPProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get normalizedName() {
+ return "jabber";
+ },
+ get name() {
+ return "XMPP";
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-jabber/skin/";
+ },
+ getAccount(aImAccount) {
+ return new lazy.XMPPAccount(this, aImAccount);
+ },
+
+ usernameSplits: [
+ {
+ get label() {
+ return lazy._("options.domain");
+ },
+ separator: "@",
+ defaultValue: "jabber.org",
+ },
+ ],
+
+ options: {
+ resource: {
+ get label() {
+ return lazy._("options.resource");
+ },
+ default: "",
+ },
+ priority: {
+ get label() {
+ return lazy._("options.priority");
+ },
+ default: 0,
+ },
+ connection_security: {
+ get label() {
+ return lazy._("options.connectionSecurity");
+ },
+ listValues: {
+ get require_tls() {
+ return lazy._("options.connectionSecurity.requireEncryption");
+ },
+ get opportunistic_tls() {
+ return lazy._("options.connectionSecurity.opportunisticTLS");
+ },
+ get allow_unencrypted_plain_auth() {
+ return lazy._("options.connectionSecurity.allowUnencryptedAuth");
+ },
+ // "old_ssl" and "none" are also supported, but not exposed in the UI.
+ // Any unknown value will fallback to the opportunistic_tls behavior.
+ },
+ default: "require_tls",
+ },
+ server: {
+ get label() {
+ return lazy._("options.connectServer");
+ },
+ default: "",
+ },
+ port: {
+ get label() {
+ return lazy._("options.connectPort");
+ },
+ default: 5222,
+ },
+ },
+ get chatHasTopic() {
+ return true;
+ },
+};
diff --git a/comm/chat/protocols/yahoo/components.conf b/comm/chat/protocols/yahoo/components.conf
new file mode 100644
index 0000000000..5ca1d8ad10
--- /dev/null
+++ b/comm/chat/protocols/yahoo/components.conf
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{50ea817e-5d79-4657-91ae-aa0a52bdb98c}',
+ 'contract_ids': ['@mozilla.org/chat/yahoo;1'],
+ 'esModule': 'resource:///modules/yahoo.sys.mjs',
+ 'constructor': 'YahooProtocol',
+ 'categories': {'im-protocol-plugin': 'prpl-yahoo'},
+ },
+]
diff --git a/comm/chat/protocols/yahoo/icons/prpl-yahoo-32.png b/comm/chat/protocols/yahoo/icons/prpl-yahoo-32.png
new file mode 100644
index 0000000000..aefe383c32
--- /dev/null
+++ b/comm/chat/protocols/yahoo/icons/prpl-yahoo-32.png
Binary files differ
diff --git a/comm/chat/protocols/yahoo/icons/prpl-yahoo-48.png b/comm/chat/protocols/yahoo/icons/prpl-yahoo-48.png
new file mode 100644
index 0000000000..f454f26d3c
--- /dev/null
+++ b/comm/chat/protocols/yahoo/icons/prpl-yahoo-48.png
Binary files differ
diff --git a/comm/chat/protocols/yahoo/icons/prpl-yahoo.png b/comm/chat/protocols/yahoo/icons/prpl-yahoo.png
new file mode 100644
index 0000000000..4cff5da7fc
--- /dev/null
+++ b/comm/chat/protocols/yahoo/icons/prpl-yahoo.png
Binary files differ
diff --git a/comm/chat/protocols/yahoo/jar.mn b/comm/chat/protocols/yahoo/jar.mn
new file mode 100644
index 0000000000..31d6e9cf0b
--- /dev/null
+++ b/comm/chat/protocols/yahoo/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin prpl-yahoo classic/1.0 %skin/classic/prpl/yahoo/
+ skin/classic/prpl/yahoo/icon32.png (icons/prpl-yahoo-32.png)
+ skin/classic/prpl/yahoo/icon48.png (icons/prpl-yahoo-48.png)
+ skin/classic/prpl/yahoo/icon.png (icons/prpl-yahoo.png)
diff --git a/comm/chat/protocols/yahoo/moz.build b/comm/chat/protocols/yahoo/moz.build
new file mode 100644
index 0000000000..b2e5ab78d6
--- /dev/null
+++ b/comm/chat/protocols/yahoo/moz.build
@@ -0,0 +1,14 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "yahoo.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/protocols/yahoo/yahoo.sys.mjs b/comm/chat/protocols/yahoo/yahoo.sys.mjs
new file mode 100644
index 0000000000..c44d27e8e5
--- /dev/null
+++ b/comm/chat/protocols/yahoo/yahoo.sys.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericProtocolPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/yahoo.properties")
+);
+
+function YahooAccount(aProtoInstance, aImAccount) {
+ this._init(aProtoInstance, aImAccount);
+}
+YahooAccount.prototype = {
+ __proto__: GenericAccountPrototype,
+
+ connect() {
+ this.WARN(
+ "The legacy versions of Yahoo Messenger was disabled on August " +
+ "5, 2016. It is currently not possible to connect to Yahoo " +
+ "Messenger. See bug 1316000."
+ );
+ this.reportDisconnecting(
+ Ci.prplIAccount.ERROR_OTHER_ERROR,
+ lazy._("yahoo.disabled")
+ );
+ this.reportDisconnected();
+ },
+
+ // Nothing to do.
+ unInit() {},
+ remove() {},
+};
+
+export function YahooProtocol() {}
+YahooProtocol.prototype = {
+ __proto__: GenericProtocolPrototype,
+ get id() {
+ return "prpl-yahoo";
+ },
+ get normalizedName() {
+ return "yahoo";
+ },
+ get name() {
+ return "Yahoo";
+ },
+ get iconBaseURI() {
+ return "chrome://prpl-yahoo/skin/";
+ },
+ getAccount(aImAccount) {
+ return new YahooAccount(this, aImAccount);
+ },
+};
diff --git a/comm/chat/themes/chat-left.svg b/comm/chat/themes/chat-left.svg
new file mode 100644
index 0000000000..60222c3f9a
--- /dev/null
+++ b/comm/chat/themes/chat-left.svg
@@ -0,0 +1,31 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16" width="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-opacity="0"/>
+ <stop offset=".5"/>
+ <stop offset="1" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="b">
+ <stop offset="0" stop-color="#fff"/>
+ <stop offset="1" stop-color="#efefef"/>
+ </linearGradient>
+ <linearGradient id="c">
+ <stop offset="0" stop-color="#a3a3a3"/>
+ <stop offset="1" stop-color="#f6f6f6"/>
+ </linearGradient>
+ <linearGradient id="d" xlink:href="#a" gradientUnits="userSpaceOnUse" y2="9" x2="1" y1="13" x1="1"/>
+ <linearGradient id="e" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="13" x2="11" y1="7" x1="11"/>
+ <linearGradient id="f" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="11" x2="5" y1="4" x1="5"/>
+ <linearGradient id="g" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="7" x2="9" y1="1" x1="9"/>
+ <linearGradient id="h" xlink:href="#c" gradientUnits="userSpaceOnUse" y2="8" x2="8" y1="14" x1="14"/>
+ </defs>
+ <path fill="url(#d)" opacity=".13" d="M1 9h5v4H1z"/>
+ <path fill="url(#e)" stroke="#787878" d="M15 6.5c.29 0 .5.22.5.5v6c0 .28-.21.5-.5.5h-.5V15l-2-1.5H7c-.3 0-.5-.22-.5-.5V7c0-.28.2-.5.5-.5z"/>
+ <path fill="url(#f)" stroke="#787878" d="M1 3.5c-.3 0-.5.22-.5.5v6c0 .28.2.5.5.5h.5V12l2-1.5H9c.3 0 .5-.22.5-.5V4c0-.28-.2-.5-.5-.5z"/>
+ <path fill="url(#g)" stroke="#787878" d="M13 .5c.3 0 .5.22.5.5v6c0 .28-.2.5-.5.5h-.5V9l-2-1.5H5c-.3 0-.5-.22-.5-.5V1c0-.28.2-.5.5-.5z"/>
+ <path fill="#787878" d="M16 11a5 5 0 11-10 0 5 5 0 1110 0z"/>
+ <path fill="url(#h)" d="M15.38 11a4.38 4.38 0 11-8.76 0 4.38 4.38 0 118.76 0z"/>
+</svg>
diff --git a/comm/chat/themes/chat.svg b/comm/chat/themes/chat.svg
new file mode 100644
index 0000000000..364bb4c252
--- /dev/null
+++ b/comm/chat/themes/chat.svg
@@ -0,0 +1,32 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16" width="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-opacity="0"/>
+ <stop offset=".5"/>
+ <stop offset="1" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="b">
+ <stop offset="0"/>
+ <stop offset="1" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="c">
+ <stop offset="0" stop-color="#fff"/>
+ <stop offset="1" stop-color="#efefef"/>
+ </linearGradient>
+ <linearGradient id="d" xlink:href="#a" gradientUnits="userSpaceOnUse" y2="12" x2="8" y1="16" x1="8"/>
+ <linearGradient id="e" xlink:href="#a" gradientUnits="userSpaceOnUse" y2="9" x2="1" y1="13" x1="1"/>
+ <linearGradient id="f" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="15" x2="7" y1="14" x1="8"/>
+ <linearGradient id="g" xlink:href="#c" gradientUnits="userSpaceOnUse" y2="14" x2="11" y1="7" x1="11"/>
+ <linearGradient id="h" xlink:href="#c" gradientUnits="userSpaceOnUse" y2="11" x2="5" y1="4" x1="5"/>
+ <linearGradient id="i" xlink:href="#c" gradientUnits="userSpaceOnUse" y2="8" x2="9" y1="1" x1="9"/>
+ </defs>
+ <path fill="url(#d)" opacity=".13" d="M8 12h7v4H8z"/>
+ <path fill="url(#e)" opacity=".13" d="M1 9h5v4H1z"/>
+ <path fill="url(#f)" opacity=".13" d="M8 16H6v-3h2z"/>
+ <path fill="url(#g)" stroke="#787878" d="M15 6.5c.29 0 .5.22.5.5v6c0 .28-.21.5-.5.5h-.5V15l-2-1.5H7c-.3 0-.5-.22-.5-.5V7c0-.28.2-.5.5-.5z"/>
+ <path fill="url(#h)" stroke="#787878" d="M1 3.5c-.3 0-.5.22-.5.5v6c0 .28.2.5.5.5h.5V12l2-1.5H9c.3 0 .5-.22.5-.5V4c0-.28-.2-.5-.5-.5z"/>
+ <path fill="url(#i)" stroke="#787878" d="M13 .5c.3 0 .5.22.5.5v6c0 .28-.2.5-.5.5h-.5V9l-2-1.5H5c-.3 0-.5-.22-.5-.5V1c0-.28.2-.5.5-.5z"/>
+</svg>
diff --git a/comm/chat/themes/conv.css b/comm/chat/themes/conv.css
new file mode 100644
index 0000000000..9b2aad9961
--- /dev/null
+++ b/comm/chat/themes/conv.css
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.ib-msg-txt {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.moz-txt-underscore {
+ text-decoration: underline;
+}
+
+/* disable overflow in themes until bug 42676 is fixed in Mozilla */
+#Chat {
+ overflow: hidden !important;
+}
+
+#Chat * {
+ overflow: visible !important;
+ unicode-bidi: plaintext;
+}
+
+#unread-ruler {
+ margin: 0;
+ width: 100%;
+ border: none;
+ border-top: 1px dashed red;
+}
+
+.ib-nick {
+ font-weight: bold;
+}
+
+.ib-nick[left] {
+ color: grey;
+}
+
+.monospaced {
+ font-family: monospace;
+}
diff --git a/comm/chat/themes/icons/otr-connection-encrypted.svg b/comm/chat/themes/icons/otr-connection-encrypted.svg
new file mode 100644
index 0000000000..a086b143b2
--- /dev/null
+++ b/comm/chat/themes/icons/otr-connection-encrypted.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M12,7 L13,7 C13.5522847,7 14,7.44771525 14,8 L14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 L2,8 C2,7.44771525 2.44771525,7 3,7 L4,7 L4,5.00032973 C4,2.79202307 5.79321704,1 8,1 C10.2075938,1 12,2.79481161 12,5.00032973 L12,7 Z M10,7 L10,5.00032973 C10,3.89878113 9.10242341,3 8,3 C6.89748845,3 6,3.89689088 6,5.00032973 L6,7 L10,7 Z"/>
+ <path style="fill:#00b22c;fill-opacity:1;stroke-width:0.99999988" d="M 9.0226853,15.999039 A 0.8763328,0.8763328 0 0 1 8.403118,15.742273 L 5.7741199,13.113275 A 0.8763328,0.8763328 0 0 1 7.0132545,11.87414 l 1.8902496,1.89025 5.5349179,-7.9071509 a 0.8763328,0.8763328 0 0 1 1.436309,1.0042774 L 9.7404016,15.624844 a 0.8763328,0.8763328 0 0 1 -0.6414753,0.374195 0.75627521,0.75627521 0 0 1 -0.076241,0 z"/>
+</svg>
diff --git a/comm/chat/themes/icons/otr-connection-finished.svg b/comm/chat/themes/icons/otr-connection-finished.svg
new file mode 100644
index 0000000000..d98610a6cf
--- /dev/null
+++ b/comm/chat/themes/icons/otr-connection-finished.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M12,7 L13,7 C13.5522847,7 14,7.44771525 14,8 L14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 L2,8 C2,7.44771525 2.44771525,7 3,7 L4,7 L4,5.00032973 C4,2.79202307 5.79321704,1 8,1 C10.2075938,1 12,2.79481161 12,5.00032973 L12,7 Z M10,7 L10,5.00032973 C10,3.89878113 9.10242341,3 8,3 C6.89748845,3 6,3.89689088 6,5.00032973 L6,7 L10,7 Z"/>
+ <path d="M 11.936582,10.734873 15.767811,6.9036441 A 0.8280753,0.8280753 0 0 0 14.59636,5.7332977 L 10.765132,9.5634223 6.9339034,5.7332977 A 0.8280753,0.8280753 0 1 0 5.763557,6.9036441 L 9.5936809,10.734873 5.763557,14.566099 a 0.8280753,0.8280753 0 1 0 1.1703464,1.170347 l 3.8312286,-3.830123 3.831228,3.831227 a 0.8280753,0.8280753 0 0 0 1.170347,-1.171451 z" style="fill:#ff9400;fill-opacity:1;stroke-width:1"/>
+</svg>
diff --git a/comm/chat/themes/icons/prpl-generic-32.png b/comm/chat/themes/icons/prpl-generic-32.png
new file mode 100644
index 0000000000..e798681e98
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-generic-32.png
Binary files differ
diff --git a/comm/chat/themes/icons/prpl-generic-48.png b/comm/chat/themes/icons/prpl-generic-48.png
new file mode 100644
index 0000000000..3fc98a6e34
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-generic-48.png
Binary files differ
diff --git a/comm/chat/themes/icons/prpl-generic.png b/comm/chat/themes/icons/prpl-generic.png
new file mode 100644
index 0000000000..ccc1fa166c
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-generic.png
Binary files differ
diff --git a/comm/chat/themes/icons/prpl-unknown-32.png b/comm/chat/themes/icons/prpl-unknown-32.png
new file mode 100644
index 0000000000..8de4412df0
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-unknown-32.png
Binary files differ
diff --git a/comm/chat/themes/icons/prpl-unknown-48.png b/comm/chat/themes/icons/prpl-unknown-48.png
new file mode 100644
index 0000000000..7fba36968f
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-unknown-48.png
Binary files differ
diff --git a/comm/chat/themes/icons/prpl-unknown.png b/comm/chat/themes/icons/prpl-unknown.png
new file mode 100644
index 0000000000..a3ca23a495
--- /dev/null
+++ b/comm/chat/themes/icons/prpl-unknown.png
Binary files differ
diff --git a/comm/chat/themes/imtooltip.css b/comm/chat/themes/imtooltip.css
new file mode 100644
index 0000000000..084bdcc206
--- /dev/null
+++ b/comm/chat/themes/imtooltip.css
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.tooltipBuddies {
+ margin-inline-start: -3px;
+}
+
+.tooltipTable {
+ border-spacing: 0;
+}
+
+.tooltipTable th {
+ text-align: left;
+ white-space: nowrap;
+}
+
+.tooltipTable th,
+.tooltipTable td {
+ line-height: 1.5em;
+}
+
+.chatTooltipSeparator {
+ border-bottom: 1px solid hsla(0, 0%, 50%, 0.5);
+}
+
+.displayUserAccount.tooltipDisplayUserAccount {
+ /* Remove padding from top and sides. */
+ padding-inline: 0;
+ padding-block-start: 0;
+}
diff --git a/comm/chat/themes/jar.mn b/comm/chat/themes/jar.mn
new file mode 100644
index 0000000000..c8d60616a3
--- /dev/null
+++ b/comm/chat/themes/jar.mn
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+chat.jar:
+% skin chat classic/1.0 %skin/classic/chat/
+ skin/classic/chat/chat.svg
+ skin/classic/chat/chat-left.svg
+ skin/classic/chat/conv.css
+ skin/classic/chat/imtooltip.css
+ skin/classic/chat/mobile.svg
+ skin/classic/chat/otrFingerprintDialog.css
+ skin/classic/chat/typed.svg
+ skin/classic/chat/typing.svg
+ skin/classic/chat/unknown.svg
+ skin/classic/chat/otr-connection-encrypted.svg (icons/otr-connection-encrypted.svg)
+ skin/classic/chat/otr-connection-finished.svg (icons/otr-connection-finished.svg)
+ skin/classic/chat/prpl-generic/icon.png (icons/prpl-generic.png)
+ skin/classic/chat/prpl-generic/icon32.png (icons/prpl-generic-32.png)
+ skin/classic/chat/prpl-generic/icon48.png (icons/prpl-generic-48.png)
+ skin/classic/chat/prpl-unknown/icon.png (icons/prpl-unknown.png)
+ skin/classic/chat/prpl-unknown/icon32.png (icons/prpl-unknown-32.png)
+ skin/classic/chat/prpl-unknown/icon48.png (icons/prpl-unknown-48.png)
diff --git a/comm/chat/themes/mobile.svg b/comm/chat/themes/mobile.svg
new file mode 100644
index 0000000000..ee0d600a0a
--- /dev/null
+++ b/comm/chat/themes/mobile.svg
@@ -0,0 +1,27 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#fff"/>
+ <stop offset="1" stop-color="#99eaff"/>
+ </linearGradient>
+ <linearGradient id="b">
+ <stop offset="0" stop-color="#535353"/>
+ <stop offset="1" stop-color="#535353" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient id="c" xlink:href="#a" gradientUnits="userSpaceOnUse" x1="8.25" y2="10" x2="13.75" y1="2.25"/>
+ <linearGradient id="d" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="7.99" x2="5.2" y1="7.97" x1="2.86"/>
+ <linearGradient id="e" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="7.98" x2="4.34" y1="7.99" x1=".87"/>
+ <linearGradient id="f" xlink:href="#b" gradientUnits="userSpaceOnUse" y2="8.02" x2="6.47" y1="8.01" x1="4.82"/>
+ </defs>
+ <path fill="#333" d="M7 0h1.25v3H7z"/>
+ <rect fill="#333" width="8" height="14" x="7" y="1" ry="1.75"/>
+ <path fill="#888" d="M8.25 10h5.5v3.75h-5.5z"/>
+ <path fill="#333" d="M9.25 11h3.5v1.75h-3.5z"/>
+ <path fill="url(#c)" d="M8.25 2.25h5.5V10h-5.5z"/>
+ <path fill="none" stroke="url(#d)" d="M5.34 11.99c-1.8-.86-2.45-3.66-1.72-5.82C3.9 5.29 4.38 4.54 5 4"/>
+ <path fill="none" stroke="url(#e)" d="M4.39 14C1.84 12.96.76 9.02 1.62 5.83A7.37 7.37 0 014.02 2"/>
+ <path fill="none" stroke="url(#f)" d="M6.33 9.99c-.97-.52-1.25-1.97-.8-3.05a2.2 2.62 0 01.69-.95"/>
+</svg>
diff --git a/comm/chat/themes/moz.build b/comm/chat/themes/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/chat/themes/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/chat/themes/otrFingerprintDialog.css b/comm/chat/themes/otrFingerprintDialog.css
new file mode 100644
index 0000000000..d22a2a2375
--- /dev/null
+++ b/comm/chat/themes/otrFingerprintDialog.css
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ --text-color: #36385A;
+ --primary-color: #0a84ff;
+ --warning-color: #FF9400;
+ --error-color: #5A0002;
+}
+
+body {
+ margin: 0;
+}
+
+dialog {
+ width: 100vw;
+ height: 100vh;
+}
+
+.label-title {
+ font-weight: bold;
+ margin-bottom: 5px;
+ color: var(--text-color);
+}
+
+.msg-error {
+ color: var(--error-color);
+}
+
+/* Form and input fields */
+
+.form-control {
+ position: relative;
+ margin: 0;
+}
+
+.input-control {
+ display: flex;
+ align-items: stretch;
+}
+
+.input-field {
+ padding-block: 5px;
+ padding-inline: 6px 30px;
+ flex-grow: 1;
+ margin: 2px 4px;
+}
+
+.input-field:invalid {
+ box-shadow: 0 0 2px 1px var(--warning-color);
+}
+
+.input-helper {
+ font-family: monospace;
+ font-size: 1em;
+ opacity: 0.7;
+}
+
+/* Icons */
+
+.header-icon {
+ -moz-context-properties: fill, stroke-opacity;
+ fill: currentColor;
+ color: var(--primary-color);
+ width: 3.5em;
+ margin: 10px;
+}
+
+.warning-icon {
+ cursor: pointer;
+ -moz-context-properties: fill, stroke-opacity;
+ fill: currentColor;
+ margin-inline: -26px 10px;
+ color: var(--warning-color);
+}
diff --git a/comm/chat/themes/typed.svg b/comm/chat/themes/typed.svg
new file mode 100644
index 0000000000..8bf48a3b67
--- /dev/null
+++ b/comm/chat/themes/typed.svg
@@ -0,0 +1,18 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#fff"/>
+ <stop offset="1" stop-color="#efefef"/>
+ </linearGradient>
+ <linearGradient id="b" xlink:href="#a" x1="8" y1="3" x2="8" y2="13" gradientUnits="userSpaceOnUse"/>
+ <filter id="e" x="0" width="1" y="0" height="3">
+ <feGaussianBlur stdDeviation=".7"/>
+ </filter>
+ </defs>
+ <rect filter="url(#e)" opacity=".12" width="13" height="3" x="1.5" y="11.5" ry="1.5" rx="1.5"/>
+ <path fill="url(#b)" stroke="#787878" d="M3 2.5C2 2.5 1.5 3 1.5 4v7c-.02 1 .5 1.5 1.5 1.5h.5v2l2.5-2h7c1 0 1.5-.5 1.5-1.5V4c.02-1-.5-1.5-1.5-1.5H3z"/>
+ <path fill="#d35f5f" d="M4 9h8v1H4z"/>
+</svg>
diff --git a/comm/chat/themes/typing.svg b/comm/chat/themes/typing.svg
new file mode 100644
index 0000000000..05ea7e9ec1
--- /dev/null
+++ b/comm/chat/themes/typing.svg
@@ -0,0 +1,17 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#fff"/>
+ <stop offset="1" stop-color="#efefef"/>
+ </linearGradient>
+ <linearGradient id="b" xlink:href="#a" x1="8" y1="3" x2="8" y2="13" gradientUnits="userSpaceOnUse"/>
+ <filter id="e" x="0" width="1" y="0" height="3">
+ <feGaussianBlur stdDeviation=".7"/>
+ </filter>
+ </defs>
+ <rect filter="url(#e)" opacity=".12" width="13" height="3" x="1.5" y="11.5" ry="1.5" rx="1.5"/>
+ <path fill="url(#b)" stroke="#787878" d="M3 2.5C2 2.5 1.5 3 1.5 4v7c-.02 1 .5 1.5 1.5 1.5h.5v2l2.5-2h7c1 0 1.5-.5 1.5-1.5V4c.02-1-.5-1.5-1.5-1.5H3z"/>
+</svg>
diff --git a/comm/chat/themes/unknown.svg b/comm/chat/themes/unknown.svg
new file mode 100644
index 0000000000..c827048328
--- /dev/null
+++ b/comm/chat/themes/unknown.svg
@@ -0,0 +1,15 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" opacity=".5">
+ <defs>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#f5f5f5"/>
+ <stop offset="1" stop-color="#b2b2b2"/>
+ </linearGradient>
+ <radialGradient id="b" xlink:href="#a" r="4" fy="7.6" fx="8.5" cy="7.6" cx="8.5" gradientTransform="matrix(3.6 -.02 .02 3.1 -25.4 -19.3)" gradientUnits="userSpaceOnUse"/>
+ </defs>
+ <circle fill="#797979" r="8" cy="8" cx="8"/>
+ <circle fill="url(#b)" r="7" cy="8" cx="8"/>
+ <path d="M8.99 12.4c0-1.31-1.98-1.31-1.98 0S9 13.71 9 12.4zM6.7 5.98c0-.63.55-1.27 1.29-1.27S9.3 5.35 9.3 6c-.04.3-.16.58-.35.82-.15.19-.32.38-.5.5l-.22.2-.63.7a2.76 2.76 0 00-.45 1.62c0 1.13 1.7 1.13 1.7 0 0-.38.1-.57.17-.65.05-.07.11-.14.18-.2l.11-.12.15-.15.08-.06c.26-.23.51-.51.76-.78.32-.44.7-1.08.7-1.9 0-3.97-6-3.97-6 0 0 1.14 1.7 1.14 1.7 0z" fill="#565656"/>
+</svg>