From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/chat/chat-prefs.js | 123 + comm/chat/components/public/imIAccount.idl | 331 + comm/chat/components/public/imIAccountsService.idl | 63 + comm/chat/components/public/imICommandsService.idl | 79 + comm/chat/components/public/imIContactsService.idl | 290 + .../components/public/imIConversationsService.idl | 117 + comm/chat/components/public/imICoreService.idl | 28 + comm/chat/components/public/imILogger.idl | 86 + comm/chat/components/public/imIStatusInfo.idl | 55 + comm/chat/components/public/imITagsService.idl | 81 + comm/chat/components/public/imIUserStatusInfo.idl | 55 + comm/chat/components/public/moz.build | 25 + comm/chat/components/public/prplIConversation.idl | 274 + comm/chat/components/public/prplIMessage.idl | 106 + comm/chat/components/public/prplIPref.idl | 38 + comm/chat/components/public/prplIProtocol.idl | 148 + comm/chat/components/public/prplIRequest.idl | 115 + comm/chat/components/public/prplITooltipInfo.idl | 29 + comm/chat/components/src/components.conf | 50 + comm/chat/components/src/imAccounts.sys.mjs | 1237 ++++ comm/chat/components/src/imCommands.sys.mjs | 289 + comm/chat/components/src/imContacts.sys.mjs | 1809 +++++ comm/chat/components/src/imConversations.sys.mjs | 951 +++ comm/chat/components/src/imCore.sys.mjs | 407 ++ comm/chat/components/src/logger.sys.mjs | 971 +++ comm/chat/components/src/moz.build | 19 + comm/chat/components/src/test/test_accounts.js | 48 + comm/chat/components/src/test/test_commands.js | 271 + .../chat/components/src/test/test_conversations.js | 239 + comm/chat/components/src/test/test_init.js | 28 + comm/chat/components/src/test/test_logger.js | 860 +++ comm/chat/components/src/test/xpcshell.ini | 9 + comm/chat/content/chat-account-richlistitem.js | 354 + comm/chat/content/chat-tooltip.js | 604 ++ comm/chat/content/conv.html | 4 + comm/chat/content/conversation-browser.js | 906 +++ comm/chat/content/imAccountOptionsHelper.js | 121 + comm/chat/content/jar.mn | 18 + comm/chat/content/moz.build | 6 + comm/chat/content/otr-add-fingerprint.js | 84 + comm/chat/content/otr-add-fingerprint.xhtml | 91 + comm/chat/content/otr-auth.js | 198 + comm/chat/content/otr-auth.xhtml | 163 + comm/chat/content/otr-finger.js | 159 + comm/chat/content/otr-finger.xhtml | 74 + comm/chat/content/otrWorker.js | 61 + comm/chat/locales/Makefile.in | 6 + comm/chat/locales/en-US/accounts.dtd | 33 + comm/chat/locales/en-US/accounts.properties | 9 + comm/chat/locales/en-US/commands.properties | 27 + comm/chat/locales/en-US/contacts.properties | 8 + comm/chat/locales/en-US/conversations.properties | 80 + comm/chat/locales/en-US/facebook.properties | 6 + comm/chat/locales/en-US/imtooltip.properties | 10 + comm/chat/locales/en-US/irc.properties | 209 + comm/chat/locales/en-US/logger.properties | 7 + comm/chat/locales/en-US/matrix.ftl | 24 + comm/chat/locales/en-US/matrix.properties | 255 + comm/chat/locales/en-US/status.properties | 23 + comm/chat/locales/en-US/twitter.properties | 9 + comm/chat/locales/en-US/xmpp.properties | 274 + comm/chat/locales/en-US/yahoo.properties | 5 + comm/chat/locales/jar.mn | 24 + comm/chat/locales/moz.build | 6 + comm/chat/modules/CLib.sys.mjs | 64 + comm/chat/modules/IMServices.sys.mjs | 50 + comm/chat/modules/InteractiveBrowser.sys.mjs | 138 + comm/chat/modules/NormalizedMap.sys.mjs | 48 + comm/chat/modules/OTR.sys.mjs | 1506 ++++ comm/chat/modules/OTRLib.sys.mjs | 1151 +++ comm/chat/modules/OTRUI.sys.mjs | 998 +++ comm/chat/modules/ToLocaleFormat.sys.mjs | 208 + comm/chat/modules/imContentSink.sys.mjs | 495 ++ comm/chat/modules/imSmileys.sys.mjs | 184 + comm/chat/modules/imStatusUtils.sys.mjs | 57 + comm/chat/modules/imTextboxUtils.sys.mjs | 19 + comm/chat/modules/imThemes.sys.mjs | 1333 ++++ comm/chat/modules/imXPCOMUtils.sys.mjs | 249 + comm/chat/modules/jsProtoHelper.sys.mjs | 1796 +++++ comm/chat/modules/moz.build | 25 + comm/chat/modules/socket.sys.mjs | 644 ++ comm/chat/modules/test/test_InteractiveBrowser.js | 280 + comm/chat/modules/test/test_NormalizedMap.js | 80 + comm/chat/modules/test/test_filtering.js | 479 ++ comm/chat/modules/test/test_imThemes.js | 342 + comm/chat/modules/test/test_jsProtoHelper.js | 159 + comm/chat/modules/test/test_otrlib.js | 21 + comm/chat/modules/test/xpcshell.ini | 10 + comm/chat/moz.build | 28 + comm/chat/protocols/facebook/components.conf | 15 + comm/chat/protocols/facebook/facebook.sys.mjs | 56 + .../protocols/facebook/icons/prpl-facebook-32.png | Bin 0 -> 1193 bytes .../protocols/facebook/icons/prpl-facebook-48.png | Bin 0 -> 1521 bytes .../protocols/facebook/icons/prpl-facebook.png | Bin 0 -> 552 bytes comm/chat/protocols/facebook/jar.mn | 9 + comm/chat/protocols/facebook/moz.build | 14 + comm/chat/protocols/gtalk/components.conf | 15 + comm/chat/protocols/gtalk/gtalk.sys.mjs | 60 + comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png | Bin 0 -> 2024 bytes comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png | Bin 0 -> 3168 bytes comm/chat/protocols/gtalk/icons/prpl-gtalk.png | Bin 0 -> 865 bytes comm/chat/protocols/gtalk/jar.mn | 9 + comm/chat/protocols/gtalk/moz.build | 14 + comm/chat/protocols/irc/components.conf | 15 + comm/chat/protocols/irc/icons/prpl-irc-32.png | Bin 0 -> 695 bytes comm/chat/protocols/irc/icons/prpl-irc-48.png | Bin 0 -> 1003 bytes comm/chat/protocols/irc/icons/prpl-irc.png | Bin 0 -> 454 bytes comm/chat/protocols/irc/irc.sys.mjs | 122 + comm/chat/protocols/irc/ircAccount.sys.mjs | 2296 ++++++ comm/chat/protocols/irc/ircBase.sys.mjs | 1768 +++++ comm/chat/protocols/irc/ircCAP.sys.mjs | 170 + comm/chat/protocols/irc/ircCTCP.sys.mjs | 291 + comm/chat/protocols/irc/ircCommands.sys.mjs | 599 ++ comm/chat/protocols/irc/ircDCC.sys.mjs | 66 + comm/chat/protocols/irc/ircEchoMessage.sys.mjs | 41 + .../protocols/irc/ircHandlerPriorities.sys.mjs | 16 + comm/chat/protocols/irc/ircHandlers.sys.mjs | 306 + comm/chat/protocols/irc/ircISUPPORT.sys.mjs | 246 + comm/chat/protocols/irc/ircMultiPrefix.sys.mjs | 60 + comm/chat/protocols/irc/ircNonStandard.sys.mjs | 262 + comm/chat/protocols/irc/ircSASL.sys.mjs | 179 + comm/chat/protocols/irc/ircServerTime.sys.mjs | 80 + comm/chat/protocols/irc/ircServices.sys.mjs | 317 + comm/chat/protocols/irc/ircUtils.sys.mjs | 303 + comm/chat/protocols/irc/ircWatchMonitor.sys.mjs | 467 ++ comm/chat/protocols/irc/jar.mn | 9 + comm/chat/protocols/irc/moz.build | 33 + comm/chat/protocols/irc/test/test_ctcpColoring.js | 72 + comm/chat/protocols/irc/test/test_ctcpDequote.js | 55 + .../chat/protocols/irc/test/test_ctcpFormatting.js | 59 + comm/chat/protocols/irc/test/test_ctcpQuote.js | 64 + comm/chat/protocols/irc/test/test_ircCAP.js | 236 + comm/chat/protocols/irc/test/test_ircChannel.js | 187 + comm/chat/protocols/irc/test/test_ircCommands.js | 218 + comm/chat/protocols/irc/test/test_ircMessage.js | 336 + .../chat/protocols/irc/test/test_ircNonStandard.js | 209 + comm/chat/protocols/irc/test/test_ircProtocol.js | 20 + comm/chat/protocols/irc/test/test_ircServerTime.js | 130 + .../protocols/irc/test/test_sendBufferedCommand.js | 199 + comm/chat/protocols/irc/test/test_setMode.js | 70 + .../protocols/irc/test/test_splitLongMessages.js | 44 + comm/chat/protocols/irc/test/test_tryNewNick.js | 148 + comm/chat/protocols/irc/test/xpcshell.ini | 18 + comm/chat/protocols/jsTest/components.conf | 15 + comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs | 145 + comm/chat/protocols/jsTest/moz.build | 13 + comm/chat/protocols/matrix/components.conf | 15 + comm/chat/protocols/matrix/icons/README | 5 + .../chat/protocols/matrix/icons/prpl-matrix-32.png | Bin 0 -> 693 bytes .../chat/protocols/matrix/icons/prpl-matrix-48.png | Bin 0 -> 1012 bytes comm/chat/protocols/matrix/icons/prpl-matrix.png | Bin 0 -> 145 bytes comm/chat/protocols/matrix/jar.mn | 9 + .../protocols/matrix/lib/@matrix-org/olm/LICENSE | 177 + .../protocols/matrix/lib/@matrix-org/olm/olm.js | 163 + .../protocols/matrix/lib/@matrix-org/olm/olm.wasm | Bin 0 -> 153573 bytes comm/chat/protocols/matrix/lib/README.md | 174 + .../chat/protocols/matrix/lib/another-json/LICENSE | 177 + .../matrix/lib/another-json/another-json.js | 93 + comm/chat/protocols/matrix/lib/base-x/LICENSE.md | 22 + comm/chat/protocols/matrix/lib/base-x/index.js | 119 + comm/chat/protocols/matrix/lib/bs58/LICENSE | 21 + comm/chat/protocols/matrix/lib/bs58/index.js | 4 + .../chat/protocols/matrix/lib/content-type/LICENSE | 22 + .../protocols/matrix/lib/content-type/index.js | 225 + comm/chat/protocols/matrix/lib/events/LICENSE | 22 + comm/chat/protocols/matrix/lib/events/events.js | 497 ++ .../lib/matrix-events-sdk/ExtensibleEvents.js | 189 + .../matrix/lib/matrix-events-sdk/IPartialEvent.js | 5 + .../lib/matrix-events-sdk/InvalidEventError.js | 69 + .../protocols/matrix/lib/matrix-events-sdk/LICENSE | 201 + .../matrix/lib/matrix-events-sdk/NamespacedMap.js | 149 + .../lib/matrix-events-sdk/NamespacedValue.js | 166 + .../lib/matrix-events-sdk/events/EmoteEvent.js | 99 + .../matrix-events-sdk/events/ExtensibleEvent.js | 60 + .../lib/matrix-events-sdk/events/MessageEvent.js | 214 + .../lib/matrix-events-sdk/events/NoticeEvent.js | 99 + .../lib/matrix-events-sdk/events/PollEndEvent.js | 138 + .../matrix-events-sdk/events/PollResponseEvent.js | 198 + .../lib/matrix-events-sdk/events/PollStartEvent.js | 287 + .../lib/matrix-events-sdk/events/message_types.js | 74 + .../lib/matrix-events-sdk/events/poll_types.js | 70 + .../matrix-events-sdk/events/relationship_types.js | 34 + .../matrix/lib/matrix-events-sdk/index.js | 278 + .../interpreters/legacy/MRoomMessage.js | 62 + .../interpreters/modern/MMessage.js | 40 + .../matrix-events-sdk/interpreters/modern/MPoll.js | 41 + .../matrix/lib/matrix-events-sdk/types.js | 49 + .../matrix-events-sdk/utility/MessageMatchers.js | 59 + .../matrix/lib/matrix-events-sdk/utility/events.js | 51 + .../matrix-sdk/@types/IIdentityServerProvider.js | 5 + .../matrix/lib/matrix-sdk/@types/PushRules.js | 101 + .../matrix/lib/matrix-sdk/@types/another-json.d.js | 1 + .../protocols/matrix/lib/matrix-sdk/@types/auth.js | 68 + .../matrix/lib/matrix-sdk/@types/beacon.js | 126 + .../matrix/lib/matrix-sdk/@types/crypto.js | 5 + .../matrix/lib/matrix-sdk/@types/event.js | 240 + .../lib/matrix-sdk/@types/extensible_events.js | 121 + .../matrix/lib/matrix-sdk/@types/global.d.js | 6 + .../lib/matrix-sdk/@types/local_notifications.js | 5 + .../matrix/lib/matrix-sdk/@types/location.js | 72 + .../matrix/lib/matrix-sdk/@types/partials.js | 63 + .../matrix/lib/matrix-sdk/@types/polls.js | 93 + .../matrix/lib/matrix-sdk/@types/read_receipts.js | 33 + .../matrix/lib/matrix-sdk/@types/requests.js | 5 + .../matrix/lib/matrix-sdk/@types/search.js | 35 + .../matrix/lib/matrix-sdk/@types/signed.js | 5 + .../matrix/lib/matrix-sdk/@types/spaces.js | 5 + .../matrix/lib/matrix-sdk/@types/synapse.js | 5 + .../protocols/matrix/lib/matrix-sdk/@types/sync.js | 30 + .../matrix/lib/matrix-sdk/@types/threepids.js | 27 + .../matrix/lib/matrix-sdk/@types/topic.js | 63 + .../protocols/matrix/lib/matrix-sdk/@types/uia.js | 5 + comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE | 177 + .../matrix/lib/matrix-sdk/NamespacedValue.js | 123 + .../protocols/matrix/lib/matrix-sdk/ReEmitter.js | 89 + .../matrix/lib/matrix-sdk/ToDeviceMessageQueue.js | 133 + .../matrix/lib/matrix-sdk/autodiscovery.js | 429 ++ .../matrix/lib/matrix-sdk/browser-index.js | 58 + .../chat/protocols/matrix/lib/matrix-sdk/client.js | 7660 ++++++++++++++++++++ .../lib/matrix-sdk/common-crypto/CryptoBackend.js | 5 + .../matrix/lib/matrix-sdk/content-helpers.js | 266 + .../matrix/lib/matrix-sdk/content-repo.js | 74 + .../protocols/matrix/lib/matrix-sdk/crypto-api.js | 105 + .../lib/matrix-sdk/crypto-api/verification.js | 46 + .../matrix/lib/matrix-sdk/crypto/CrossSigning.js | 703 ++ .../matrix/lib/matrix-sdk/crypto/DeviceList.js | 860 +++ .../lib/matrix-sdk/crypto/EncryptionSetup.js | 342 + .../matrix/lib/matrix-sdk/crypto/OlmDevice.js | 1162 +++ .../crypto/OutgoingRoomKeyRequestManager.js | 406 ++ .../matrix/lib/matrix-sdk/crypto/RoomList.js | 60 + .../matrix/lib/matrix-sdk/crypto/SecretSharing.js | 199 + .../matrix/lib/matrix-sdk/crypto/SecretStorage.js | 119 + .../protocols/matrix/lib/matrix-sdk/crypto/aes.js | 127 + .../lib/matrix-sdk/crypto/algorithms/base.js | 226 + .../lib/matrix-sdk/crypto/algorithms/index.js | 18 + .../lib/matrix-sdk/crypto/algorithms/megolm.js | 1682 +++++ .../matrix/lib/matrix-sdk/crypto/algorithms/olm.js | 276 + .../protocols/matrix/lib/matrix-sdk/crypto/api.js | 12 + .../matrix/lib/matrix-sdk/crypto/backup.js | 651 ++ .../matrix/lib/matrix-sdk/crypto/crypto.js | 60 + .../matrix/lib/matrix-sdk/crypto/dehydration.js | 237 + .../lib/matrix-sdk/crypto/device-converter.js | 47 + .../matrix/lib/matrix-sdk/crypto/deviceinfo.js | 152 + .../matrix/lib/matrix-sdk/crypto/index.js | 3427 +++++++++ .../matrix/lib/matrix-sdk/crypto/key_passphrase.js | 69 + .../matrix/lib/matrix-sdk/crypto/keybackup.js | 5 + .../matrix/lib/matrix-sdk/crypto/olmlib.js | 480 ++ .../matrix/lib/matrix-sdk/crypto/recoverykey.js | 60 + .../matrix/lib/matrix-sdk/crypto/store/base.js | 5 + .../crypto/store/indexeddb-crypto-store-backend.js | 913 +++ .../crypto/store/indexeddb-crypto-store.js | 599 ++ .../crypto/store/localStorage-crypto-store.js | 329 + .../matrix-sdk/crypto/store/memory-crypto-store.js | 439 ++ .../lib/matrix-sdk/crypto/verification/Base.js | 345 + .../lib/matrix-sdk/crypto/verification/Error.js | 100 + .../crypto/verification/IllegalMethod.js | 46 + .../lib/matrix-sdk/crypto/verification/QRCode.js | 269 + .../lib/matrix-sdk/crypto/verification/SAS.js | 454 ++ .../matrix-sdk/crypto/verification/SASDecimal.js | 39 + .../crypto/verification/request/Channel.js | 5 + .../crypto/verification/request/InRoomChannel.js | 349 + .../crypto/verification/request/ToDeviceChannel.js | 322 + .../verification/request/VerificationRequest.js | 870 +++ .../protocols/matrix/lib/matrix-sdk/embedded.js | 261 + .../chat/protocols/matrix/lib/matrix-sdk/errors.js | 62 + .../matrix/lib/matrix-sdk/event-mapper.js | 86 + .../extensible_events_v1/ExtensibleEvent.js | 63 + .../extensible_events_v1/InvalidEventError.js | 31 + .../extensible_events_v1/MessageEvent.js | 138 + .../extensible_events_v1/PollEndEvent.js | 93 + .../extensible_events_v1/PollResponseEvent.js | 140 + .../extensible_events_v1/PollStartEvent.js | 191 + .../matrix-sdk/extensible_events_v1/utilities.js | 40 + .../protocols/matrix/lib/matrix-sdk/feature.js | 78 + .../matrix/lib/matrix-sdk/filter-component.js | 171 + .../chat/protocols/matrix/lib/matrix-sdk/filter.js | 212 + .../matrix/lib/matrix-sdk/http-api/errors.js | 83 + .../matrix/lib/matrix-sdk/http-api/fetch.js | 265 + .../matrix/lib/matrix-sdk/http-api/index.js | 240 + .../matrix/lib/matrix-sdk/http-api/interface.js | 27 + .../matrix/lib/matrix-sdk/http-api/method.js | 29 + .../matrix/lib/matrix-sdk/http-api/prefix.js | 39 + .../matrix/lib/matrix-sdk/http-api/utils.js | 143 + comm/chat/protocols/matrix/lib/matrix-sdk/index.js | 43 + .../matrix/lib/matrix-sdk/indexeddb-helpers.js | 56 + .../matrix/lib/matrix-sdk/indexeddb-worker.js | 12 + .../matrix/lib/matrix-sdk/interactive-auth.js | 510 ++ .../chat/protocols/matrix/lib/matrix-sdk/logger.js | 80 + .../chat/protocols/matrix/lib/matrix-sdk/matrix.js | 546 ++ .../matrix/lib/matrix-sdk/models/MSC3089Branch.js | 227 + .../lib/matrix-sdk/models/MSC3089TreeSpace.js | 508 ++ .../lib/matrix-sdk/models/ToDeviceMessage.js | 5 + .../matrix/lib/matrix-sdk/models/beacon.js | 181 + .../matrix/lib/matrix-sdk/models/device.js | 80 + .../matrix/lib/matrix-sdk/models/event-context.js | 116 + .../matrix/lib/matrix-sdk/models/event-status.js | 35 + .../lib/matrix-sdk/models/event-timeline-set.js | 809 +++ .../matrix/lib/matrix-sdk/models/event-timeline.js | 469 ++ .../matrix/lib/matrix-sdk/models/event.js | 1442 ++++ .../lib/matrix-sdk/models/invites-ignorer.js | 358 + .../protocols/matrix/lib/matrix-sdk/models/poll.js | 237 + .../matrix/lib/matrix-sdk/models/read-receipt.js | 260 + .../lib/matrix-sdk/models/related-relations.js | 41 + .../lib/matrix-sdk/models/relations-container.js | 135 + .../matrix/lib/matrix-sdk/models/relations.js | 336 + .../matrix/lib/matrix-sdk/models/room-member.js | 363 + .../matrix/lib/matrix-sdk/models/room-state.js | 931 +++ .../matrix/lib/matrix-sdk/models/room-summary.js | 34 + .../protocols/matrix/lib/matrix-sdk/models/room.js | 3079 ++++++++ .../matrix/lib/matrix-sdk/models/search-result.js | 58 + .../matrix/lib/matrix-sdk/models/thread.js | 649 ++ .../lib/matrix-sdk/models/typed-event-emitter.js | 200 + .../protocols/matrix/lib/matrix-sdk/models/user.js | 211 + .../matrix/lib/matrix-sdk/pushprocessor.js | 676 ++ .../matrix/lib/matrix-sdk/randomstring.js | 44 + .../matrix/lib/matrix-sdk/realtime-callbacks.js | 179 + .../matrix/lib/matrix-sdk/receipt-accumulator.js | 169 + .../lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js | 240 + .../lib/matrix-sdk/rendezvous/RendezvousChannel.js | 5 + .../lib/matrix-sdk/rendezvous/RendezvousCode.js | 5 + .../lib/matrix-sdk/rendezvous/RendezvousError.js | 29 + .../rendezvous/RendezvousFailureReason.js | 36 + .../lib/matrix-sdk/rendezvous/RendezvousIntent.js | 27 + .../matrix-sdk/rendezvous/RendezvousTransport.js | 5 + .../channels/MSC3903ECDHv2RendezvousChannel.js | 194 + .../lib/matrix-sdk/rendezvous/channels/index.js | 16 + .../matrix/lib/matrix-sdk/rendezvous/index.js | 82 + .../MSC3886SimpleHttpRendezvousTransport.js | 176 + .../lib/matrix-sdk/rendezvous/transports/index.js | 16 + .../matrix/lib/matrix-sdk/room-hierarchy.js | 133 + .../matrix-sdk/rust-crypto/CrossSigningIdentity.js | 93 + .../lib/matrix-sdk/rust-crypto/KeyClaimManager.js | 78 + .../rust-crypto/OutgoingRequestProcessor.js | 117 + .../lib/matrix-sdk/rust-crypto/RoomEncryptor.js | 124 + .../lib/matrix-sdk/rust-crypto/browserify-index.js | 31 + .../matrix/lib/matrix-sdk/rust-crypto/constants.js | 25 + .../lib/matrix-sdk/rust-crypto/device-converter.js | 121 + .../matrix/lib/matrix-sdk/rust-crypto/index.js | 54 + .../lib/matrix-sdk/rust-crypto/rust-crypto.js | 574 ++ .../protocols/matrix/lib/matrix-sdk/scheduler.js | 314 + .../matrix/lib/matrix-sdk/secret-storage.js | 431 ++ .../matrix/lib/matrix-sdk/service-types.js | 27 + .../matrix/lib/matrix-sdk/sliding-sync-sdk.js | 861 +++ .../matrix/lib/matrix-sdk/sliding-sync.js | 795 ++ .../protocols/matrix/lib/matrix-sdk/store/index.js | 5 + .../lib/matrix-sdk/store/indexeddb-backend.js | 5 + .../matrix-sdk/store/indexeddb-local-backend.js | 569 ++ .../matrix-sdk/store/indexeddb-remote-backend.js | 200 + .../lib/matrix-sdk/store/indexeddb-store-worker.js | 151 + .../matrix/lib/matrix-sdk/store/indexeddb.js | 329 + .../store/local-storage-events-emitter.js | 43 + .../matrix/lib/matrix-sdk/store/memory.js | 418 ++ .../protocols/matrix/lib/matrix-sdk/store/stub.js | 262 + .../matrix/lib/matrix-sdk/sync-accumulator.js | 474 ++ comm/chat/protocols/matrix/lib/matrix-sdk/sync.js | 1594 ++++ .../matrix/lib/matrix-sdk/timeline-window.js | 462 ++ comm/chat/protocols/matrix/lib/matrix-sdk/utils.js | 754 ++ .../matrix/lib/matrix-sdk/webrtc/audioContext.js | 52 + .../protocols/matrix/lib/matrix-sdk/webrtc/call.js | 2364 ++++++ .../lib/matrix-sdk/webrtc/callEventHandler.js | 339 + .../matrix/lib/matrix-sdk/webrtc/callEventTypes.js | 19 + .../matrix/lib/matrix-sdk/webrtc/callFeed.js | 294 + .../matrix/lib/matrix-sdk/webrtc/groupCall.js | 1213 ++++ .../lib/matrix-sdk/webrtc/groupCallEventHandler.js | 181 + .../matrix/lib/matrix-sdk/webrtc/mediaHandler.js | 395 + .../webrtc/stats/callStatsReportGatherer.js | 194 + .../webrtc/stats/callStatsReportSummary.js | 5 + .../lib/matrix-sdk/webrtc/stats/connectionStats.js | 34 + .../webrtc/stats/connectionStatsBuilder.js | 33 + .../webrtc/stats/connectionStatsReportBuilder.js | 127 + .../lib/matrix-sdk/webrtc/stats/groupCallStats.js | 80 + .../webrtc/stats/media/mediaSsrcHandler.js | 62 + .../webrtc/stats/media/mediaTrackHandler.js | 69 + .../webrtc/stats/media/mediaTrackStats.js | 150 + .../webrtc/stats/media/mediaTrackStatsHandler.js | 82 + .../lib/matrix-sdk/webrtc/stats/statsReport.js | 28 + .../matrix-sdk/webrtc/stats/statsReportEmitter.js | 36 + .../webrtc/stats/summaryStatsReportGatherer.js | 103 + .../matrix-sdk/webrtc/stats/trackStatsBuilder.js | 172 + .../lib/matrix-sdk/webrtc/stats/transportStats.js | 5 + .../webrtc/stats/transportStatsBuilder.js | 40 + .../lib/matrix-sdk/webrtc/stats/valueFormatter.js | 31 + .../lib/matrix-widget-api/ClientWidgetApi.js | 1126 +++ .../protocols/matrix/lib/matrix-widget-api/LICENSE | 201 + .../matrix/lib/matrix-widget-api/Symbols.js | 27 + .../matrix/lib/matrix-widget-api/WidgetApi.js | 808 +++ .../lib/matrix-widget-api/driver/WidgetDriver.js | 239 + .../matrix/lib/matrix-widget-api/index.js | 512 ++ .../lib/matrix-widget-api/interfaces/ApiVersion.js | 45 + .../matrix-widget-api/interfaces/Capabilities.js | 69 + .../interfaces/CapabilitiesAction.js | 6 + .../interfaces/ContentLoadedAction.js | 6 + .../interfaces/GetOpenIDAction.js | 29 + .../interfaces/ICustomWidgetData.js | 6 + .../interfaces/IJitsiWidgetData.js | 6 + .../lib/matrix-widget-api/interfaces/IRoomEvent.js | 6 + .../interfaces/IStickerpickerWidgetData.js | 6 + .../lib/matrix-widget-api/interfaces/IWidget.js | 6 + .../interfaces/IWidgetApiErrorResponse.js | 30 + .../interfaces/IWidgetApiRequest.js | 6 + .../interfaces/IWidgetApiResponse.js | 6 + .../interfaces/ModalButtonKind.js | 31 + .../interfaces/ModalWidgetActions.js | 30 + .../matrix-widget-api/interfaces/NavigateAction.js | 6 + .../interfaces/OpenIDCredentialsAction.js | 6 + .../interfaces/ReadEventAction.js | 6 + .../interfaces/ReadRelationsAction.js | 6 + .../interfaces/ScreenshotAction.js | 6 + .../interfaces/SendEventAction.js | 6 + .../interfaces/SendToDeviceAction.js | 6 + .../interfaces/SetModalButtonEnabledAction.js | 6 + .../matrix-widget-api/interfaces/StickerAction.js | 6 + .../matrix-widget-api/interfaces/StickyAction.js | 6 + .../interfaces/SupportedVersionsAction.js | 6 + .../interfaces/TurnServerActions.js | 6 + .../interfaces/UserDirectorySearchAction.js | 6 + .../interfaces/VisibilityAction.js | 6 + .../interfaces/WidgetApiAction.js | 59 + .../interfaces/WidgetApiDirection.js | 38 + .../interfaces/WidgetConfigAction.js | 6 + .../lib/matrix-widget-api/interfaces/WidgetKind.js | 29 + .../lib/matrix-widget-api/interfaces/WidgetType.js | 29 + .../matrix/lib/matrix-widget-api/models/Widget.js | 142 + .../models/WidgetEventCapability.js | 237 + .../lib/matrix-widget-api/models/WidgetParser.js | 152 + .../lib/matrix-widget-api/models/validation/url.js | 39 + .../matrix-widget-api/models/validation/utils.js | 28 + .../matrix-widget-api/templating/url-template.js | 59 + .../lib/matrix-widget-api/transport/ITransport.js | 6 + .../transport/PostmessageTransport.js | 222 + .../lib/matrix-widget-api/util/SimpleObservable.js | 68 + comm/chat/protocols/matrix/lib/moz.build | 365 + comm/chat/protocols/matrix/lib/p-retry/index.js | 85 + comm/chat/protocols/matrix/lib/p-retry/license | 9 + comm/chat/protocols/matrix/lib/retry/License | 21 + comm/chat/protocols/matrix/lib/retry/index.js | 1 + comm/chat/protocols/matrix/lib/retry/lib/retry.js | 100 + .../matrix/lib/retry/lib/retry_operation.js | 162 + .../protocols/matrix/lib/sdp-transform/LICENSE | 22 + .../protocols/matrix/lib/sdp-transform/grammar.js | 494 ++ .../protocols/matrix/lib/sdp-transform/index.js | 11 + .../protocols/matrix/lib/sdp-transform/parser.js | 124 + .../protocols/matrix/lib/sdp-transform/writer.js | 114 + comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE | 22 + .../protocols/matrix/lib/unhomoglyph/data.json | 6313 ++++++++++++++++ .../chat/protocols/matrix/lib/unhomoglyph/index.js | 20 + comm/chat/protocols/matrix/matrix-sdk.sys.mjs | 220 + comm/chat/protocols/matrix/matrix.sys.mjs | 93 + comm/chat/protocols/matrix/matrixAccount.sys.mjs | 3495 +++++++++ comm/chat/protocols/matrix/matrixCommands.sys.mjs | 490 ++ .../protocols/matrix/matrixMessageContent.sys.mjs | 377 + .../protocols/matrix/matrixPowerLevels.sys.mjs | 82 + .../protocols/matrix/matrixTextForEvent.sys.mjs | 330 + comm/chat/protocols/matrix/moz.build | 29 + comm/chat/protocols/matrix/shims/empty.js | 16 + comm/chat/protocols/matrix/shims/loglevel.js | 73 + comm/chat/protocols/matrix/shims/moz.build | 14 + comm/chat/protocols/matrix/shims/safe-buffer.js | 48 + comm/chat/protocols/matrix/shims/uuid.js | 13 + comm/chat/protocols/matrix/test/head.js | 291 + .../protocols/matrix/test/test_matrixAccount.js | 399 + .../protocols/matrix/test/test_matrixCommands.js | 177 + .../protocols/matrix/test/test_matrixMessage.js | 441 ++ .../matrix/test/test_matrixMessageContent.js | 652 ++ .../matrix/test/test_matrixPowerLevels.js | 204 + comm/chat/protocols/matrix/test/test_matrixRoom.js | 928 +++ .../matrix/test/test_matrixTextForEvent.js | 834 +++ .../protocols/matrix/test/test_roomTypeChange.js | 54 + comm/chat/protocols/matrix/test/xpcshell.ini | 12 + comm/chat/protocols/odnoklassniki/components.conf | 15 + .../odnoklassniki/icons/prpl-odnoklassniki-32.png | Bin 0 -> 2165 bytes .../odnoklassniki/icons/prpl-odnoklassniki-48.png | Bin 0 -> 2649 bytes .../odnoklassniki/icons/prpl-odnoklassniki.png | Bin 0 -> 753 bytes comm/chat/protocols/odnoklassniki/jar.mn | 9 + comm/chat/protocols/odnoklassniki/moz.build | 14 + .../protocols/odnoklassniki/odnoklassniki.sys.mjs | 83 + comm/chat/protocols/twitter/components.conf | 15 + .../protocols/twitter/icons/prpl-twitter-32.png | Bin 0 -> 554 bytes .../protocols/twitter/icons/prpl-twitter-48.png | Bin 0 -> 721 bytes .../protocols/twitter/icons/prpl-twitter-left.png | Bin 0 -> 563 bytes comm/chat/protocols/twitter/icons/prpl-twitter.png | Bin 0 -> 319 bytes comm/chat/protocols/twitter/jar.mn | 10 + comm/chat/protocols/twitter/moz.build | 14 + comm/chat/protocols/twitter/twitter.sys.mjs | 62 + comm/chat/protocols/xmpp/.eslintrc.js | 12 + comm/chat/protocols/xmpp/components.conf | 15 + comm/chat/protocols/xmpp/icons/prpl-jabber-32.png | Bin 0 -> 1725 bytes comm/chat/protocols/xmpp/icons/prpl-jabber-48.png | Bin 0 -> 2536 bytes comm/chat/protocols/xmpp/icons/prpl-jabber.png | Bin 0 -> 768 bytes comm/chat/protocols/xmpp/jar.mn | 5 + comm/chat/protocols/xmpp/lib/README.md | 6 + comm/chat/protocols/xmpp/lib/moz.build | 8 + comm/chat/protocols/xmpp/lib/sax/LICENSE | 41 + comm/chat/protocols/xmpp/lib/sax/sax.js | 1648 +++++ comm/chat/protocols/xmpp/moz.build | 26 + comm/chat/protocols/xmpp/sax.sys.mjs | 7 + comm/chat/protocols/xmpp/test/test_authmechs.js | 160 + comm/chat/protocols/xmpp/test/test_dnsSrv.js | 112 + .../xmpp/test/test_parseJidAndNormalization.js | 104 + comm/chat/protocols/xmpp/test/test_parseVCard.js | 139 + comm/chat/protocols/xmpp/test/test_saslPrep.js | 66 + comm/chat/protocols/xmpp/test/test_xmppParser.js | 135 + comm/chat/protocols/xmpp/test/test_xmppXml.js | 103 + comm/chat/protocols/xmpp/test/xpcshell.ini | 11 + comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs | 561 ++ comm/chat/protocols/xmpp/xmpp-base.sys.mjs | 3421 +++++++++ comm/chat/protocols/xmpp/xmpp-commands.sys.mjs | 347 + comm/chat/protocols/xmpp/xmpp-session.sys.mjs | 764 ++ comm/chat/protocols/xmpp/xmpp-xml.sys.mjs | 508 ++ comm/chat/protocols/xmpp/xmpp.sys.mjs | 106 + comm/chat/protocols/yahoo/components.conf | 15 + comm/chat/protocols/yahoo/icons/prpl-yahoo-32.png | Bin 0 -> 1438 bytes comm/chat/protocols/yahoo/icons/prpl-yahoo-48.png | Bin 0 -> 2439 bytes comm/chat/protocols/yahoo/icons/prpl-yahoo.png | Bin 0 -> 531 bytes comm/chat/protocols/yahoo/jar.mn | 9 + comm/chat/protocols/yahoo/moz.build | 14 + comm/chat/protocols/yahoo/yahoo.sys.mjs | 60 + comm/chat/themes/chat-left.svg | 31 + comm/chat/themes/chat.svg | 32 + comm/chat/themes/conv.css | 41 + .../chat/themes/icons/otr-connection-encrypted.svg | 7 + comm/chat/themes/icons/otr-connection-finished.svg | 7 + comm/chat/themes/icons/prpl-generic-32.png | Bin 0 -> 622 bytes comm/chat/themes/icons/prpl-generic-48.png | Bin 0 -> 992 bytes comm/chat/themes/icons/prpl-generic.png | Bin 0 -> 364 bytes comm/chat/themes/icons/prpl-unknown-32.png | Bin 0 -> 1093 bytes comm/chat/themes/icons/prpl-unknown-48.png | Bin 0 -> 1692 bytes comm/chat/themes/icons/prpl-unknown.png | Bin 0 -> 588 bytes comm/chat/themes/imtooltip.css | 31 + comm/chat/themes/jar.mn | 23 + comm/chat/themes/mobile.svg | 27 + comm/chat/themes/moz.build | 6 + comm/chat/themes/otrFingerprintDialog.css | 76 + comm/chat/themes/typed.svg | 18 + comm/chat/themes/typing.svg | 17 + comm/chat/themes/unknown.svg | 15 + 536 files changed, 126598 insertions(+) create mode 100644 comm/chat/chat-prefs.js create mode 100644 comm/chat/components/public/imIAccount.idl create mode 100644 comm/chat/components/public/imIAccountsService.idl create mode 100644 comm/chat/components/public/imICommandsService.idl create mode 100644 comm/chat/components/public/imIContactsService.idl create mode 100644 comm/chat/components/public/imIConversationsService.idl create mode 100644 comm/chat/components/public/imICoreService.idl create mode 100644 comm/chat/components/public/imILogger.idl create mode 100644 comm/chat/components/public/imIStatusInfo.idl create mode 100644 comm/chat/components/public/imITagsService.idl create mode 100644 comm/chat/components/public/imIUserStatusInfo.idl create mode 100644 comm/chat/components/public/moz.build create mode 100644 comm/chat/components/public/prplIConversation.idl create mode 100644 comm/chat/components/public/prplIMessage.idl create mode 100644 comm/chat/components/public/prplIPref.idl create mode 100644 comm/chat/components/public/prplIProtocol.idl create mode 100644 comm/chat/components/public/prplIRequest.idl create mode 100644 comm/chat/components/public/prplITooltipInfo.idl create mode 100644 comm/chat/components/src/components.conf create mode 100644 comm/chat/components/src/imAccounts.sys.mjs create mode 100644 comm/chat/components/src/imCommands.sys.mjs create mode 100644 comm/chat/components/src/imContacts.sys.mjs create mode 100644 comm/chat/components/src/imConversations.sys.mjs create mode 100644 comm/chat/components/src/imCore.sys.mjs create mode 100644 comm/chat/components/src/logger.sys.mjs create mode 100644 comm/chat/components/src/moz.build create mode 100644 comm/chat/components/src/test/test_accounts.js create mode 100644 comm/chat/components/src/test/test_commands.js create mode 100644 comm/chat/components/src/test/test_conversations.js create mode 100644 comm/chat/components/src/test/test_init.js create mode 100644 comm/chat/components/src/test/test_logger.js create mode 100644 comm/chat/components/src/test/xpcshell.ini create mode 100644 comm/chat/content/chat-account-richlistitem.js create mode 100644 comm/chat/content/chat-tooltip.js create mode 100644 comm/chat/content/conv.html create mode 100644 comm/chat/content/conversation-browser.js create mode 100644 comm/chat/content/imAccountOptionsHelper.js create mode 100644 comm/chat/content/jar.mn create mode 100644 comm/chat/content/moz.build create mode 100644 comm/chat/content/otr-add-fingerprint.js create mode 100644 comm/chat/content/otr-add-fingerprint.xhtml create mode 100644 comm/chat/content/otr-auth.js create mode 100644 comm/chat/content/otr-auth.xhtml create mode 100644 comm/chat/content/otr-finger.js create mode 100644 comm/chat/content/otr-finger.xhtml create mode 100644 comm/chat/content/otrWorker.js create mode 100644 comm/chat/locales/Makefile.in create mode 100644 comm/chat/locales/en-US/accounts.dtd create mode 100644 comm/chat/locales/en-US/accounts.properties create mode 100644 comm/chat/locales/en-US/commands.properties create mode 100644 comm/chat/locales/en-US/contacts.properties create mode 100644 comm/chat/locales/en-US/conversations.properties create mode 100644 comm/chat/locales/en-US/facebook.properties create mode 100644 comm/chat/locales/en-US/imtooltip.properties create mode 100644 comm/chat/locales/en-US/irc.properties create mode 100644 comm/chat/locales/en-US/logger.properties create mode 100644 comm/chat/locales/en-US/matrix.ftl create mode 100644 comm/chat/locales/en-US/matrix.properties create mode 100644 comm/chat/locales/en-US/status.properties create mode 100644 comm/chat/locales/en-US/twitter.properties create mode 100644 comm/chat/locales/en-US/xmpp.properties create mode 100644 comm/chat/locales/en-US/yahoo.properties create mode 100644 comm/chat/locales/jar.mn create mode 100644 comm/chat/locales/moz.build create mode 100644 comm/chat/modules/CLib.sys.mjs create mode 100644 comm/chat/modules/IMServices.sys.mjs create mode 100644 comm/chat/modules/InteractiveBrowser.sys.mjs create mode 100644 comm/chat/modules/NormalizedMap.sys.mjs create mode 100644 comm/chat/modules/OTR.sys.mjs create mode 100644 comm/chat/modules/OTRLib.sys.mjs create mode 100644 comm/chat/modules/OTRUI.sys.mjs create mode 100644 comm/chat/modules/ToLocaleFormat.sys.mjs create mode 100644 comm/chat/modules/imContentSink.sys.mjs create mode 100644 comm/chat/modules/imSmileys.sys.mjs create mode 100644 comm/chat/modules/imStatusUtils.sys.mjs create mode 100644 comm/chat/modules/imTextboxUtils.sys.mjs create mode 100644 comm/chat/modules/imThemes.sys.mjs create mode 100644 comm/chat/modules/imXPCOMUtils.sys.mjs create mode 100644 comm/chat/modules/jsProtoHelper.sys.mjs create mode 100644 comm/chat/modules/moz.build create mode 100644 comm/chat/modules/socket.sys.mjs create mode 100644 comm/chat/modules/test/test_InteractiveBrowser.js create mode 100644 comm/chat/modules/test/test_NormalizedMap.js create mode 100644 comm/chat/modules/test/test_filtering.js create mode 100644 comm/chat/modules/test/test_imThemes.js create mode 100644 comm/chat/modules/test/test_jsProtoHelper.js create mode 100644 comm/chat/modules/test/test_otrlib.js create mode 100644 comm/chat/modules/test/xpcshell.ini create mode 100644 comm/chat/moz.build create mode 100644 comm/chat/protocols/facebook/components.conf create mode 100644 comm/chat/protocols/facebook/facebook.sys.mjs create mode 100644 comm/chat/protocols/facebook/icons/prpl-facebook-32.png create mode 100644 comm/chat/protocols/facebook/icons/prpl-facebook-48.png create mode 100644 comm/chat/protocols/facebook/icons/prpl-facebook.png create mode 100644 comm/chat/protocols/facebook/jar.mn create mode 100644 comm/chat/protocols/facebook/moz.build create mode 100644 comm/chat/protocols/gtalk/components.conf create mode 100644 comm/chat/protocols/gtalk/gtalk.sys.mjs create mode 100644 comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png create mode 100644 comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png create mode 100644 comm/chat/protocols/gtalk/icons/prpl-gtalk.png create mode 100644 comm/chat/protocols/gtalk/jar.mn create mode 100644 comm/chat/protocols/gtalk/moz.build create mode 100644 comm/chat/protocols/irc/components.conf create mode 100644 comm/chat/protocols/irc/icons/prpl-irc-32.png create mode 100644 comm/chat/protocols/irc/icons/prpl-irc-48.png create mode 100644 comm/chat/protocols/irc/icons/prpl-irc.png create mode 100644 comm/chat/protocols/irc/irc.sys.mjs create mode 100644 comm/chat/protocols/irc/ircAccount.sys.mjs create mode 100644 comm/chat/protocols/irc/ircBase.sys.mjs create mode 100644 comm/chat/protocols/irc/ircCAP.sys.mjs create mode 100644 comm/chat/protocols/irc/ircCTCP.sys.mjs create mode 100644 comm/chat/protocols/irc/ircCommands.sys.mjs create mode 100644 comm/chat/protocols/irc/ircDCC.sys.mjs create mode 100644 comm/chat/protocols/irc/ircEchoMessage.sys.mjs create mode 100644 comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs create mode 100644 comm/chat/protocols/irc/ircHandlers.sys.mjs create mode 100644 comm/chat/protocols/irc/ircISUPPORT.sys.mjs create mode 100644 comm/chat/protocols/irc/ircMultiPrefix.sys.mjs create mode 100644 comm/chat/protocols/irc/ircNonStandard.sys.mjs create mode 100644 comm/chat/protocols/irc/ircSASL.sys.mjs create mode 100644 comm/chat/protocols/irc/ircServerTime.sys.mjs create mode 100644 comm/chat/protocols/irc/ircServices.sys.mjs create mode 100644 comm/chat/protocols/irc/ircUtils.sys.mjs create mode 100644 comm/chat/protocols/irc/ircWatchMonitor.sys.mjs create mode 100644 comm/chat/protocols/irc/jar.mn create mode 100644 comm/chat/protocols/irc/moz.build create mode 100644 comm/chat/protocols/irc/test/test_ctcpColoring.js create mode 100644 comm/chat/protocols/irc/test/test_ctcpDequote.js create mode 100644 comm/chat/protocols/irc/test/test_ctcpFormatting.js create mode 100644 comm/chat/protocols/irc/test/test_ctcpQuote.js create mode 100644 comm/chat/protocols/irc/test/test_ircCAP.js create mode 100644 comm/chat/protocols/irc/test/test_ircChannel.js create mode 100644 comm/chat/protocols/irc/test/test_ircCommands.js create mode 100644 comm/chat/protocols/irc/test/test_ircMessage.js create mode 100644 comm/chat/protocols/irc/test/test_ircNonStandard.js create mode 100644 comm/chat/protocols/irc/test/test_ircProtocol.js create mode 100644 comm/chat/protocols/irc/test/test_ircServerTime.js create mode 100644 comm/chat/protocols/irc/test/test_sendBufferedCommand.js create mode 100644 comm/chat/protocols/irc/test/test_setMode.js create mode 100644 comm/chat/protocols/irc/test/test_splitLongMessages.js create mode 100644 comm/chat/protocols/irc/test/test_tryNewNick.js create mode 100644 comm/chat/protocols/irc/test/xpcshell.ini create mode 100644 comm/chat/protocols/jsTest/components.conf create mode 100644 comm/chat/protocols/jsTest/jsTestProtocol.sys.mjs create mode 100644 comm/chat/protocols/jsTest/moz.build create mode 100644 comm/chat/protocols/matrix/components.conf create mode 100644 comm/chat/protocols/matrix/icons/README create mode 100644 comm/chat/protocols/matrix/icons/prpl-matrix-32.png create mode 100644 comm/chat/protocols/matrix/icons/prpl-matrix-48.png create mode 100644 comm/chat/protocols/matrix/icons/prpl-matrix.png create mode 100644 comm/chat/protocols/matrix/jar.mn create mode 100644 comm/chat/protocols/matrix/lib/@matrix-org/olm/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.js create mode 100755 comm/chat/protocols/matrix/lib/@matrix-org/olm/olm.wasm create mode 100644 comm/chat/protocols/matrix/lib/README.md create mode 100644 comm/chat/protocols/matrix/lib/another-json/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/another-json/another-json.js create mode 100644 comm/chat/protocols/matrix/lib/base-x/LICENSE.md create mode 100644 comm/chat/protocols/matrix/lib/base-x/index.js create mode 100644 comm/chat/protocols/matrix/lib/bs58/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/bs58/index.js create mode 100644 comm/chat/protocols/matrix/lib/content-type/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/content-type/index.js create mode 100644 comm/chat/protocols/matrix/lib/events/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/events/events.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/ExtensibleEvents.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/IPartialEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/InvalidEventError.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedMap.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/NamespacedValue.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/EmoteEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/ExtensibleEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/MessageEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/NoticeEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollEndEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollResponseEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/PollStartEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/message_types.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/poll_types.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/events/relationship_types.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/legacy/MRoomMessage.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MMessage.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/interpreters/modern/MPoll.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/types.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/MessageMatchers.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-events-sdk/utility/events.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/IIdentityServerProvider.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/PushRules.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/another-json.d.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/auth.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/beacon.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/crypto.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/event.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/extensible_events.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/global.d.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/local_notifications.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/location.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/partials.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/polls.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/read_receipts.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/requests.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/search.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/signed.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/spaces.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/synapse.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/sync.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/threepids.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/topic.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/@types/uia.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/NamespacedValue.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/ToDeviceMessageQueue.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/browser-index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/client.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/common-crypto/CryptoBackend.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/content-repo.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto-api/verification.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/CrossSigning.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/EncryptionSetup.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretSharing.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/SecretStorage.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/aes.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/api.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/backup.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/crypto.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/dehydration.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/device-converter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/key_passphrase.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/keybackup.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/IllegalMethod.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SASDecimal.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/Channel.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/InRoomChannel.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/ToDeviceChannel.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/request/VerificationRequest.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/embedded.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/errors.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/event-mapper.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/ExtensibleEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/InvalidEventError.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/MessageEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollEndEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollResponseEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/PollStartEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/extensible_events_v1/utilities.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/feature.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/filter-component.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/filter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/errors.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/fetch.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/interface.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/method.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/prefix.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/http-api/utils.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/logger.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/matrix.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089Branch.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/MSC3089TreeSpace.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/ToDeviceMessage.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/beacon.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/device.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/event-status.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/event.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/invites-ignorer.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/poll.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/read-receipt.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/related-relations.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/relations-container.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/relations.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/room.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/thread.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/typed-event-emitter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/models/user.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/randomstring.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/receipt-accumulator.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/MSC3906Rendezvous.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousChannel.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousCode.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousError.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousFailureReason.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousIntent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/RendezvousTransport.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/channels/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rendezvous/transports/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/room-hierarchy.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/CrossSigningIdentity.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/KeyClaimManager.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/OutgoingRequestProcessor.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/RoomEncryptor.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/browserify-index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/constants.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/device-converter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/rust-crypto/rust-crypto.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/scheduler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/secret-storage.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/service-types.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync-sdk.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/sliding-sync.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-backend.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/local-storage-events-emitter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/memory.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/store/stub.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/sync.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/utils.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/audioContext.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callEventTypes.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/callFeed.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCall.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/groupCallEventHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/mediaHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportGatherer.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/callStatsReportSummary.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStats.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsBuilder.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/connectionStatsReportBuilder.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/groupCallStats.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaSsrcHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStats.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/media/mediaTrackStatsHandler.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReport.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/statsReportEmitter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/summaryStatsReportGatherer.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/trackStatsBuilder.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStats.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/transportStatsBuilder.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-sdk/webrtc/stats/valueFormatter.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/ClientWidgetApi.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/Symbols.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/WidgetApi.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/driver/WidgetDriver.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/index.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ApiVersion.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/Capabilities.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/CapabilitiesAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ContentLoadedAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/GetOpenIDAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ICustomWidgetData.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IJitsiWidgetData.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IRoomEvent.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IStickerpickerWidgetData.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidget.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiErrorResponse.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiRequest.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/IWidgetApiResponse.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalButtonKind.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ModalWidgetActions.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/NavigateAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/OpenIDCredentialsAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadEventAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ReadRelationsAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/ScreenshotAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendEventAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SendToDeviceAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SetModalButtonEnabledAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickerAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/StickyAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/SupportedVersionsAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/TurnServerActions.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/UserDirectorySearchAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/VisibilityAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetApiDirection.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetConfigAction.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetKind.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/interfaces/WidgetType.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/models/Widget.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetEventCapability.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/models/WidgetParser.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/url.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/models/validation/utils.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/templating/url-template.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/transport/ITransport.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/transport/PostmessageTransport.js create mode 100644 comm/chat/protocols/matrix/lib/matrix-widget-api/util/SimpleObservable.js create mode 100644 comm/chat/protocols/matrix/lib/moz.build create mode 100644 comm/chat/protocols/matrix/lib/p-retry/index.js create mode 100644 comm/chat/protocols/matrix/lib/p-retry/license create mode 100644 comm/chat/protocols/matrix/lib/retry/License create mode 100644 comm/chat/protocols/matrix/lib/retry/index.js create mode 100644 comm/chat/protocols/matrix/lib/retry/lib/retry.js create mode 100644 comm/chat/protocols/matrix/lib/retry/lib/retry_operation.js create mode 100644 comm/chat/protocols/matrix/lib/sdp-transform/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/sdp-transform/grammar.js create mode 100644 comm/chat/protocols/matrix/lib/sdp-transform/index.js create mode 100644 comm/chat/protocols/matrix/lib/sdp-transform/parser.js create mode 100644 comm/chat/protocols/matrix/lib/sdp-transform/writer.js create mode 100644 comm/chat/protocols/matrix/lib/unhomoglyph/LICENSE create mode 100644 comm/chat/protocols/matrix/lib/unhomoglyph/data.json create mode 100644 comm/chat/protocols/matrix/lib/unhomoglyph/index.js create mode 100644 comm/chat/protocols/matrix/matrix-sdk.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrix.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrixAccount.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrixCommands.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrixMessageContent.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrixPowerLevels.sys.mjs create mode 100644 comm/chat/protocols/matrix/matrixTextForEvent.sys.mjs create mode 100644 comm/chat/protocols/matrix/moz.build create mode 100644 comm/chat/protocols/matrix/shims/empty.js create mode 100644 comm/chat/protocols/matrix/shims/loglevel.js create mode 100644 comm/chat/protocols/matrix/shims/moz.build create mode 100644 comm/chat/protocols/matrix/shims/safe-buffer.js create mode 100644 comm/chat/protocols/matrix/shims/uuid.js create mode 100644 comm/chat/protocols/matrix/test/head.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixAccount.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixCommands.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixMessage.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixMessageContent.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixPowerLevels.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixRoom.js create mode 100644 comm/chat/protocols/matrix/test/test_matrixTextForEvent.js create mode 100644 comm/chat/protocols/matrix/test/test_roomTypeChange.js create mode 100644 comm/chat/protocols/matrix/test/xpcshell.ini create mode 100644 comm/chat/protocols/odnoklassniki/components.conf create mode 100644 comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-32.png create mode 100644 comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki-48.png create mode 100644 comm/chat/protocols/odnoklassniki/icons/prpl-odnoklassniki.png create mode 100644 comm/chat/protocols/odnoklassniki/jar.mn create mode 100644 comm/chat/protocols/odnoklassniki/moz.build create mode 100644 comm/chat/protocols/odnoklassniki/odnoklassniki.sys.mjs create mode 100644 comm/chat/protocols/twitter/components.conf create mode 100644 comm/chat/protocols/twitter/icons/prpl-twitter-32.png create mode 100644 comm/chat/protocols/twitter/icons/prpl-twitter-48.png create mode 100644 comm/chat/protocols/twitter/icons/prpl-twitter-left.png create mode 100644 comm/chat/protocols/twitter/icons/prpl-twitter.png create mode 100644 comm/chat/protocols/twitter/jar.mn create mode 100644 comm/chat/protocols/twitter/moz.build create mode 100644 comm/chat/protocols/twitter/twitter.sys.mjs create mode 100644 comm/chat/protocols/xmpp/.eslintrc.js create mode 100644 comm/chat/protocols/xmpp/components.conf create mode 100644 comm/chat/protocols/xmpp/icons/prpl-jabber-32.png create mode 100644 comm/chat/protocols/xmpp/icons/prpl-jabber-48.png create mode 100644 comm/chat/protocols/xmpp/icons/prpl-jabber.png create mode 100644 comm/chat/protocols/xmpp/jar.mn create mode 100644 comm/chat/protocols/xmpp/lib/README.md create mode 100644 comm/chat/protocols/xmpp/lib/moz.build create mode 100644 comm/chat/protocols/xmpp/lib/sax/LICENSE create mode 100644 comm/chat/protocols/xmpp/lib/sax/sax.js create mode 100644 comm/chat/protocols/xmpp/moz.build create mode 100644 comm/chat/protocols/xmpp/sax.sys.mjs create mode 100644 comm/chat/protocols/xmpp/test/test_authmechs.js create mode 100644 comm/chat/protocols/xmpp/test/test_dnsSrv.js create mode 100644 comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js create mode 100644 comm/chat/protocols/xmpp/test/test_parseVCard.js create mode 100644 comm/chat/protocols/xmpp/test/test_saslPrep.js create mode 100644 comm/chat/protocols/xmpp/test/test_xmppParser.js create mode 100644 comm/chat/protocols/xmpp/test/test_xmppXml.js create mode 100644 comm/chat/protocols/xmpp/test/xpcshell.ini create mode 100644 comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs create mode 100644 comm/chat/protocols/xmpp/xmpp-base.sys.mjs create mode 100644 comm/chat/protocols/xmpp/xmpp-commands.sys.mjs create mode 100644 comm/chat/protocols/xmpp/xmpp-session.sys.mjs create mode 100644 comm/chat/protocols/xmpp/xmpp-xml.sys.mjs create mode 100644 comm/chat/protocols/xmpp/xmpp.sys.mjs create mode 100644 comm/chat/protocols/yahoo/components.conf create mode 100644 comm/chat/protocols/yahoo/icons/prpl-yahoo-32.png create mode 100644 comm/chat/protocols/yahoo/icons/prpl-yahoo-48.png create mode 100644 comm/chat/protocols/yahoo/icons/prpl-yahoo.png create mode 100644 comm/chat/protocols/yahoo/jar.mn create mode 100644 comm/chat/protocols/yahoo/moz.build create mode 100644 comm/chat/protocols/yahoo/yahoo.sys.mjs create mode 100644 comm/chat/themes/chat-left.svg create mode 100644 comm/chat/themes/chat.svg create mode 100644 comm/chat/themes/conv.css create mode 100644 comm/chat/themes/icons/otr-connection-encrypted.svg create mode 100644 comm/chat/themes/icons/otr-connection-finished.svg create mode 100644 comm/chat/themes/icons/prpl-generic-32.png create mode 100644 comm/chat/themes/icons/prpl-generic-48.png create mode 100644 comm/chat/themes/icons/prpl-generic.png create mode 100644 comm/chat/themes/icons/prpl-unknown-32.png create mode 100644 comm/chat/themes/icons/prpl-unknown-48.png create mode 100644 comm/chat/themes/icons/prpl-unknown.png create mode 100644 comm/chat/themes/imtooltip.css create mode 100644 comm/chat/themes/jar.mn create mode 100644 comm/chat/themes/mobile.svg create mode 100644 comm/chat/themes/moz.build create mode 100644 comm/chat/themes/otrFingerprintDialog.css create mode 100644 comm/chat/themes/typed.svg create mode 100644 comm/chat/themes/typing.svg create mode 100644 comm/chat/themes/unknown.svg (limited to 'comm/chat') 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 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} + */ + 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 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 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 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 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 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 . + // Format: : + // Example: "help <name>: show the help message for the <name> + // 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 listCommandsForConversation( + [optional] in prplIConversation aConversation); + + Array 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 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 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 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 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} + */ + 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 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 getUIConversations(); + imIConversation getUIConversation(in prplIConversation aConversation); + imIConversation getUIConversationByContactId(in long aId); + + Array 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 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 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 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 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 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 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} + */ + 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 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 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 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 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 getUsernameSplit(); + + /** + * Split a username into its parts without separators (or prefix). + * Returns an empty array if the username can not be split. + */ + Array 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} + */ + 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} + */ + 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-. + 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} + */ +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%", + "file", + "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", + "\\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( + ` + + + + + + + + + + + + + + + + + + + + + + + + `, + ["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="". + 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(` + + + + + + + + + + + + + + + + + + `) + ); + 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 and 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 @@ + + 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 () { + // 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, "
"), + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + <!-- auth-title --> + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 <command> 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 <message>: send a message without processing commands. +rawHelpString=raw <message>: send a message without escaping HTML entities. +helpHelpString=help <name>: show the help message for the <name> 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 <status message>: 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 <action to perform>: Perform an action. +command.ban=%S <nick!user@host>: Ban the users matching the given pattern. +command.ctcp=%S <nick> <msg>: Sends a CTCP message to the nick. +command.chanserv=%S <command>: Send a command to ChanServ. +command.deop=%S <nick1>[,<nick2>]*: Remove channel operator status from someone. You must be a channel operator to do this. +command.devoice=%S <nick1>[,<nick2>]*: 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 <nick>[ <nick>]* [<channel>]: Invite one or more nicks to join you in the current channel, or to join the specified channel. +command.join=%S <room1>[ <key1>][,<room2>[ <key2>]]*: Enter one or more channels, optionally providing a channel key for each if needed. +command.kick=%S <nick> [<message>]: 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 <command>: Send a command to MemoServ. +command.modeUser2=%S <nick> [(+|-)<mode>]: Get, set or unset a user's mode. +command.modeChannel2=%S [<channel>] [(+|-)<new mode> [<parameter>][,<parameter>]*]: Get, set, or unset a channel mode. +command.msg=%S <nick> <message>: Send a private message to a user (as opposed to a channel). +command.nick=%S <new nickname>: Change your nickname. +command.nickserv=%S <command>: Send a command to NickServ. +command.notice=%S <target> <message>: Send a notice to a user or channel. +command.op=%S <nick1>[,<nick2>]*: Grant channel operator status to someone. You must be a channel operator to do this. +command.operserv=%S <command>: Send a command to OperServ. +command.part=%S [message]: Leave the current channel with an optional message. +command.ping=%S [<nick>]: Asks how much lag a user (or the server if no user specified) has. +command.quit=%S <message>: Disconnect from the server, with an optional message. +command.quote=%S <command>: Send a raw command to the server. +command.time=%S: Displays the current local time at the IRC server. +command.topic=%S [<new topic>]: Set this channel's topic. +command.umode=%S (+|-)<new mode>: Set or unset a user mode. +command.version=%S <nick>: Request the version of a user's client. +command.voice=%S <nick1>[,<nick2>]*: Grant channel voice status to someone. You must be a channel operator to do this. +command.whois2=%S [<nick>]: 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 <userId> [<reason>]: Ban the user with the userId from the room with optional reason message. Requires permission to ban users. +command.invite=%S <userId>: Invite the user to the room. +command.kick=%S <userId> [<reason>]: Kick the user with the userId from the room with optional reason message. Requires permission to kick users. +command.nick=%S <display_name>: Change your display name. +command.op=%S <userId> [<power level>]: 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 <userId>: 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 <topic>: Set the topic for the room. Requires permissions to change the room topic. +command.unban=%S <userId>: Unban a user who is banned from the room. Requires permission to ban users. +command.visibility=%S [<visibility>]: 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 <guest access> <history visibility>: 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 <name>: 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 <alias>: Create an alias for the room. Expected room alias of the form '#localname:domain'. Requires permission to add aliases. +command.removealias=%S <alias>: Remove the alias for the room. Expected room alias of the form '#localname:domain'. Requires permission to remove aliases. +command.upgraderoom=%S <newVersion>: Upgrade room to given version. Requires permission to upgrade the room. +command.me=%S <action>: Perform an action. +command.msg=%S <userId> <message>: Send a direct message to the given user. +command.join=%S <roomId>: 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 [<room>[@<server>][/<nick>]] [<password>]: Join a room, optionally providing a different server, or nickname, or the room password. +command.part2=%S [<message>]: Leave the current room with an optional message. +command.topic=%S [<new topic>]: Set this room's topic. +command.ban=%S <nick>[<message>]: Ban someone from the room. You must be a room administrator to do this. +command.kick=%S <nick>[<message>]: Remove someone from the room. You must be a room moderator to do this. +command.invite=%S <jid>[<message>]: Invite a user to join the current room with an optional message. +command.inviteto=%S <room jid>[<password>]: Invite your conversation partner to join a room, together with its password if required. +command.me=%S <action to perform>: Perform an action. +command.nick=%S <new nickname>: Change your nickname. +command.msg=%S <nick> <message>: 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} 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} 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} 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 + // + 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}} + */ + +/** + * 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} 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} 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 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 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 to avoid losing leading whitespace. + let doc = parser.parseFromString( + "" + aText + "", + "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 => + '' + + (aMsg.autoResponse ? formatAutoResponce(aMsg.message) : aMsg.message) + + "", + 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 `${lazy.TXTToHTML(aName)}`; +} +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 = '
'; + 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} 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}>} + * 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(, [, ("starttls" | "ssl" | "udp") + * [, [, , ]]]) + * disconnect() + * sendData(String [, ]) + * sendString(String [, [, ]]) + * 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 ) + * onTransportStatus(nsISocketTransport , nsresult , + * unsigned long , unsigned long ) + * sendPing() + * LOG() + * DEBUG() + * + * 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 + "<html>&", // keep escaped characters + ]; + for (let string of strings) { + Assert.equal(string, cleanupImMarkup(string)); + } +} + +function test_paragraphs() { + const strings = ["

foo

bar

", "

foo
bar

", "foo
bar"]; + for (let string of strings) { + Assert.equal(string, cleanupImMarkup(string)); + } +} + +function test_stripScripts() { + const strings = [ + ["", ""], + ["foo ", "foo "], + ["

foo

", "

foo

"], + ["

foo

", "

foo

"], + ]; + 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 = 'foo'; + 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( + "foo", + cleanupImMarkup('foo') + ); + } + + // keep link titles + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); +} + +function test_table() { + const table = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
test table
keydata
loremipsum
"; + Assert.equal(table, cleanupImMarkup(table)); +} + +function test_allModes() { + test_plainText(); + test_paragraphs(); + test_stripScripts(); + test_links(); + // Remove random classes. + Assert.equal("

foo

", cleanupImMarkup('

foo

')); + // Test unparsable style. + Assert.equal("

foo

", cleanupImMarkup('

foo

')); +} + +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")); + } + + // check that font settings are removed. + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + Assert.equal( + "

foo

", + cleanupImMarkup('

foo

') + ); + + // Discard hr + Assert.equal("foobar", cleanupImMarkup("foo
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"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep special allowed classes. + for (let className of ["moz-txt-underscore", "moz-txt-tag"]) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Remove font settings + let font_string = 'foo'; + Assert.equal("foo", cleanupImMarkup(font_string)); + + // Discard hr + Assert.equal("foobar", cleanupImMarkup("foo
bar")); + + const okCSS = ["font-style: italic", "font-weight: bold"]; + for (let css of okCSS) { + let string = 'foo'; + 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( + 'foo', + cleanupImMarkup('foo') + ); + + 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( + "foo", + cleanupImMarkup('foo') + ); + } + // The shorthand 'font' is decomposed to non-shorthand properties, + // and not recomposed as some non-shorthand properties are filtered out. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + // Discard headings + const heading1 = "test heading"; + Assert.equal(heading1, cleanupImMarkup(`

${heading1}

`)); + + // Setting the start number of an
    is allowed + const olWithOffset = '
    1. two
    2. three
    '; + 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"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep special allowed classes. + for (let className of ["moz-txt-underscore", "moz-txt-tag"]) { + let string = 'foo'; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Keep font settings + const fontAttributes = ['face="Times"', 'color="pink"', 'size="3"']; + for (let fontAttribute of fontAttributes) { + let string = "foo"; + Assert.equal(string, cleanupImMarkup(string)); + } + + // Allow hr + let hr_string = "foo
    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 = 'foo'; + 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( + 'foo', + cleanupImMarkup('foo') + ); + + // The shorthand 'font' is decomposed to non-shorthand properties, + // and not recomposed as some non-shorthand properties are filtered out. + Assert.equal( + 'foo', + cleanupImMarkup('foo') + ); + + // But still filter out dangerous CSS rules. + const badCSS = [ + "display: none", + "visibility: hidden", + "unsupported-by-gecko: blah", + ]; + for (let css of badCSS) { + Assert.equal( + "foo", + cleanupImMarkup('foo') + ); + } + + run_next_test(); +} + +function test_addGlobalAllowedTag() { + Services.prefs.setIntPref(kModePref, kStrictMode); + + // Check that
    isn't allowed by default in strict mode. + // Note: we use
    instead of to avoid mailnews' content policy + // messing things up. + Assert.equal("", cleanupImMarkup("
    ")); + + // Allow
    without attributes. + addGlobalAllowedTag("hr"); + Assert.equal("
    ", cleanupImMarkup("
    ")); + Assert.equal("
    ", cleanupImMarkup('
    ')); + removeGlobalAllowedTag("hr"); + + // Allow
    with an unfiltered src attribute. + addGlobalAllowedTag("hr", { src: true }); + Assert.equal("
    ", cleanupImMarkup('
    ')); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + removeGlobalAllowedTag("hr"); + + // Allow
    with an src attribute taking only http(s) urls. + addGlobalAllowedTag("hr", { src: aValue => /^https?:/.test(aValue) }); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + Assert.equal( + "
    ", + cleanupImMarkup('
    ') + ); + 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("
    ", cleanupImMarkup('
    ')); + + // Allow id unconditionally. + addGlobalAllowedAttribute("id"); + Assert.equal('
    ', cleanupImMarkup('
    ')); + removeGlobalAllowedAttribute("id"); + + // Allow id only with numbers. + addGlobalAllowedAttribute("id", aId => /^\d+$/.test(aId)); + Assert.equal('
    ', cleanupImMarkup('
    ')); + Assert.equal("
    ", cleanupImMarkup('
    ')); + 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("
    ", cleanupImMarkup('
    ')); + + // Allow clear. + addGlobalAllowedStyleRule("clear"); + Assert.equal( + '
    ', + cleanupImMarkup('
    ') + ); + removeGlobalAllowedStyleRule("clear"); + + run_next_test(); +} + +function test_createDerivedRuleset() { + Services.prefs.setIntPref(kModePref, kStandardMode); + + let rules = createDerivedRuleset(); + + let string = "
    "; + Assert.equal("", cleanupImMarkup(string)); + Assert.equal("", cleanupImMarkup(string, rules)); + rules.tags.hr = true; + Assert.equal(string, cleanupImMarkup(string, rules)); + + string = '
    '; + Assert.equal("
    ", cleanupImMarkup(string)); + Assert.equal("
    ", cleanupImMarkup(string, rules)); + rules.attrs.id = true; + Assert.equal(string, cleanupImMarkup(string, rules)); + + string = '
    '; + Assert.equal("
    ", cleanupImMarkup(string)); + Assert.equal("
    ", 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 = + '
    '; + +add_task(function test_initHTMLDocument() { + const window = {}; + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.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 = '
    foo bar
    '; + 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 = '
    foo bar
    '; + 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: + '%sender%%message%', + }, + }; + const html = getHTMLForMessage(message, theme, false, false); + equal( + html, + 'display namefoo bar' + ); +}); + +add_task(function test_replaceHTMLForMessage() { + const document = MockDocument.createTestDocument( + "chrome://chat/content/conv.html", + BASIC_CONV_DOCUMENT_HTML + ); + const html = '
    foo bar
    '; + 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 = + '
    lorem ipsum
    '; + 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 = '
    lorem ipsum
    '; + 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 = '
    foo bar
    '; + 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 = '
    lorem ipsum
    '; + 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 = "
    foo bar
    "; + 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 = "
    foo bar
    "; + 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 = '
    foo bar
    '; + 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 = '
    foo bar
    '; + 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 Binary files /dev/null and b/comm/chat/protocols/facebook/icons/prpl-facebook-32.png 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 Binary files /dev/null and b/comm/chat/protocols/facebook/icons/prpl-facebook-48.png 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 Binary files /dev/null and b/comm/chat/protocols/facebook/icons/prpl-facebook.png 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 Binary files /dev/null and b/comm/chat/protocols/gtalk/icons/prpl-gtalk-32.png 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 Binary files /dev/null and b/comm/chat/protocols/gtalk/icons/prpl-gtalk-48.png 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 Binary files /dev/null and b/comm/chat/protocols/gtalk/icons/prpl-gtalk.png 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 Binary files /dev/null and b/comm/chat/protocols/irc/icons/prpl-irc-32.png 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 Binary files /dev/null and b/comm/chat/protocols/irc/icons/prpl-irc-48.png 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 Binary files /dev/null and b/comm/chat/protocols/irc/icons/prpl-irc.png 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 + * @ or 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: + // ["@" " "] [":" " "] [" " ]* [":" ] + // : /[^ ]+/ + // : :( | [["!" ] "@" ]) + // : /[^ ]+/ + // : /[^ ]+/ + // : /.+/ + // 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 + // s when no 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 ? " " : "") + ); + }; + + 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 *("," ) [ *("," )] + // 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. 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 []*\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 " + ); + } + + // 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 + // 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 + 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 ( *( "," ) [ *( "," ) ] ) / "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 *( "," ) *( "," ) [] + 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 *( ( "+" / "-") *( "i" / "w" / "o" / "O" / "r" ) ) + // MODE *( ( "-" / "+" ) * * ) + 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 + this.changeBuddyNick(aMessage.origin, aMessage.params[0]); + return true; + }, + NOTICE(aMessage) { + // NOTICE + // 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 *( "," ) [ ] + return leftRoom( + this, + [aMessage.origin], + aMessage.params[0].split(","), + aMessage.source, + aMessage.params.length == 2 ? aMessage.params[1] : null + ); + }, + PING(aMessage) { + // PING [ ] + // Keep the connection alive. + this.sendMessage("PONG", aMessage.params[0]); + return true; + }, + PONG(aMessage) { + // PONG [ ] + 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 + // 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) { + // + return true; + }, + TOPIC(aMessage) { + // 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 !@ + 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 , running version + return serverMessage(this, aMessage); + }, + "003": function (aMessage) { + // RPL_CREATED + // This server was created + // TODO parse this date and keep it for some reason? Do we care? + return serverMessage(this, aMessage); + }, + "004": function (aMessage) { + // RPL_MYINFO + // + // TODO parse the available modes, let the UI respond and inform the user + return serverMessage(this, aMessage); + }, + "005": function (aMessage) { + // RPL_BOUNCE + // Try server , port + return serverMessage(this, aMessage); + }, + + /* + * Handle response to TRACE message + */ + 200(aMessage) { + // RPL_TRACELINK + // Link + // V + // + return serverMessage(this, aMessage); + }, + 201(aMessage) { + // RPL_TRACECONNECTING + // Try. + return serverMessage(this, aMessage); + }, + 202(aMessage) { + // RPL_TRACEHANDSHAKE + // H.S. + return serverMessage(this, aMessage); + }, + 203(aMessage) { + // RPL_TRACEUNKNOWN + // ???? [] + return serverMessage(this, aMessage); + }, + 204(aMessage) { + // RPL_TRACEOPERATOR + // Oper + return serverMessage(this, aMessage); + }, + 205(aMessage) { + // RPL_TRACEUSER + // User + return serverMessage(this, aMessage); + }, + 206(aMessage) { + // RPL_TRACESERVER + // Serv S C @ + // V + return serverMessage(this, aMessage); + }, + 207(aMessage) { + // RPL_TRACESERVICE + // Service + return serverMessage(this, aMessage); + }, + 208(aMessage) { + // RPL_TRACENEWTYPE + // 0 + return serverMessage(this, aMessage); + }, + 209(aMessage) { + // RPL_TRACECLASS + // Class + return serverMessage(this, aMessage); + }, + 210(aMessage) { + // RPL_TRACERECONNECTION + // Unused. + return serverMessage(this, aMessage); + }, + + /* + * Handle stats messages. + **/ + 211(aMessage) { + // RPL_STATSLINKINFO + // + //