diff options
Diffstat (limited to 'comm/chat/modules/jsProtoHelper.sys.mjs')
-rw-r--r-- | comm/chat/modules/jsProtoHelper.sys.mjs | 1796 |
1 files changed, 1796 insertions, 0 deletions
diff --git a/comm/chat/modules/jsProtoHelper.sys.mjs b/comm/chat/modules/jsProtoHelper.sys.mjs new file mode 100644 index 0000000000..b792a02ffe --- /dev/null +++ b/comm/chat/modules/jsProtoHelper.sys.mjs @@ -0,0 +1,1796 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + initLogModule, + nsSimpleEnumerator, + l10nHelper, + ClassInfo, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/conversations.properties") +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTXT => cs.scanTXT(aTXT, cs.kEntities); +}); + +function OutgoingMessage(aMsg, aConversation) { + this.message = aMsg; + this.conversation = aConversation; +} +OutgoingMessage.prototype = { + __proto__: ClassInfo("imIOutgoingMessage", "Outgoing Message"), + cancelled: false, + action: false, + notification: false, +}; + +export var GenericAccountPrototype = { + __proto__: ClassInfo("prplIAccount", "generic account object"), + get wrappedJSObject() { + return this; + }, + _init(aProtocol, aImAccount) { + this.protocol = aProtocol; + this.imAccount = aImAccount; + initLogModule(aProtocol.id, this); + }, + observe(aSubject, aTopic, aData) {}, + remove() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + unInit() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + connect() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + disconnect() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + createConversation(aName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + joinChat(aComponents) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + setBool(aName, aVal) {}, + setInt(aName, aVal) {}, + setString(aName, aVal) {}, + + get name() { + return this.imAccount.name; + }, + get connected() { + return this.imAccount.connected; + }, + get connecting() { + return this.imAccount.connecting; + }, + get disconnected() { + return this.imAccount.disconnected; + }, + get disconnecting() { + return this.imAccount.disconnecting; + }, + _connectionErrorReason: Ci.prplIAccount.NO_ERROR, + get connectionErrorReason() { + return this._connectionErrorReason; + }, + + /** + * Convert a socket's nsITransportSecurityInfo into a prplIAccount connection error. Store + * the nsITransportSecurityInfo and the connection location on the account so the + * certificate exception dialog can access the information. + * + * @param {Socket} aSocket - Socket where the connection error occurred. + * @returns {number} The prplIAccount error constant describing the problem. + */ + handleConnectionSecurityError(aSocket) { + // Stash away the connectionTarget and securityInfo. + this._connectionTarget = aSocket.host + ":" + aSocket.port; + let securityInfo = (this._securityInfo = aSocket.securityInfo); + + if (!securityInfo) { + return Ci.prplIAccount.ERROR_CERT_NOT_PROVIDED; + } + + if (securityInfo.isUntrusted) { + if (securityInfo.serverCert && securityInfo.serverCert.isSelfSigned) { + return Ci.prplIAccount.ERROR_CERT_SELF_SIGNED; + } + return Ci.prplIAccount.ERROR_CERT_UNTRUSTED; + } + + if (securityInfo.isNotValidAtThisTime) { + if ( + securityInfo.serverCert && + securityInfo.serverCert.validity.notBefore < Date.now() * 1000 + ) { + return Ci.prplIAccount.ERROR_CERT_NOT_ACTIVATED; + } + return Ci.prplIAccount.ERROR_CERT_EXPIRED; + } + + if (securityInfo.isDomainMismatch) { + return Ci.prplIAccount.ERROR_CERT_HOSTNAME_MISMATCH; + } + + // XXX ERROR_CERT_FINGERPRINT_MISMATCH + + return Ci.prplIAccount.ERROR_CERT_OTHER_ERROR; + }, + _connectionTarget: "", + get connectionTarget() { + return this._connectionTarget; + }, + _securityInfo: null, + get securityInfo() { + return this._securityInfo; + }, + + reportConnected() { + this.imAccount.observe(this, "account-connected", null); + }, + reportConnecting(aConnectionStateMsg) { + // Delete any leftover errors from the previous connection. + delete this._connectionTarget; + delete this._securityInfo; + + if (!this.connecting) { + this.imAccount.observe(this, "account-connecting", null); + } + if (aConnectionStateMsg) { + this.imAccount.observe( + this, + "account-connect-progress", + aConnectionStateMsg + ); + } + }, + reportDisconnected() { + this.imAccount.observe(this, "account-disconnected", null); + }, + reportDisconnecting(aConnectionErrorReason, aConnectionErrorMessage) { + this._connectionErrorReason = aConnectionErrorReason; + this.imAccount.observe( + this, + "account-disconnecting", + aConnectionErrorMessage + ); + this.cancelPendingBuddyRequests(); + this.cancelPendingChatRequests(); + this.cancelPendingVerificationRequests(); + }, + + // Called when the user adds a new buddy from the UI. + addBuddy(aTag, aName) { + IMServices.contacts.accountBuddyAdded( + new AccountBuddy(this, null, aTag, aName) + ); + }, + // Called during startup for each of the buddies in the local buddy list. + loadBuddy(aBuddy, aTag) { + try { + return new AccountBuddy(this, aBuddy, aTag); + } catch (x) { + dump(x + "\n"); + return null; + } + }, + + _pendingBuddyRequests: null, + addBuddyRequest(aUserName, aGrantCallback, aDenyCallback) { + if (!this._pendingBuddyRequests) { + this._pendingBuddyRequests = []; + } + let buddyRequest = { + get account() { + return this._account.imAccount; + }, + get userName() { + return aUserName; + }, + _account: this, + // Grant and deny callbacks both receive the auth request object as an + // argument for further use. + grant() { + aGrantCallback(this); + this._remove(); + }, + deny() { + aDenyCallback(this); + this._remove(); + }, + cancel() { + Services.obs.notifyObservers( + this, + "buddy-authorization-request-canceled" + ); + this._remove(); + }, + _remove() { + this._account.removeBuddyRequest(this); + }, + QueryInterface: ChromeUtils.generateQI(["prplIBuddyRequest"]), + }; + this._pendingBuddyRequests.push(buddyRequest); + Services.obs.notifyObservers(buddyRequest, "buddy-authorization-request"); + }, + removeBuddyRequest(aRequest) { + if (!this._pendingBuddyRequests) { + return; + } + + this._pendingBuddyRequests = this._pendingBuddyRequests.filter( + r => r !== aRequest + ); + }, + /** + * Cancel a pending buddy request. + * + * @param {string} aUserName - The username the request is for. + */ + cancelBuddyRequest(aUserName) { + if (!this._pendingBuddyRequests) { + return; + } + + for (let request of this._pendingBuddyRequests) { + if (request.userName == aUserName) { + request.cancel(); + break; + } + } + }, + cancelPendingBuddyRequests() { + if (!this._pendingBuddyRequests) { + return; + } + + for (let request of this._pendingBuddyRequests) { + request.cancel(); + } + delete this._pendingBuddyRequests; + }, + + _pendingChatRequests: null, + /** + * Inform the user about a new conversation invitation. + * + * @param {string} conversationName - Name of the conversation the user is + * invited to. + * @param {(prplIChatRequest) => void} grantCallback - Function to be called + * when the invite is accepted. + * @param {(prplIChatRequest?, boolean) => void} [denyCallback] - Function to + * be called when the invite is rejected. If omitted, |canDeny| will be + * |false|. Callback is passed a boolean indicating whether the rejection should be + * sent to the other party. It being false is equivalent to ignoring the invite, in + * which case the callback should try to apply the ignore on the protocol level. + */ + addChatRequest(conversationName, grantCallback, denyCallback) { + if (!this._pendingChatRequests) { + this._pendingChatRequests = new Set(); + } + let inviteHandling = Services.prefs.getIntPref( + "messenger.conversations.autoAcceptChatInvitations" + ); + // Only auto-reject invites that can be denied. + if (inviteHandling <= 0 && denyCallback) { + const shouldReject = inviteHandling == -1; + denyCallback(null, shouldReject); + return; + } + let resolvePromise; + let rejectPromise; + let completePromise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + /** @implements {prplIChatRequest} */ + let chatRequest = { + get account() { + return this._account.imAccount; + }, + get conversationName() { + return conversationName; + }, + get canDeny() { + return Boolean(denyCallback); + }, + _account: this, + // Grant and deny callbacks both receive the auth request object as an + // argument for further use. + grant() { + resolvePromise(true); + grantCallback(this); + this._remove(); + }, + deny() { + if (!denyCallback) { + throw new Error("Can not deny this invitation."); + } + resolvePromise(false); + denyCallback(this, true); + this._remove(); + }, + cancel() { + rejectPromise(new Error("Cancelled")); + this._remove(); + }, + completePromise, + _remove() { + this._account.removeChatRequest(this); + }, + QueryInterface: ChromeUtils.generateQI(["prplIChatRequest"]), + }; + this._pendingChatRequests.add(chatRequest); + Services.obs.notifyObservers(chatRequest, "conv-authorization-request"); + }, + removeChatRequest(aRequest) { + if (!this._pendingChatRequests) { + return; + } + + this._pendingChatRequests.delete(aRequest); + }, + /** + * Cancel a pending chat request. + * + * @param {string} conversationName - The conversation the request is for. + */ + cancelChatRequest(conversationName) { + if (!this._pendingChatRequests) { + return; + } + + for (let request of this._pendingChatRequests) { + if (request.conversationName == conversationName) { + request.cancel(); + break; + } + } + }, + cancelPendingChatRequests() { + if (!this._pendingChatRequests) { + return; + } + + for (let request of this._pendingChatRequests) { + request.cancel(); + } + this._pendingChatRequests = null; + }, + + requestBuddyInfo(aBuddyName) {}, + + get canJoinChat() { + return false; + }, + getChatRoomFields() { + if (!this.chatRoomFields) { + return []; + } + let fieldNames = Object.keys(this.chatRoomFields); + return fieldNames.map( + fieldName => new ChatRoomField(fieldName, this.chatRoomFields[fieldName]) + ); + }, + getChatRoomDefaultFieldValues(aDefaultChatName) { + if (!this.chatRoomFields) { + return new ChatRoomFieldValues({}); + } + + let defaultFieldValues = {}; + for (let fieldName in this.chatRoomFields) { + defaultFieldValues[fieldName] = this.chatRoomFields[fieldName].default; + } + + if (aDefaultChatName && "parseDefaultChatName" in this) { + let parsedDefaultChatName = this.parseDefaultChatName(aDefaultChatName); + for (let field in parsedDefaultChatName) { + defaultFieldValues[field] = parsedDefaultChatName[field]; + } + } + + return new ChatRoomFieldValues(defaultFieldValues); + }, + requestRoomInfo(aCallback) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + getRoomInfo(aName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + get isRoomInfoStale() { + return false; + }, + + getPref(aName, aType) { + return this.prefs.prefHasUserValue(aName) + ? this.prefs["get" + aType + "Pref"](aName) + : this.protocol._getOptionDefault(aName); + }, + getInt(aName) { + return this.getPref(aName, "Int"); + }, + getBool(aName) { + return this.getPref(aName, "Bool"); + }, + getString(aName) { + return this.prefs.prefHasUserValue(aName) + ? this.prefs.getStringPref(aName) + : this.protocol._getOptionDefault(aName); + }, + + get prefs() { + return ( + this._prefs || + (this._prefs = Services.prefs.getBranch( + "messenger.account." + this.imAccount.id + ".options." + )) + ); + }, + + get normalizedName() { + return this.normalize(this.name); + }, + normalize(aName) { + return aName.toLowerCase(); + }, + + getSessions() { + return []; + }, + reportSessionsChanged() { + Services.obs.notifyObservers(this.imAccount, "account-sessions-changed"); + }, + + _pendingVerificationRequests: null, + /** + * + * @param {string} aDisplayName - Display name the request is from. + * @param {() => Promise<{challenge: string, challengeDescription: string?}>} aGetChallenge - Accept request and generate + * the challenge. + * @param {AbortSignal} [aAbortSignal] - Abort signal to indicate the request + * was cancelled. + * @returns {Promise<boolean>} Completion promise for the verification. + * Boolean indicates the result of the verification, rejection is a cancel. + */ + addVerificationRequest(aDisplayName, aGetChallenge, aAbortSignal) { + if (!this._pendingVerificationRequests) { + this._pendingVerificationRequests = []; + } + let verificationRequest = { + _account: this, + get account() { + return this._account.imAccount; + }, + get subject() { + return aDisplayName; + }, + get challengeType() { + return Ci.imISessionVerification.CHALLENGE_TEXT; + }, + get challenge() { + return this._challenge; + }, + get challengeDescription() { + return this._challengeDescription; + }, + _challenge: "", + _challengeDescription: "", + _canceled: false, + completePromise: null, + async verify() { + const { challenge, challengeDescription = "" } = await aGetChallenge(); + this._challenge = challenge; + this._challengeDescription = challengeDescription; + }, + submitResponse(challengeMatches) { + this._accept(challengeMatches); + this._remove(); + }, + cancel() { + if (this._canceled) { + return; + } + this._canceled = true; + Services.obs.notifyObservers( + this, + "buddy-verification-request-canceled" + ); + this._deny(); + this._remove(); + }, + _remove() { + this._account.removeVerificationRequest(this); + }, + QueryInterface: ChromeUtils.generateQI([ + "imIIncomingSessionVerification", + ]), + }; + verificationRequest.completePromise = new Promise((resolve, reject) => { + verificationRequest._accept = resolve; + verificationRequest._deny = reject; + }); + this._pendingVerificationRequests.push(verificationRequest); + Services.obs.notifyObservers( + verificationRequest, + "buddy-verification-request" + ); + if (aAbortSignal) { + aAbortSignal.addEventListener( + "abort", + () => { + verificationRequest.cancel(); + }, + { once: true } + ); + if (aAbortSignal.aborted) { + verificationRequest.cancel(); + } + } + return verificationRequest.completePromise; + }, + /** + * Remove a verification request for this account. + * + * @param {imIIncomingSessionVerification} aRequest + */ + removeVerificationRequest(aRequest) { + if (!this._pendingVerificationRequests) { + return; + } + this._pendingVerificationRequests = + this._pendingVerificationRequests.filter(r => r !== aRequest); + }, + cancelPendingVerificationRequests() { + if (!this._pendingVerificationRequests) { + return; + } + for (let request of this._pendingVerificationRequests) { + request.cancel(); + } + this._pendingVerificationRequests = null; + }, + + _encryptionStatus: [], + get encryptionStatus() { + return this._encryptionStatus; + }, + set encryptionStatus(newStatus) { + this._encryptionStatus = newStatus; + Services.obs.notifyObservers( + this.imAccount, + "account-encryption-status-changed", + newStatus + ); + }, +}; + +export var GenericAccountBuddyPrototype = { + __proto__: ClassInfo("prplIAccountBuddy", "generic account buddy object"), + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _init(aAccount, aBuddy, aTag, aUserName) { + if (!aBuddy && !aUserName) { + throw new Error("aUserName is required when aBuddy is null"); + } + + this._tag = aTag; + this._account = aAccount; + this._buddy = aBuddy; + if (aBuddy) { + let displayName = aBuddy.displayName; + if (displayName != aUserName) { + this._serverAlias = displayName; + } + } + this._userName = aUserName; + }, + unInit() { + delete this._tag; + delete this._account; + delete this._buddy; + }, + + get account() { + return this._account.imAccount; + }, + set buddy(aBuddy) { + if (this._buddy) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + this._buddy = aBuddy; + }, + get buddy() { + return this._buddy; + }, + get tag() { + return this._tag; + }, + set tag(aNewTag) { + let oldTag = this._tag; + this._tag = aNewTag; + IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag); + }, + + _notifyObservers(aTopic, aData) { + try { + this._buddy.observe(this, "account-buddy-" + aTopic, aData); + } catch (e) { + this.ERROR(e); + } + }, + + _userName: "", + get userName() { + return this._userName || this._buddy.userName; + }, + get normalizedName() { + return this._account.normalize(this.userName); + }, + _serverAlias: "", + get serverAlias() { + return this._serverAlias; + }, + set serverAlias(aNewAlias) { + let old = this.displayName; + this._serverAlias = aNewAlias; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + + /** + * Method called to start verification of the buddy. Same signature as + * _startVerification of GenericSessionPrototype. If the property is not a + * function, |canVerifyIdentity| is false. + * + * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?} + */ + _startVerification: null, + get canVerifyIdentity() { + return typeof this._startVerification === "function"; + }, + _identityVerified: false, + get identityVerified() { + return this.canVerifyIdentity && this._identityVerified; + }, + verifyIdentity() { + if (!this.canVerifyIdentity) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + if (this.identityVerified) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.userName, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, + + remove() { + IMServices.contacts.accountBuddyRemoved(this); + }, + + // imIStatusInfo implementation + get displayName() { + return this.serverAlias || this.userName; + }, + _buddyIconFilename: "", + get buddyIconFilename() { + return this._buddyIconFilename; + }, + set buddyIconFilename(aNewFileName) { + this._buddyIconFilename = aNewFileName; + this._notifyObservers("icon-changed"); + }, + _statusType: 0, + get statusType() { + return this._statusType; + }, + get online() { + return this._statusType > Ci.imIStatusInfo.STATUS_OFFLINE; + }, + get available() { + return this._statusType == Ci.imIStatusInfo.STATUS_AVAILABLE; + }, + get idle() { + return this._statusType == Ci.imIStatusInfo.STATUS_IDLE; + }, + get mobile() { + return this._statusType == Ci.imIStatusInfo.STATUS_MOBILE; + }, + _statusText: "", + get statusText() { + return this._statusText; + }, + + // This is for use by the protocol plugin, it's not exposed in the + // imIStatusInfo interface. + // All parameters are optional and will be ignored if they are null + // or undefined. + setStatus(aStatusType, aStatusText, aAvailabilityDetails) { + // Ignore omitted parameters. + if (aStatusType === undefined || aStatusType === null) { + aStatusType = this._statusType; + } + if (aStatusText === undefined || aStatusText === null) { + aStatusText = this._statusText; + } + if (aAvailabilityDetails === undefined || aAvailabilityDetails === null) { + aAvailabilityDetails = this._availabilityDetails; + } + + // Decide which notifications should be fired. + let notifications = []; + if ( + this._statusType != aStatusType || + this._availabilityDetails != aAvailabilityDetails + ) { + notifications.push("availability-changed"); + } + if (this._statusType != aStatusType || this._statusText != aStatusText) { + notifications.push("status-changed"); + if (this.online && aStatusType <= Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-off"); + } + if (!this.online && aStatusType > Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-on"); + } + } + + // Actually change the stored status. + [this._statusType, this._statusText, this._availabilityDetails] = [ + aStatusType, + aStatusText, + aAvailabilityDetails, + ]; + + // Fire the notifications. + notifications.forEach(function (aTopic) { + this._notifyObservers(aTopic); + }, this); + }, + + _availabilityDetails: 0, + get availabilityDetails() { + return this._availabilityDetails; + }, + + get canSendMessage() { + return this.online; + }, + + getTooltipInfo: () => [], + createConversation() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, +}; + +// aUserName is required only if aBuddy is null, i.e., we are adding a buddy. +function AccountBuddy(aAccount, aBuddy, aTag, aUserName) { + this._init(aAccount, aBuddy, aTag, aUserName); +} +AccountBuddy.prototype = GenericAccountBuddyPrototype; + +export var GenericMessagePrototype = { + __proto__: ClassInfo("prplIMessage", "generic message object"), + + _lastId: 0, + _init(aWho, aMessage, aObject, aConversation) { + this.id = ++GenericMessagePrototype._lastId; + this.time = Math.floor(new Date() / 1000); + this.who = aWho; + this.message = aMessage; + this.originalMessage = aMessage; + this.conversation = aConversation; + + if (aObject) { + for (let i in aObject) { + this[i] = aObject[i]; + } + } + }, + _alias: "", + get alias() { + return this._alias || this.who; + }, + _iconURL: "", + get iconURL() { + // If the protocol plugin has explicitly set an icon for the message, use it. + if (this._iconURL) { + return this._iconURL; + } + + // Otherwise, attempt to find a buddy for incoming messages, and forward the call. + if (this.incoming && this.conversation && !this.conversation.isChat) { + let buddy = this.conversation.buddy; + if (buddy) { + return buddy.buddyIconFilename; + } + } + return ""; + }, + conversation: null, + remoteId: "", + + outgoing: false, + incoming: false, + system: false, + autoResponse: false, + containsNick: false, + noLog: false, + error: false, + delayed: false, + noFormat: false, + containsImages: false, + notification: false, + noLinkification: false, + noCollapse: false, + isEncrypted: false, + action: false, + deleted: false, + + getActions() { + return []; + }, + + whenDisplayed() {}, + whenRead() {}, +}; + +export function Message(aWho, aMessage, aObject, aConversation) { + this._init(aWho, aMessage, aObject, aConversation); +} + +Message.prototype = GenericMessagePrototype; + +export var GenericConversationPrototype = { + __proto__: ClassInfo("prplIConversation", "generic conversation object"), + get wrappedJSObject() { + return this; + }, + + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _init(aAccount, aName) { + this._account = aAccount; + this._name = aName; + this._observers = []; + this._date = new Date() * 1000; + IMServices.conversations.addConversation(this); + }, + + _id: 0, + get id() { + return this._id; + }, + set id(aId) { + if (this._id) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + this._id = aId; + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + try { + observer.observe(aSubject, aTopic, aData); + } catch (e) { + this.ERROR(e); + } + } + }, + + prepareForSending: aOutgoingMessage => [aOutgoingMessage.message], + prepareForDisplaying(aImMessage) { + if (aImMessage.displayMessage !== aImMessage.message) { + this.DEBUG( + "Preparing:\n" + + aImMessage.message + + "\nDisplaying:\n" + + aImMessage.displayMessage + ); + } + }, + sendMsg(aMsg, aAction = false, aNotification = false) { + // Add-ons (eg. pastebin) have an opportunity to cancel the message at this + // point, or change the text content of the message. + // If an add-on wants to split a message, it should truncate the first + // message, and insert new messages using the conversation's sendMsg method. + let om = new OutgoingMessage(aMsg, this); + om.action = aAction; + om.notification = aNotification; + this.notifyObservers(om, "preparing-message"); + if (om.cancelled) { + return; + } + + // Protocols have an opportunity here to preprocess messages before they are + // sent (eg. split long messages). If a message is split here, the split + // will be visible in the UI. + let messages = this.prepareForSending(om); + let isAction = om.action; + let isNotification = om.notification; + + for (let msg of messages) { + // Add-ons (eg. OTR) have an opportunity to tweak or cancel the message + // at this point. + om = new OutgoingMessage(msg, this); + om.action = isAction; + om.notification = isNotification; + this.notifyObservers(om, "sending-message"); + if (om.cancelled) { + continue; + } + this.dispatchMessage(om.message, om.action, om.notification); + } + }, + dispatchMessage(message, action, notification) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + sendTyping: aString => Ci.prplIConversation.NO_TYPING_LIMIT, + + close() { + Services.obs.notifyObservers(this, "closing-conversation"); + IMServices.conversations.removeConversation(this); + }, + unInit() { + delete this._account; + delete this._observers; + }, + + /** + * Create a prplIMessage instance from params. + * + * @param {string} who - Nick of the participant who sent the message. + * @param {string} text - Raw message contents. + * @param {object} properties - Additional properties of the message. + * @returns {prplIMessage} + */ + createMessage(who, text, properties) { + return new Message(who, text, properties, this); + }, + + writeMessage(aWho, aText, aProperties) { + const message = this.createMessage(aWho, aText, aProperties); + this.notifyObservers(message, "new-text"); + }, + + /** + * Update the contents of a message. + * + * @param {string} who - Nick of the participant who sent the message. + * @param {string} text - Raw contents of the message. + * @param {object} properties - Additional properties of the message. Should + * specify a |remoteId| to find the previous version of this message. + */ + updateMessage(who, text, properties) { + const message = this.createMessage(who, text, properties); + this.notifyObservers(message, "update-text"); + }, + + /** + * Remove a message from the conversation. Does not affect logs, use + * updateMessage with a deleted property to remove from logs. + * + * @param {string} remoteId - Remote ID of the event to remove. + */ + removeMessage(remoteId) { + this.notifyObservers(null, "remove-text", remoteId); + }, + + get account() { + return this._account.imAccount; + }, + get name() { + return this._name; + }, + get normalizedName() { + return this._account.normalize(this.name); + }, + get title() { + return this.name; + }, + get startDate() { + return this._date; + }, + _convIconFilename: "", + get convIconFilename() { + return this._convIconFilename; + }, + set convIconFilename(aNewFilename) { + this._convIconFilename = aNewFilename; + this.notifyObservers(this, "update-conv-icon"); + }, + + get encryptionState() { + return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED; + }, + initializeEncryption() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, +}; + +export var GenericConvIMPrototype = { + __proto__: GenericConversationPrototype, + _interfaces: [Ci.prplIConversation, Ci.prplIConvIM], + classDescription: "generic ConvIM object", + + updateTyping(aState, aName) { + if (aState == this.typingState) { + return; + } + + if (aState == Ci.prplIConvIM.NOT_TYPING) { + delete this.typingState; + } else { + this.typingState = aState; + } + this.notifyObservers(null, "update-typing", aName); + }, + + get isChat() { + return false; + }, + buddy: null, + typingState: Ci.prplIConvIM.NOT_TYPING, + get convIconFilename() { + // By default, pass through information from the buddy for IM conversations + // that don't have their own icon. + const convIconFilename = this._convIconFilename; + if (convIconFilename) { + return convIconFilename; + } + return this.buddy?.buddyIconFilename; + }, +}; + +export var GenericConvChatPrototype = { + __proto__: GenericConversationPrototype, + _interfaces: [Ci.prplIConversation, Ci.prplIConvChat], + classDescription: "generic ConvChat object", + + _init(aAccount, aName, aNick) { + // _participants holds prplIConvChatBuddy objects. + this._participants = new Map(); + this.nick = aNick; + GenericConversationPrototype._init.call(this, aAccount, aName); + }, + + get isChat() { + return true; + }, + + // Stores the prplIChatRoomFieldValues required to join this channel + // to enable later reconnections. If null, the MUC will not be reconnected + // automatically after disconnections. + chatRoomFields: null, + + _topic: "", + _topicSetter: null, + get topic() { + return this._topic; + }, + get topicSettable() { + return false; + }, + get topicSetter() { + return this._topicSetter; + }, + /** + * Set the topic of a conversation. + * + * @param {string} aTopic - The new topic. If an update message is sent to + * the conversation, this will be HTML escaped before being sent. + * @param {string} aTopicSetter - The user who last modified the topic. + * @param {string} aQuiet - If false, a message notifying about the topic + * change will be sent to the conversation. + */ + setTopic(aTopic, aTopicSetter, aQuiet) { + // Only change the topic if the topic and/or topic setter has changed. + if ( + this._topic == aTopic && + (!this._topicSetter || this._topicSetter == aTopicSetter) + ) { + return; + } + + this._topic = aTopic; + this._topicSetter = aTopicSetter; + + this.notifyObservers(null, "chat-update-topic"); + + if (aQuiet) { + return; + } + + // Send the topic as a message. + let message; + if (aTopicSetter) { + if (aTopic) { + message = lazy._("topicChanged", aTopicSetter, lazy.TXTToHTML(aTopic)); + } else { + message = lazy._("topicCleared", aTopicSetter); + } + } else { + aTopicSetter = null; + if (aTopic) { + message = lazy._("topicSet", this.name, lazy.TXTToHTML(aTopic)); + } else { + message = lazy._("topicNotSet", this.name); + } + } + this.writeMessage(aTopicSetter, message, { system: true }); + }, + + get nick() { + return this._nick; + }, + set nick(aNick) { + this._nick = aNick; + let escapedNick = this._nick.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&"); + this._pingRegexp = new RegExp("(?:^|\\W)" + escapedNick + "(?:\\W|$)", "i"); + }, + + _left: false, + get left() { + return this._left; + }, + set left(aLeft) { + if (aLeft == this._left) { + return; + } + this._left = aLeft; + this.notifyObservers(null, "update-conv-chatleft"); + }, + + _joining: false, + get joining() { + return this._joining; + }, + set joining(aJoining) { + if (aJoining == this._joining) { + return; + } + this._joining = aJoining; + this.notifyObservers(null, "update-conv-chatjoining"); + }, + + getParticipant(aName) { + return this._participants.has(aName) ? this._participants.get(aName) : null; + }, + getParticipants() { + // Convert the values of the Map into an array. + return Array.from(this._participants.values()); + }, + getNormalizedChatBuddyName: aChatBuddyName => aChatBuddyName, + + // Updates the nick of a participant in conversation to a new one. + updateNick(aOldNick, aNewNick, isOwnNick) { + let message; + let isParticipant = this._participants.has(aOldNick); + if (isOwnNick) { + // If this is the user's nick, change it. + this.nick = aNewNick; + message = lazy._("nickSet.you", aNewNick); + + // If the account was disconnected, it's OK the user is not a participant. + if (!isParticipant) { + return; + } + } else if (!isParticipant) { + this.ERROR( + "Trying to rename nick that doesn't exist! " + + aOldNick + + " to " + + aNewNick + ); + return; + } else { + message = lazy._("nickSet", aOldNick, aNewNick); + } + + // Get the original participant and then remove it. + let participant = this._participants.get(aOldNick); + this._participants.delete(aOldNick); + + // Update the nickname and add it under the new nick. + participant.name = aNewNick; + this._participants.set(aNewNick, participant); + + this.notifyObservers(participant, "chat-buddy-update", aOldNick); + this.writeMessage(aOldNick, message, { system: true }); + }, + + // Removes a participant from conversation. + removeParticipant(aNick) { + if (!this._participants.has(aNick)) { + return; + } + + let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + stringNickname.data = aNick; + this.notifyObservers( + new nsSimpleEnumerator([stringNickname]), + "chat-buddy-remove" + ); + this._participants.delete(aNick); + }, + + // Removes all participant in conversation. + removeAllParticipants() { + let stringNicknames = []; + this._participants.forEach(function (aParticipant) { + let stringNickname = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + stringNickname.data = aParticipant.name; + stringNicknames.push(stringNickname); + }); + this.notifyObservers( + new nsSimpleEnumerator(stringNicknames), + "chat-buddy-remove" + ); + this._participants.clear(); + }, + + createMessage(who, text, properties) { + properties.containsNick = + "incoming" in properties && this._pingRegexp.test(text); + return GenericConversationPrototype.createMessage.apply(this, arguments); + }, +}; + +export var GenericConvChatBuddyPrototype = { + __proto__: ClassInfo("prplIConvChatBuddy", "generic ConvChatBuddy object"), + + _name: "", + get name() { + return this._name; + }, + set name(aName) { + this._name = aName; + }, + alias: "", + buddy: false, + buddyIconFilename: "", + + voiced: false, + moderator: false, + admin: false, + founder: false, + typing: false, + + /** + * Method called to start verification of the buddy. Same signature as + * _startVerification of GenericSessionPrototype. If the property is not a + * function, |canVerifyIdentity| is false. + * + * @type {() => {challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise}?} + */ + _startVerification: null, + get canVerifyIdentity() { + return typeof this._startVerification === "function"; + }, + _identityVerified: false, + get identityVerified() { + return this.canVerifyIdentity && this._identityVerified; + }, + verifyIdentity() { + if (!this.canVerifyIdentity) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + if (this.identityVerified) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.name, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, +}; + +export function TooltipInfo(aLabel, aValue, aType = Ci.prplITooltipInfo.pair) { + this.type = aType; + if (aType == Ci.prplITooltipInfo.status) { + this.label = aLabel.toString(); + this.value = aValue || ""; + } else if (aType == Ci.prplITooltipInfo.icon) { + this.value = aValue; + } else if ( + aLabel === undefined || + aType == Ci.prplITooltipInfo.sectionBreak + ) { + this.type = Ci.prplITooltipInfo.sectionBreak; + } else { + this.label = aLabel; + if (aValue === undefined) { + this.type = Ci.prplITooltipInfo.sectionHeader; + } else { + this.value = aValue; + } + } +} + +TooltipInfo.prototype = ClassInfo("prplITooltipInfo", "generic tooltip info"); + +/* aOption is an object containing: + * - label: localized text to display (recommended: use a getter with _) + * - default: the default value for this option. The type of the + * option will be determined based on the type of the default value. + * If the default value is a string, the option will be of type + * list if listValues has been provided. In that case the default + * value should be one of the listed values. + * - [optional] listValues: only if this option can only take a list of + * predefined values. This is an object of the form: + * {value1: localizedLabel, value2: ...}. + * - [optional] masked: boolean, if true the UI shouldn't display the value. + * This could typically be used for password field. + * Warning: The UI currently doesn't support this. + */ +function purplePref(aName, aOption) { + this.name = aName; // Preference name + this.label = aOption.label; // Text to display + + if (aOption.default === undefined || aOption.default === null) { + throw new Error( + "A default value for the option is required to determine its type." + ); + } + this._defaultValue = aOption.default; + + const kTypes = { boolean: "Bool", string: "String", number: "Int" }; + let type = kTypes[typeof aOption.default]; + if (!type) { + throw new Error("Invalid option type"); + } + + if (type == "String" && "listValues" in aOption) { + type = "List"; + this._listValues = aOption.listValues; + } + this.type = Ci.prplIPref["type" + type]; + + if ("masked" in aOption && aOption.masked) { + this.masked = true; + } +} +purplePref.prototype = { + __proto__: ClassInfo("prplIPref", "generic account option preference"), + + masked: false, + + // Default value + getBool() { + return this._defaultValue; + }, + getInt() { + return this._defaultValue; + }, + getString() { + return this._defaultValue; + }, + getList() { + // Convert a JavaScript object map {"value 1": "label 1", ...} + let keys = Object.keys(this._listValues); + return keys.map(key => new purpleKeyValuePair(this._listValues[key], key)); + }, + getListDefault() { + return this._defaultValue; + }, +}; + +function purpleKeyValuePair(aName, aValue) { + this.name = aName; + this.value = aValue; +} +purpleKeyValuePair.prototype = ClassInfo( + "prplIKeyValuePair", + "generic Key Value Pair" +); + +function UsernameSplit(aValues) { + this._values = aValues; +} +UsernameSplit.prototype = { + __proto__: ClassInfo("prplIUsernameSplit", "username split object"), + + get label() { + return this._values.label; + }, + get separator() { + return this._values.separator; + }, + get defaultValue() { + return this._values.defaultValue; + }, +}; + +function ChatRoomField(aIdentifier, aField) { + this.identifier = aIdentifier; + this.label = aField.label; + this.required = !!aField.required; + + let type = "TEXT"; + if (typeof aField.default == "number") { + type = "INT"; + this.min = aField.min; + this.max = aField.max; + } else if (aField.isPassword) { + type = "PASSWORD"; + } + this.type = Ci.prplIChatRoomField["TYPE_" + type]; +} +ChatRoomField.prototype = ClassInfo( + "prplIChatRoomField", + "ChatRoomField object" +); + +function ChatRoomFieldValues(aMap) { + this.values = aMap; +} +ChatRoomFieldValues.prototype = { + __proto__: ClassInfo("prplIChatRoomFieldValues", "ChatRoomFieldValues"), + + getValue(aIdentifier) { + return this.values.hasOwnProperty(aIdentifier) + ? this.values[aIdentifier] + : null; + }, + setValue(aIdentifier, aValue) { + this.values[aIdentifier] = aValue; + }, +}; + +// the name getter and the getAccount method need to be implemented by +// protocol plugins. +export var GenericProtocolPrototype = { + __proto__: ClassInfo("prplIProtocol", "Generic protocol object"), + + init(aId) { + if (aId != this.id) { + throw new Error( + "Creating an instance of " + + aId + + " but this object implements " + + this.id + ); + } + }, + get id() { + return "prpl-" + this.normalizedName; + }, + get iconBaseURI() { + return "chrome://chat/skin/prpl-generic/"; + }, + + getAccount(aImAccount) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + _getOptionDefault(aName) { + if (this.options && this.options.hasOwnProperty(aName)) { + return this.options[aName].default; + } + throw new Error(aName + " has no default value in " + this.id + "."); + }, + getOptions() { + if (!this.options) { + return []; + } + + let purplePrefs = []; + for (let [name, option] of Object.entries(this.options)) { + purplePrefs.push(new purplePref(name, option)); + } + return purplePrefs; + }, + usernamePrefix: "", + getUsernameSplit() { + if (!this.usernameSplits || !this.usernameSplits.length) { + return []; + } + return this.usernameSplits.map(split => new UsernameSplit(split)); + }, + + /** + * Protocol agnostic implementation that splits the username by the pattern + * defined with |usernamePrefix| and |usernameSplits| on the protocol. + * Prefers the first occurrence of a separator. + * + * @param {string} aName - Username to split. + * @returns {string[]} Parts of the username or empty array if the username + * doesn't match the splitting format. + */ + splitUsername(aName) { + let remainingName = aName; + if (this.usernamePrefix) { + if (!remainingName.startsWith(this.usernamePrefix)) { + return []; + } + remainingName = remainingName.slice(this.usernamePrefix.length); + } + if (!this.usernameSplits || !this.usernameSplits.length) { + return [remainingName]; + } + const parts = []; + for (const split of this.usernameSplits) { + if (!remainingName.includes(split.separator)) { + return []; + } + const separatorIndex = remainingName.indexOf(split.separator); + parts.push(remainingName.slice(0, separatorIndex)); + remainingName = remainingName.slice( + separatorIndex + split.separator.length + ); + } + parts.push(remainingName); + return parts; + }, + + registerCommands() { + if (!this.commands) { + return; + } + + this.commands.forEach(function (command) { + if (!command.hasOwnProperty("name") || !command.hasOwnProperty("run")) { + throw new Error("Every command must have a name and a run function."); + } + if (!("QueryInterface" in command)) { + command.QueryInterface = ChromeUtils.generateQI(["imICommand"]); + } + if (!command.hasOwnProperty("usageContext")) { + command.usageContext = Ci.imICommand.CMD_CONTEXT_ALL; + } + if (!command.hasOwnProperty("priority")) { + command.priority = Ci.imICommand.CMD_PRIORITY_PRPL; + } + IMServices.cmd.registerCommand(command, this.id); + }, this); + }, + + // NS_ERROR_XPC_JSOBJECT_HAS_NO_FUNCTION_NAMED errors are too noisy + get usernameEmptyText() { + return ""; + }, + accountExists: () => false, // FIXME + + get chatHasTopic() { + return false; + }, + get noPassword() { + return false; + }, + get passwordOptional() { + return false; + }, + get slashCommandsNative() { + return false; + }, + get canEncrypt() { + return false; + }, + + get classDescription() { + return this.name + " Protocol"; + }, + get contractID() { + return "@mozilla.org/chat/" + this.normalizedName + ";1"; + }, +}; + +/** + * Text challenge session verification flow. Starts the UI flow. + * + * @param {string} challenge - String the challenge should display. + * @param {string} subject - Human readable identifier of the other side of the + * challenge. + * @param {string} [challengeDescription] - Description of the challenge + * contents. + */ +function SessionVerification(challenge, subject, challengeDescription) { + this._challenge = challenge; + this._subject = subject; + if (challengeDescription) { + this._description = challengeDescription; + } + this._responsePromise = new Promise((resolve, reject) => { + this._submit = resolve; + this._cancel = reject; + }); +} +SessionVerification.prototype = { + __proto__: ClassInfo( + "imISessionVerification", + "generic session verification object" + ), + _challengeType: Ci.imISessionVerification.CHALLENGE_TEXT, + _challenge: "", + _description: "", + _responsePromise: null, + _submit: null, + _cancel: null, + _cancelled: false, + get challengeType() { + return this._challengeType; + }, + get challenge() { + return this._challenge; + }, + get challengeDescription() { + return this._description; + }, + get subject() { + return this._subject; + }, + get completePromise() { + return this._responsePromise; + }, + submitResponse(challengeMatches) { + this._submit(challengeMatches); + }, + cancel() { + if (this._cancelled) { + return; + } + this._cancelled = true; + this._cancel(); + }, +}; + +export var GenericSessionPrototype = { + __proto__: ClassInfo("prplISession", "generic session object"), + /** + * Initialize the session. + * + * @param {prplIAccount} account - Account the session is related to. + * @param {string} id - ID of the session. + * @param {boolean} [trusted=false] - If the session is trusted. + * @param {boolean} [currentSession=false] - If the session represents the. + * session we're connected as. + */ + _init(account, id, trusted = false, currentSession = false) { + this._account = account; + this._id = id; + this._trusted = trusted; + this._currentSession = currentSession; + }, + _account: null, + _id: "", + _trusted: false, + _currentSession: false, + get id() { + return this._id; + }, + get trusted() { + return this._trusted; + }, + set trusted(newTrust) { + this._trusted = newTrust; + this._account.reportSessionsChanged(); + }, + get currentSession() { + return this._currentSession; + }, + /** + * Handle the start of the session verification process. The protocol is + * expected to update the trusted property on the session if it becomes + * trusted after verification. + * + * @returns {Promise<{challenge: string, challengeDescription: string?, handleResult: (boolean) => void, cancel: () => void, cancelPromise: Promise<void>}>} + * Promise resolves to an object holding the challenge string, as well as a + * callback that handles the result of the verification flow. The cancel + * callback is called when the verification is cancelled and the cancelPromise + * is used for the protocol to report when the other side cancels. + * The cancel callback will be called when the cancel promise resolves. + */ + _startVerification() { + return Promise.reject( + Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED) + ); + }, + verify() { + if (this.trusted) { + return Promise.resolve(); + } + return this._startVerification().then( + ({ + challenge, + challengeDescription, + handleResult, + cancel, + cancelPromise, + }) => { + const verifier = new SessionVerification( + challenge, + this.id, + challengeDescription + ); + verifier.completePromise.then( + result => handleResult(result), + () => cancel() + ); + cancelPromise.then(() => verifier.cancel()); + return verifier; + } + ); + }, +}; |