summaryrefslogtreecommitdiffstats
path: root/comm/chat/protocols/xmpp/xmpp-base.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'comm/chat/protocols/xmpp/xmpp-base.sys.mjs')
-rw-r--r--comm/chat/protocols/xmpp/xmpp-base.sys.mjs3421
1 files changed, 3421 insertions, 0 deletions
diff --git a/comm/chat/protocols/xmpp/xmpp-base.sys.mjs b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs
new file mode 100644
index 0000000000..f7e0ccd98e
--- /dev/null
+++ b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs
@@ -0,0 +1,3421 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { Status } from "resource:///modules/imStatusUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ executeSoon,
+ nsSimpleEnumerator,
+ EmptyEnumerator,
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericAccountBuddyPrototype,
+ GenericConvIMPrototype,
+ GenericConvChatPrototype,
+ GenericConversationPrototype,
+ TooltipInfo,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs";
+import {
+ Stanza,
+ SupportedFeatures,
+} from "resource:///modules/xmpp-xml.sys.mjs";
+import { XMPPSession } from "resource:///modules/xmpp-session.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "imgTools",
+ "@mozilla.org/image/tools;1",
+ "imgITools"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/xmpp.properties")
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () {
+ let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv);
+ return aTxt => cs.scanTXT(aTxt, cs.kEntities);
+});
+
+// Parses the status from a presence stanza into an object of statusType,
+// statusText and idleSince.
+function parseStatus(aStanza) {
+ let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE;
+ let show = aStanza.getElement(["show"]);
+ if (show) {
+ show = show.innerText;
+ if (show == "away") {
+ statusType = Ci.imIStatusInfo.STATUS_AWAY;
+ } else if (show == "chat") {
+ statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; // FIXME
+ } else if (show == "dnd") {
+ statusType = Ci.imIStatusInfo.STATUS_UNAVAILABLE;
+ } else if (show == "xa") {
+ statusType = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+ }
+
+ let idleSince = 0;
+ let date = _getDelay(aStanza);
+ if (date) {
+ idleSince = date.getTime();
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (query && query.uri == Stanza.NS.last) {
+ let now = Math.floor(Date.now() / 1000);
+ idleSince = now - parseInt(query.attributes.seconds, 10);
+ statusType = Ci.imIStatusInfo.STATUS_IDLE;
+ }
+
+ let status = aStanza.getElement(["status"]);
+ status = status ? status.innerText : "";
+
+ return { statusType, statusText: status, idleSince };
+}
+
+// Returns a Date object for the delay value (stamp) in aStanza if it exists,
+// otherwise returns undefined.
+function _getDelay(aStanza) {
+ // XEP-0203: Delayed Delivery.
+ let date;
+ let delay = aStanza.getElement(["delay"]);
+ if (delay && delay.uri == Stanza.NS.delay) {
+ if (delay.attributes.stamp) {
+ date = new Date(delay.attributes.stamp);
+ }
+ }
+ if (date && isNaN(date.getTime())) {
+ return undefined;
+ }
+
+ return date;
+}
+
+// Writes aMsg in aConv as an outgoing message with optional date as the
+// message may be sent from another client.
+function _displaySentMsg(aConv, aMsg, aDate) {
+ let who;
+ if (aConv._account._connection) {
+ who = aConv._account._connection._jid.jid;
+ }
+ if (!who) {
+ who = aConv._account.name;
+ }
+
+ let flags = { outgoing: true };
+ flags._alias = aConv.account.alias || aConv.account.statusInfo.displayName;
+
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ aConv.writeMessage(who, aMsg, flags);
+}
+
+// The timespan after which we consider roomInfo to be stale.
+var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours.
+
+/* This is an ordered list, used to determine chat buddy flags:
+ * index = member -> voiced
+ * moderator -> moderator
+ * admin -> admin
+ * owner -> founder
+ */
+var kRoles = [
+ "outcast",
+ "visitor",
+ "participant",
+ "member",
+ "moderator",
+ "admin",
+ "owner",
+];
+
+function MUCParticipant(aNick, aJid, aPresenceStanza) {
+ this._jid = aJid;
+ this.name = aNick;
+ this.onPresenceStanza(aPresenceStanza);
+}
+MUCParticipant.prototype = {
+ __proto__: ClassInfo("prplIConvChatBuddy", "XMPP ConvChatBuddy object"),
+
+ buddy: false,
+
+ // The occupant jid of the participant which is of the form room@domain/nick.
+ _jid: null,
+
+ // The real jid of the participant which is of the form local@domain/resource.
+ accountJid: null,
+
+ statusType: null,
+ statusText: null,
+ get alias() {
+ return this.name;
+ },
+
+ role: 2, // "participant" by default
+
+ // Called when a presence stanza is received for this participant.
+ onPresenceStanza(aStanza) {
+ let statusInfo = parseStatus(aStanza);
+ this.statusType = statusInfo.statusType;
+ this.statusText = statusInfo.statusText;
+
+ let x = aStanza.children.filter(
+ child => child.localName == "x" && child.uri == Stanza.NS.muc_user
+ );
+ if (x.length == 0) {
+ return;
+ }
+
+ // XEP-0045 (7.2.3): We only expect a single <x/> element of this namespace,
+ // so we ignore any others.
+ x = x[0];
+
+ let item = x.getElement(["item"]);
+ if (!item) {
+ return;
+ }
+
+ this.role = Math.max(
+ kRoles.indexOf(item.attributes.role),
+ kRoles.indexOf(item.attributes.affiliation)
+ );
+
+ let accountJid = item.attributes.jid;
+ if (accountJid) {
+ this.accountJid = accountJid;
+ }
+ },
+
+ get voiced() {
+ /* FIXME: The "voiced" role corresponds to users that can send messages to
+ * the room. If the chat is unmoderated, this should include everyone, not
+ * just members. */
+ return this.role == kRoles.indexOf("member");
+ },
+ get moderator() {
+ return this.role == kRoles.indexOf("moderator");
+ },
+ get admin() {
+ return this.role == kRoles.indexOf("admin");
+ },
+ get founder() {
+ return this.role == kRoles.indexOf("owner");
+ },
+ typing: false,
+};
+
+// MUC (Multi-User Chat)
+export var XMPPMUCConversationPrototype = {
+ __proto__: GenericConvChatPrototype,
+ // By default users are not in a MUC.
+ _left: true,
+
+ // Tracks all received messages to avoid possible duplication if the server
+ // sends us the last few messages again when we rejoin a room.
+ _messageIds: new Set(),
+
+ _init(aAccount, aJID, aNick) {
+ this._messageIds = new Set();
+ GenericConvChatPrototype._init.call(this, aAccount, aJID, aNick);
+ },
+
+ _targetResource: "",
+
+ // True while we are rejoining a room previously parted by the user.
+ _rejoined: false,
+
+ get topic() {
+ return this._topic;
+ },
+ set topic(aTopic) {
+ // XEP-0045 (8.1): Modifying the room subject.
+ let subject = Stanza.node("subject", null, null, aTopic.trim());
+ let s = Stanza.message(
+ this.name,
+ null,
+ null,
+ { type: "groupchat" },
+ subject
+ );
+ let notAuthorized = lazy._(
+ "conversation.error.changeTopicFailedNotAuthorized"
+ );
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ forbidden: notAuthorized,
+ notAcceptable: notAuthorized,
+ itemNotFound: notAuthorized,
+ },
+ this
+ )
+ );
+ },
+ get topicSettable() {
+ return true;
+ },
+
+ /* Called when the user enters a chat message */
+ dispatchMessage(aMsg, aAction = false) {
+ if (aAction) {
+ // XEP-0245: The /me Command.
+ // We need to prepend "/me " as the first four characters of the message
+ // body.
+ aMsg = "/me " + aMsg;
+ }
+ // XEP-0045 (7.4): Sending a message to all occupants in a room.
+ let s = Stanza.message(this.name, aMsg, null, { type: "groupchat" });
+ let notInRoom = lazy._(
+ "conversation.error.sendFailedAsNotInRoom",
+ this.name,
+ aMsg
+ );
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ itemNotFound: notInRoom,
+ notAcceptable: notInRoom,
+ },
+ this
+ )
+ );
+ },
+
+ /* Called by the account when a presence stanza is received for this muc */
+ onPresenceStanza(aStanza) {
+ let from = aStanza.attributes.from;
+ let nick = this._account._parseJID(from).resource;
+ let jid = this._account.normalize(from);
+ let x = aStanza
+ .getElements(["x"])
+ .find(
+ e => e.uri == Stanza.NS.muc_user || e.uri == Stanza.NS.vcard_update
+ );
+
+ // Check if the join failed.
+ if (this.left && aStanza.attributes.type == "error") {
+ let error = this._account.parseError(aStanza);
+ let message;
+ switch (error.condition) {
+ case "not-authorized":
+ case "registration-required":
+ // XEP-0045 (7.2.7): Members-Only Rooms.
+ message = lazy._("conversation.error.joinFailedNotAuthorized");
+ break;
+ case "not-allowed":
+ message = lazy._("conversation.error.creationFailedNotAllowed");
+ break;
+ case "remote-server-not-found":
+ message = lazy._(
+ "conversation.error.joinFailedRemoteServerNotFound",
+ this.name
+ );
+ break;
+ case "forbidden":
+ // XEP-0045 (7.2.8): Banned users.
+ message = lazy._("conversation.error.joinForbidden", this.name);
+ break;
+ default:
+ message = lazy._("conversation.error.joinFailed", this.name);
+ this.ERROR("Failed to join MUC: " + aStanza.convertToString());
+ break;
+ }
+ this.writeMessage(this.name, message, { system: true, error: true });
+ this.joining = false;
+ return;
+ }
+
+ if (!x) {
+ this.WARN(
+ "Received a MUC presence stanza without an x element or " +
+ "with a namespace we don't handle."
+ );
+ return;
+ }
+ // Handle a MUC resource avatar
+ if (
+ x.uri == Stanza.NS.vcard_update &&
+ aStanza.attributes.from == this.normalizedName
+ ) {
+ let photo = aStanza.getElement(["x", "photo"]);
+ if (photo && photo.uri == Stanza.NS.vcard_update) {
+ let hash = photo.innerText;
+ if (hash && hash != this._photoHash) {
+ this._account._addVCardRequest(this.normalizedName);
+ } else if (!hash && this._photoHash) {
+ delete this._photoHash;
+ this.convIconFilename = "";
+ }
+ }
+ return;
+ }
+ let codes = x.getElements(["status"]).map(elt => elt.attributes.code);
+ let item = x.getElement(["item"]);
+
+ // Changes the nickname of a participant for this muc.
+ let changeNick = () => {
+ if (!item || !item.attributes.nick) {
+ this.WARN(
+ "Received a MUC presence code 303 or 210 stanza without an " +
+ "item element or a nick attribute."
+ );
+ return;
+ }
+ let newNick = item.attributes.nick;
+ this.updateNick(nick, newNick, nick == this.nick);
+ };
+
+ if (aStanza.attributes.type == "unavailable") {
+ if (!this._participants.has(nick)) {
+ this.WARN(
+ "received unavailable presence for an unknown MUC participant: " +
+ from
+ );
+ return;
+ }
+ if (codes.includes("303")) {
+ // XEP-0045 (7.6): Changing Nickname.
+ // Service Updates Nick for user.
+ changeNick();
+ return;
+ }
+ if (item && item.attributes.role == "none") {
+ // XEP-0045: an occupant has left the room.
+ this.removeParticipant(nick);
+
+ // Who caused the participant to leave the room.
+ let actor = item.getElement(["actor"]);
+ let actorNick = actor ? actor.attributes.nick : "";
+ let isActor = actorNick ? ".actor" : "";
+
+ // Why the participant left.
+ let reasonNode = item.getElement(["reason"]);
+ let reason = reasonNode ? reasonNode.innerText : "";
+ let isReason = reason ? ".reason" : "";
+
+ let isYou = nick == this.nick ? ".you" : "";
+ let affectedNick = isYou ? "" : nick;
+ if (isYou) {
+ this.left = true;
+ }
+
+ let message;
+ if (codes.includes("301")) {
+ // XEP-0045 (9.1): Banning a User.
+ message = "conversation.message.banned";
+ } else if (codes.includes("307")) {
+ // XEP-0045 (8.2): Kicking an Occupant.
+ message = "conversation.message.kicked";
+ } else if (codes.includes("322") || codes.includes("321")) {
+ // XEP-0045: Inform user that he or she is being removed from the
+ // room because the room has been changed to members-only and the
+ // user is not a member.
+ message = "conversation.message.removedNonMember";
+ } else if (codes.includes("332")) {
+ // XEP-0045: Inform user that he or she is being removed from the
+ // room because the MUC service is being shut down.
+ message = "conversation.message.mucShutdown";
+
+ // The reason here just duplicates what's in the system message.
+ reason = isReason = "";
+ } else {
+ // XEP-0045 (7.14): Received when the user parts a room.
+ message = "conversation.message.parted";
+
+ // The reason is in a status element in this case.
+ reasonNode = aStanza.getElement(["status"]);
+ reason = reasonNode ? reasonNode.innerText : "";
+ isReason = reason ? ".reason" : "";
+ }
+
+ if (message) {
+ let messageID = message + isYou + isActor + isReason;
+ let params = [actorNick, affectedNick, reason].filter(s => s);
+ this.writeMessage(this.name, lazy._(messageID, ...params), {
+ system: true,
+ });
+ }
+ } else {
+ this.WARN("Unhandled type==unavailable MUC presence stanza.");
+ }
+ return;
+ }
+
+ if (codes.includes("201")) {
+ // XEP-0045 (10.1): Creating room.
+ // Service Acknowledges Room Creation
+ // and Room is awaiting configuration.
+ // XEP-0045 (10.1.2): Instant room.
+ let query = Stanza.node(
+ "query",
+ Stanza.NS.muc_owner,
+ null,
+ Stanza.node("x", Stanza.NS.xdata, { type: "submit" })
+ );
+ let s = Stanza.iq("set", null, jid, query);
+ this._account.sendStanza(s, aStanzaReceived => {
+ if (aStanzaReceived.attributes.type != "result") {
+ return false;
+ }
+
+ // XEP-0045: Service Informs New Room Owner of Success
+ // for instant and reserved rooms.
+ this.left = false;
+ this.joining = false;
+ return true;
+ });
+ } else if (codes.includes("210")) {
+ // XEP-0045 (7.6): Changing Nickname.
+ // Service modifies this user's nickname in accordance with local service
+ // policies.
+ changeNick();
+ return;
+ } else if (codes.includes("110")) {
+ // XEP-0045: Room exists and joined successfully.
+ this.left = false;
+ this.joining = false;
+ // TODO (Bug 1172350): Implement Service Discovery Extensions (XEP-0128) to obtain
+ // configuration of this room.
+ } else if (codes.includes("104") && nick == this.name) {
+ // https://xmpp.org/extensions/inbox/muc-avatars.html (XEP-XXXX)
+ this._account._addVCardRequest(this.normalizedName);
+ }
+
+ if (!this._participants.get(nick)) {
+ let participant = new MUCParticipant(nick, from, aStanza);
+ this._participants.set(nick, participant);
+ this.notifyObservers(
+ new nsSimpleEnumerator([participant]),
+ "chat-buddy-add"
+ );
+ if (this.nick != nick && !this.joining) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.message.join", nick),
+ {
+ system: true,
+ }
+ );
+ } else if (this.nick == nick && this._rejoined) {
+ this.writeMessage(this.name, lazy._("conversation.message.rejoined"), {
+ system: true,
+ });
+ this._rejoined = false;
+ }
+ } else {
+ this._participants.get(nick).onPresenceStanza(aStanza);
+ this.notifyObservers(this._participants.get(nick), "chat-buddy-update");
+ }
+ },
+
+ /* Called by the account when a message is received for this muc */
+ incomingMessage(aMsg, aStanza, aDate) {
+ let from = this._account._parseJID(aStanza.attributes.from).resource;
+ let id = aStanza.attributes.id;
+ let flags = {};
+ if (!from) {
+ flags.system = true;
+ from = this.name;
+ } else if (aStanza.attributes.type == "error") {
+ aMsg = lazy._("conversation.error.notDelivered", aMsg);
+ flags.system = true;
+ flags.error = true;
+ } else if (from == this._nick) {
+ flags.outgoing = true;
+ } else {
+ flags.incoming = true;
+ }
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ if (id) {
+ // Checks if a message exists in conversation to avoid duplication.
+ if (this._messageIds.has(id)) {
+ return;
+ }
+ this._messageIds.add(id);
+ }
+ this.writeMessage(from, aMsg, flags);
+ },
+
+ getNormalizedChatBuddyName(aNick) {
+ return this._account.normalizeFullJid(this.name + "/" + aNick);
+ },
+
+ // Leaves MUC conversation.
+ part(aMsg = null) {
+ let s = Stanza.presence(
+ { to: this.name + "/" + this._nick, type: "unavailable" },
+ aMsg ? Stanza.node("status", null, null, aMsg.trim()) : null
+ );
+ this._account.sendStanza(s);
+ delete this.chatRoomFields;
+ },
+
+ // Invites a user to MUC conversation.
+ invite(aJID, aMsg = null) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.2): Mediated Invitation.
+ let invite = Stanza.node(
+ "invite",
+ null,
+ { to: aJID },
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let x = Stanza.node("x", Stanza.NS.muc_user, null, invite);
+ let s = Stanza.node("message", null, { to: this.name }, x);
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ forbidden: lazy._("conversation.error.inviteFailedForbidden"),
+ // ejabberd uses error not-allowed to indicate that this account does not
+ // have the required privileges to invite users instead of forbidden error,
+ // and this is not mentioned in the spec (XEP-0045).
+ notAllowed: lazy._("conversation.error.inviteFailedForbidden"),
+ itemNotFound: lazy._("conversation.error.failedJIDNotFound", aJID),
+ },
+ this
+ )
+ );
+ },
+
+ // Bans a participant from MUC conversation.
+ ban(aNickName, aMsg = null) {
+ // XEP-0045 (9.1): Banning a User.
+ let participant = this._participants.get(aNickName);
+ if (!participant) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.error.nickNotInRoom", aNickName),
+ { system: true }
+ );
+ return;
+ }
+ if (!participant.accountJid) {
+ this.writeMessage(
+ this.name,
+ lazy._("conversation.error.banCommandAnonymousRoom"),
+ { system: true }
+ );
+ return;
+ }
+
+ let attributes = { affiliation: "outcast", jid: participant.accountJid };
+ let item = Stanza.node(
+ "item",
+ null,
+ attributes,
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let s = Stanza.iq(
+ "set",
+ null,
+ this.name,
+ Stanza.node("query", Stanza.NS.muc_admin, null, item)
+ );
+ this._account.sendStanza(s, this._banKickHandler, this);
+ },
+
+ // Kicks a participant from MUC conversation.
+ kick(aNickName, aMsg = null) {
+ // XEP-0045 (8.2): Kicking an Occupant.
+ let attributes = { role: "none", nick: aNickName };
+ let item = Stanza.node(
+ "item",
+ null,
+ attributes,
+ aMsg ? Stanza.node("reason", null, null, aMsg) : null
+ );
+ let s = Stanza.iq(
+ "set",
+ null,
+ this.name,
+ Stanza.node("query", Stanza.NS.muc_admin, null, item)
+ );
+ this._account.sendStanza(s, this._banKickHandler, this);
+ },
+
+ // Callback for ban and kick commands.
+ _banKickHandler(aStanza) {
+ return this._account._handleResult(
+ {
+ notAllowed: lazy._("conversation.error.banKickCommandNotAllowed"),
+ conflict: lazy._("conversation.error.banKickCommandConflict"),
+ },
+ this
+ )(aStanza);
+ },
+
+ // Changes nick in MUC conversation to a new one.
+ setNick(aNewNick) {
+ // XEP-0045 (7.6): Changing Nickname.
+ let s = Stanza.presence({ to: this.name + "/" + aNewNick }, null);
+ this._account.sendStanza(
+ s,
+ this._account.handleErrors(
+ {
+ // XEP-0045 (7.6): Changing Nickname (example 53).
+ // TODO: We should discover if the user has a reserved nickname (maybe
+ // before joining a room), cf. XEP-0045 (7.12).
+ notAcceptable: lazy._(
+ "conversation.error.changeNickFailedNotAcceptable",
+ aNewNick
+ ),
+ // XEP-0045 (7.2.9): Nickname Conflict.
+ conflict: lazy._(
+ "conversation.error.changeNickFailedConflict",
+ aNewNick
+ ),
+ },
+ this
+ )
+ );
+ },
+
+ // Called by the account when a message stanza is received for this muc and
+ // needs to be handled.
+ onMessageStanza(aStanza) {
+ let x = aStanza.getElement(["x"]);
+ let decline = x.getElement(["decline"]);
+ if (decline) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.2): Mediated Invitation.
+ let invitee = decline.attributes.jid;
+ let reasonNode = decline.getElement(["reason"]);
+ let reason = reasonNode ? reasonNode.innerText : "";
+ let msg;
+ if (reason) {
+ msg = lazy._(
+ "conversation.message.invitationDeclined.reason",
+ invitee,
+ reason
+ );
+ } else {
+ msg = lazy._("conversation.message.invitationDeclined", invitee);
+ }
+
+ this.writeMessage(this.name, msg, { system: true });
+ } else {
+ this.WARN("Unhandled message stanza.");
+ }
+ },
+
+ /* Called when the user closed the conversation */
+ close() {
+ if (!this.left) {
+ this.part();
+ }
+ GenericConvChatPrototype.close.call(this);
+ },
+ unInit() {
+ this._account.removeConversation(this.name);
+ GenericConvChatPrototype.unInit.call(this);
+ },
+
+ _photoHash: null,
+ _saveIcon(aPhotoNode) {
+ this._account._saveResourceIcon(aPhotoNode, this).then(
+ url => {
+ this.convIconFilename = url;
+ },
+ error => {
+ this._account.WARN(
+ "Error while loading conversation icon for " +
+ this.normalizedName +
+ ": " +
+ error.message
+ );
+ }
+ );
+ },
+};
+
+function XMPPMUCConversation(aAccount, aJID, aNick) {
+ this._init(aAccount, aJID, aNick);
+}
+XMPPMUCConversation.prototype = XMPPMUCConversationPrototype;
+
+/* Helper class for buddy conversations */
+export var XMPPConversationPrototype = {
+ __proto__: GenericConvIMPrototype,
+
+ _typingTimer: null,
+ supportChatStateNotifications: true,
+ _typingState: "active",
+
+ // Indicates that current conversation is with a MUC participant and the
+ // recipient jid (stored in the userName) is of the form room@domain/nick.
+ _isMucParticipant: false,
+
+ get buddy() {
+ return this._account._buddies.get(this.name);
+ },
+ get title() {
+ return this.contactDisplayName;
+ },
+ get contactDisplayName() {
+ return this.buddy ? this.buddy.contactDisplayName : this.name;
+ },
+ get userName() {
+ return this.buddy ? this.buddy.userName : this.name;
+ },
+
+ // Returns jid (room@domain/nick) if it is with a MUC participant, and the
+ // name of conversation otherwise.
+ get normalizedName() {
+ if (this._isMucParticipant) {
+ return this._account.normalizeFullJid(this.name);
+ }
+ return this._account.normalize(this.name);
+ },
+
+ // Used to avoid showing full jids in typing notifications.
+ get shortName() {
+ if (this.buddy) {
+ return this.buddy.contactDisplayName;
+ }
+
+ let jid = this._account._parseJID(this.name);
+ if (!jid) {
+ return this.name;
+ }
+
+ // Returns nick of the recipient if conversation is with a participant of
+ // a MUC we are in as jid of the recipient is of the form room@domain/nick.
+ if (this._isMucParticipant) {
+ return jid.resource;
+ }
+
+ return jid.node;
+ },
+
+ get shouldSendTypingNotifications() {
+ return (
+ this.supportChatStateNotifications &&
+ Services.prefs.getBoolPref("purple.conversations.im.send_typing")
+ );
+ },
+
+ /* Called when the user is typing a message
+ * aString - the currently typed message
+ * Returns the number of characters that can still be typed */
+ sendTyping(aString) {
+ if (!this.shouldSendTypingNotifications) {
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ }
+
+ this._cancelTypingTimer();
+ if (aString.length) {
+ this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000);
+ }
+
+ this._setTypingState(aString.length ? "composing" : "active");
+
+ return Ci.prplIConversation.NO_TYPING_LIMIT;
+ },
+
+ finishedComposing() {
+ if (!this.shouldSendTypingNotifications) {
+ return;
+ }
+
+ this._setTypingState("paused");
+ },
+
+ _setTypingState(aNewState) {
+ if (this._typingState == aNewState) {
+ return;
+ }
+
+ let s = Stanza.message(this.to, null, aNewState);
+
+ // We don't care about errors in response to typing notifications
+ // (e.g. because the user has left the room when talking to a MUC
+ // participant).
+ this._account.sendStanza(s, () => true);
+
+ this._typingState = aNewState;
+ },
+ _cancelTypingTimer() {
+ if (this._typingTimer) {
+ clearTimeout(this._typingTimer);
+ delete this._typingTimer;
+ }
+ },
+
+ // Holds the resource of user that you are currently talking to, but if the
+ // user is a participant of a MUC we are in, holds the nick of user you are
+ // talking to.
+ _targetResource: "",
+
+ get to() {
+ if (!this._targetResource || this._isMucParticipant) {
+ return this.userName;
+ }
+ return this.userName + "/" + this._targetResource;
+ },
+
+ /* Called when the user enters a chat message */
+ dispatchMessage(aMsg, aAction = false) {
+ if (aAction) {
+ // XEP-0245: The /me Command.
+ // We need to prepend "/me " as the first four characters of the message
+ // body.
+ aMsg = "/me" + aMsg;
+ }
+ this._cancelTypingTimer();
+ let cs = this.shouldSendTypingNotifications ? "active" : null;
+ let s = Stanza.message(this.to, aMsg, cs);
+ this._account.sendStanza(s);
+ _displaySentMsg(this, aMsg);
+ delete this._typingState;
+ },
+
+ // Invites the contact to a MUC room.
+ invite(aRoomJid, aPassword = null) {
+ // XEP-0045 (7.8): Inviting Another User to a Room.
+ // XEP-0045 (7.8.1) and XEP-0249: Direct Invitation.
+ let x = Stanza.node("x", Stanza.NS.conference, {
+ jid: aRoomJid,
+ password: aPassword,
+ });
+ this._account.sendStanza(Stanza.node("message", null, { to: this.to }, x));
+ },
+
+ // Query the user for its Software Version.
+ // XEP-0092: Software Version.
+ getVersion() {
+ // TODO: Use Service Discovery to determine if the user's client supports
+ // jabber:iq:version protocol.
+
+ let s = Stanza.iq(
+ "get",
+ null,
+ this.to,
+ Stanza.node("query", Stanza.NS.version)
+ );
+ this._account.sendStanza(s, aStanza => {
+ // TODO: handle other errors that can result from querying
+ // user for its software version.
+ if (
+ this._account.handleErrors(
+ {
+ default: lazy._("conversation.error.version.unknown"),
+ },
+ this
+ )(aStanza)
+ ) {
+ return;
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (!query || query.uri != Stanza.NS.version) {
+ this.WARN(
+ "Received a response to version query which does not " +
+ "contain query element or 'jabber:iq:version' namespace."
+ );
+ return;
+ }
+
+ let name = query.getElement(["name"]);
+ let version = query.getElement(["version"]);
+ if (!name || !version) {
+ // XEP-0092: name and version elements are REQUIRED.
+ this.WARN(
+ "Received a response to version query which does not " +
+ "contain name or version."
+ );
+ return;
+ }
+
+ let messageID = "conversation.message.version";
+ let params = [this.shortName, name.innerText, version.innerText];
+
+ // XEP-0092: os is OPTIONAL.
+ let os = query.getElement(["os"]);
+ if (os) {
+ params.push(os.innerText);
+ messageID += "WithOS";
+ }
+
+ this.writeMessage(this.name, lazy._(messageID, ...params), {
+ system: true,
+ });
+ });
+ },
+
+ /* Perform entity escaping before displaying the message. We assume incoming
+ messages have already been escaped, and will otherwise be filtered. */
+ prepareForDisplaying(aMsg) {
+ if (aMsg.outgoing && !aMsg.system) {
+ aMsg.displayMessage = lazy.TXTToHTML(aMsg.displayMessage);
+ }
+ GenericConversationPrototype.prepareForDisplaying.apply(this, arguments);
+ },
+
+ /* Called by the account when a message is received from the buddy */
+ incomingMessage(aMsg, aStanza, aDate) {
+ let from = aStanza.attributes.from;
+ this._targetResource = this._account._parseJID(from).resource;
+ let flags = {};
+ let error = this._account.parseError(aStanza);
+ if (error) {
+ let norm = this._account.normalize(from);
+ let muc = this._account._mucs.get(norm);
+
+ if (!aMsg) {
+ // Failed outgoing message.
+ switch (error.condition) {
+ case "remote-server-not-found":
+ aMsg = lazy._("conversation.error.remoteServerNotFound");
+ break;
+ case "service-unavailable":
+ aMsg = lazy._(
+ "conversation.error.sendServiceUnavailable",
+ this.shortName
+ );
+ break;
+ default:
+ aMsg = lazy._("conversation.error.unknownSendError");
+ break;
+ }
+ } else if (
+ this._isMucParticipant &&
+ muc &&
+ !muc.left &&
+ error.condition == "item-not-found"
+ ) {
+ // XEP-0045 (7.5): MUC private messages.
+ // If we try to send to participant not in a room we are in.
+ aMsg = lazy._(
+ "conversation.error.sendFailedAsRecipientNotInRoom",
+ this._targetResource,
+ aMsg
+ );
+ } else if (
+ this._isMucParticipant &&
+ (error.condition == "item-not-found" ||
+ error.condition == "not-acceptable")
+ ) {
+ // If we left a room and try to send to a participant in it or the
+ // room is removed.
+ aMsg = lazy._(
+ "conversation.error.sendFailedAsNotInRoom",
+ this._account.normalize(from),
+ aMsg
+ );
+ } else {
+ aMsg = lazy._("conversation.error.notDelivered", aMsg);
+ }
+ flags.system = true;
+ flags.error = true;
+ } else {
+ flags = { incoming: true, _alias: this.contactDisplayName };
+ // XEP-0245: The /me Command.
+ if (aMsg.startsWith("/me ")) {
+ flags.action = true;
+ aMsg = aMsg.slice(4);
+ }
+ }
+ if (aDate) {
+ flags.time = aDate / 1000;
+ flags.delayed = true;
+ }
+ this.writeMessage(from, aMsg, flags);
+ },
+
+ /* Called when the user closed the conversation */
+ close() {
+ // TODO send the stanza indicating we have left the conversation?
+ GenericConvIMPrototype.close.call(this);
+ },
+ unInit() {
+ this._account.removeConversation(this.normalizedName);
+ GenericConvIMPrototype.unInit.call(this);
+ },
+};
+
+// Creates XMPP conversation.
+function XMPPConversation(aAccount, aNormalizedName, aMucParticipant) {
+ this._init(aAccount, aNormalizedName);
+ if (aMucParticipant) {
+ this._isMucParticipant = true;
+ }
+}
+XMPPConversation.prototype = XMPPConversationPrototype;
+
+/* Helper class for buddies */
+export var XMPPAccountBuddyPrototype = {
+ __proto__: GenericAccountBuddyPrototype,
+
+ subscription: "none",
+ // Returns a list of TooltipInfo objects to be displayed when the user
+ // hovers over the buddy.
+ getTooltipInfo() {
+ if (!this._account.connected) {
+ return null;
+ }
+
+ let tooltipInfo = [];
+ if (this._resources) {
+ for (let r in this._resources) {
+ let status = this._resources[r];
+ let statusString = Status.toLabel(status.statusType);
+ if (
+ status.statusType == Ci.imIStatusInfo.STATUS_IDLE &&
+ status.idleSince
+ ) {
+ let now = Math.floor(Date.now() / 1000);
+ let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(
+ now - status.idleSince
+ );
+ if (!valuesAndUnits[2]) {
+ valuesAndUnits.splice(2, 2);
+ }
+ statusString += " (" + valuesAndUnits.join(" ") + ")";
+ }
+ if (status.statusText) {
+ statusString += " - " + status.statusText;
+ }
+ let label = r
+ ? lazy._("tooltip.status", r)
+ : lazy._("tooltip.statusNoResource");
+ tooltipInfo.push(new TooltipInfo(label, statusString));
+ }
+ }
+
+ // The subscription value is interesting to display only in unusual cases.
+ if (this.subscription != "both") {
+ tooltipInfo.push(
+ new TooltipInfo(lazy._("tooltip.subscription"), this.subscription)
+ );
+ }
+
+ return tooltipInfo;
+ },
+
+ // _rosterAlias is the value stored in the roster on the XMPP
+ // server. For most servers we will be read/write.
+ _rosterAlias: "",
+ set rosterAlias(aNewAlias) {
+ let old = this.displayName;
+ this._rosterAlias = aNewAlias;
+ if (old != this.displayName) {
+ this._notifyObservers("display-name-changed", old);
+ }
+ },
+ _vCardReceived: false,
+ // _vCardFormattedName is the display name the contact has set for
+ // himself in his vCard. It's read-only from our point of view.
+ _vCardFormattedName: "",
+ set vCardFormattedName(aNewFormattedName) {
+ let old = this.displayName;
+ this._vCardFormattedName = aNewFormattedName;
+ if (old != this.displayName) {
+ this._notifyObservers("display-name-changed", old);
+ }
+ },
+
+ // _serverAlias is set by jsProtoHelper to the value we cached in sqlite.
+ // Use it only if we have neither of the other two values; usually because
+ // we haven't connected to the server yet.
+ get serverAlias() {
+ return this._rosterAlias || this._vCardFormattedName || this._serverAlias;
+ },
+ set serverAlias(aNewAlias) {
+ if (!this._rosterItem) {
+ this.ERROR(
+ "attempting to update the server alias of an account buddy " +
+ "for which we haven't received a roster item."
+ );
+ return;
+ }
+
+ let item = this._rosterItem;
+ if (aNewAlias) {
+ item.attributes.name = aNewAlias;
+ } else if ("name" in item.attributes) {
+ delete item.attributes.name;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster, null, item)
+ );
+ this._account.sendStanza(s);
+
+ // If we are going to change the alias on the server, discard the cached
+ // value that we got from our local sqlite storage at startup.
+ delete this._serverAlias;
+ },
+
+ /* Display name of the buddy */
+ get contactDisplayName() {
+ return this.buddy.contact.displayName || this.displayName;
+ },
+
+ get tag() {
+ return this._tag;
+ },
+ set tag(aNewTag) {
+ let oldTag = this._tag;
+ if (oldTag.name == aNewTag.name) {
+ this.ERROR("attempting to set the tag to the same value");
+ return;
+ }
+
+ this._tag = aNewTag;
+ IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag);
+
+ if (!this._rosterItem) {
+ this.ERROR(
+ "attempting to change the tag of an account buddy without roster item"
+ );
+ return;
+ }
+
+ let item = this._rosterItem;
+ let oldXML = item.getXML();
+ // Remove the old tag if it was listed in the roster item.
+ item.children = item.children.filter(
+ c => c.qName != "group" || c.innerText != oldTag.name
+ );
+ // Ensure the new tag is listed.
+ let newTagName = aNewTag.name;
+ if (!item.getChildren("group").some(g => g.innerText == newTagName)) {
+ item.addChild(Stanza.node("group", null, null, newTagName));
+ }
+ // Avoid sending anything to the server if the roster item hasn't changed.
+ // It's possible that the roster item hasn't changed if the roster
+ // item had several groups and the user moved locally the contact
+ // to another group where it already was on the server.
+ if (item.getXML() == oldXML) {
+ return;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster, null, item)
+ );
+ this._account.sendStanza(s);
+ },
+
+ remove() {
+ if (!this._account.connected) {
+ return;
+ }
+
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node(
+ "query",
+ Stanza.NS.roster,
+ null,
+ Stanza.node("item", null, {
+ jid: this.normalizedName,
+ subscription: "remove",
+ })
+ )
+ );
+ this._account.sendStanza(s);
+ },
+
+ _photoHash: null,
+ _saveIcon(aPhotoNode) {
+ this._account._saveResourceIcon(aPhotoNode, this).then(
+ url => {
+ this.buddyIconFilename = url;
+ },
+ error => {
+ this._account.WARN(
+ "Error loading buddy icon for " +
+ this.normalizedName +
+ ": " +
+ error.message
+ );
+ }
+ );
+ },
+
+ _preferredResource: undefined,
+ _resources: null,
+ onAccountDisconnected() {
+ delete this._preferredResource;
+ delete this._resources;
+ },
+ // Called by the account when a presence stanza is received for this buddy.
+ onPresenceStanza(aStanza) {
+ let preferred = this._preferredResource;
+
+ // Facebook chat's XMPP server doesn't send resources, let's
+ // replace undefined resources with empty resources.
+ let resource =
+ this._account._parseJID(aStanza.attributes.from).resource || "";
+
+ let type = aStanza.attributes.type;
+
+ // Reset typing status if the buddy is in a conversation and becomes unavailable.
+ let conv = this._account._conv.get(this.normalizedName);
+ if (type == "unavailable" && conv) {
+ conv.updateTyping(Ci.prplIConvIM.NOT_TYPING, this.contactDisplayName);
+ }
+
+ if (type == "unavailable" || type == "error") {
+ if (!this._resources || !(resource in this._resources)) {
+ // Ignore for already offline resources.
+ return;
+ }
+ delete this._resources[resource];
+ if (preferred == resource) {
+ preferred = undefined;
+ }
+ } else {
+ let statusInfo = parseStatus(aStanza);
+ let priority = aStanza.getElement(["priority"]);
+ priority = priority ? parseInt(priority.innerText, 10) : 0;
+
+ if (!this._resources) {
+ this._resources = {};
+ }
+ this._resources[resource] = {
+ statusType: statusInfo.statusType,
+ statusText: statusInfo.statusText,
+ idleSince: statusInfo.idleSince,
+ priority,
+ stanza: aStanza,
+ };
+ }
+
+ let photo = aStanza.getElement(["x", "photo"]);
+ if (photo && photo.uri == Stanza.NS.vcard_update) {
+ let hash = photo.innerText;
+ if (hash && hash != this._photoHash) {
+ this._account._addVCardRequest(this.normalizedName);
+ } else if (!hash && this._photoHash) {
+ delete this._photoHash;
+ this.buddyIconFilename = "";
+ }
+ }
+
+ for (let r in this._resources) {
+ if (
+ preferred === undefined ||
+ this._resources[r].statusType > this._resources[preferred].statusType
+ ) {
+ // FIXME also compare priorities...
+ preferred = r;
+ }
+ }
+ if (
+ preferred != undefined &&
+ preferred == this._preferredResource &&
+ resource != preferred
+ ) {
+ // The presence information change is only for an unused resource,
+ // only potential buddy tooltips need to be refreshed.
+ this._notifyObservers("status-detail-changed");
+ return;
+ }
+
+ // Presence info has changed enough that if we are having a
+ // conversation with one resource of this buddy, we should send
+ // the next message to all resources.
+ // FIXME: the test here isn't exactly right...
+ if (
+ this._preferredResource != preferred &&
+ this._account._conv.has(this.normalizedName)
+ ) {
+ delete this._account._conv.get(this.normalizedName)._targetResource;
+ }
+
+ this._preferredResource = preferred;
+ if (preferred === undefined) {
+ let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+ if (type == "unavailable") {
+ statusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ this.setStatus(statusType, "");
+ } else {
+ preferred = this._resources[preferred];
+ this.setStatus(preferred.statusType, preferred.statusText);
+ }
+ },
+
+ /* Can send messages to buddies who appear offline */
+ get canSendMessage() {
+ return this.account.connected;
+ },
+
+ /* Called when the user wants to chat with the buddy */
+ createConversation() {
+ return this._account.createConversation(this.normalizedName);
+ },
+};
+
+function XMPPAccountBuddy(aAccount, aBuddy, aTag, aUserName) {
+ this._init(aAccount, aBuddy, aTag, aUserName);
+}
+XMPPAccountBuddy.prototype = XMPPAccountBuddyPrototype;
+
+var XMPPRoomInfoPrototype = {
+ __proto__: ClassInfo("prplIRoomInfo", "XMPP RoomInfo Object"),
+ get topic() {
+ return "";
+ },
+ get participantCount() {
+ return Ci.prplIRoomInfo.NO_PARTICIPANT_COUNT;
+ },
+ get chatRoomFieldValues() {
+ let roomJid = this._account._roomList.get(this.name);
+ return this._account.getChatRoomDefaultFieldValues(roomJid);
+ },
+};
+function XMPPRoomInfo(aName, aAccount) {
+ this.name = aName;
+ this._account = aAccount;
+}
+XMPPRoomInfo.prototype = XMPPRoomInfoPrototype;
+
+/* Helper class for account */
+export var XMPPAccountPrototype = {
+ __proto__: GenericAccountPrototype,
+
+ _jid: null, // parsed Jabber ID: node, domain, resource
+ _connection: null, // XMPPSession socket
+ authMechanisms: null, // hook to let prpls tweak the list of auth mechanisms
+
+ // Contains the domain of MUC service which is obtained using service
+ // discovery.
+ _mucService: null,
+
+ // Maps room names to room jid.
+ _roomList: new Map(),
+
+ // Callbacks used when roomInfo is available.
+ _roomInfoCallbacks: new Set(),
+
+ // Determines if roomInfo that we have is expired or not.
+ _lastListTime: 0,
+ get isRoomInfoStale() {
+ return Date.now() - this._lastListTime > kListRefreshInterval;
+ },
+
+ // If true, we are waiting for replies.
+ _pendingList: false,
+
+ // An array of jids for which we still need to request vCards.
+ _pendingVCardRequests: [],
+
+ // XEP-0280: Message Carbons.
+ // If true, message carbons are currently enabled.
+ _isCarbonsEnabled: false,
+
+ /* Generate unique id for a stanza. Using id and unique sid is defined in
+ * RFC 6120 (Section 8.2.3, 4.7.3).
+ */
+ generateId: () => Services.uuid.generateUUID().toString().slice(1, -1),
+
+ _init(aProtoInstance, aImAccount) {
+ GenericAccountPrototype._init.call(this, aProtoInstance, aImAccount);
+
+ // Ongoing conversations.
+ // The keys of this._conv are assumed to be normalized like account@domain
+ // for normal conversations and like room@domain/nick for MUC participant
+ // convs.
+ this._conv = new NormalizedMap(this.normalizeFullJid.bind(this));
+
+ this._buddies = new NormalizedMap(this.normalize.bind(this));
+ this._mucs = new NormalizedMap(this.normalize.bind(this));
+
+ this._pendingVCardRequests = [];
+ },
+
+ get canJoinChat() {
+ return true;
+ },
+ chatRoomFields: {
+ room: {
+ get label() {
+ return lazy._("chatRoomField.room");
+ },
+ required: true,
+ },
+ server: {
+ get label() {
+ return lazy._("chatRoomField.server");
+ },
+ required: true,
+ },
+ nick: {
+ get label() {
+ return lazy._("chatRoomField.nick");
+ },
+ required: true,
+ },
+ password: {
+ get label() {
+ return lazy._("chatRoomField.password");
+ },
+ isPassword: true,
+ },
+ },
+ parseDefaultChatName(aDefaultChatName) {
+ if (!aDefaultChatName) {
+ return { nick: this._jid.node };
+ }
+
+ let params = aDefaultChatName.trim().split(/\s+/);
+ let jid = this._parseJID(params[0]);
+
+ // We swap node and domain as domain is required for parseJID, but node and
+ // resource are optional. In MUC join command, Node is required as it
+ // represents a room, but domain and resource are optional as we get muc
+ // domain from service discovery.
+ if (!jid.node && jid.domain) {
+ [jid.node, jid.domain] = [jid.domain, jid.node];
+ }
+
+ let chatFields = {
+ room: jid.node,
+ server: jid.domain || this._mucService,
+ nick: jid.resource || this._jid.node,
+ };
+ if (params.length > 1) {
+ chatFields.password = params[1];
+ }
+ return chatFields;
+ },
+ getChatRoomDefaultFieldValues(aDefaultChatName) {
+ let rv = GenericAccountPrototype.getChatRoomDefaultFieldValues.call(
+ this,
+ aDefaultChatName
+ );
+ if (!rv.values.nick) {
+ rv.values.nick = this._jid.node;
+ }
+ if (!rv.values.server && this._mucService) {
+ rv.values.server = this._mucService;
+ }
+
+ return rv;
+ },
+
+ // XEP-0045: Requests joining room if it exists or
+ // creating room if it does not exist.
+ joinChat(aComponents) {
+ let jid =
+ aComponents.getValue("room") + "@" + aComponents.getValue("server");
+ let nick = aComponents.getValue("nick");
+
+ let muc = this._mucs.get(jid);
+ if (muc) {
+ if (!muc.left) {
+ // We are already in this conversation.
+ return muc;
+ } else if (!muc.chatRoomFields) {
+ // We are rejoining a room that was parted by the user.
+ muc._rejoined = true;
+ }
+ } else {
+ muc = new this._MUCConversationConstructor(this, jid, nick);
+ this._mucs.set(jid, muc);
+ }
+
+ // Store the prplIChatRoomFieldValues to enable later reconnections.
+ muc.chatRoomFields = aComponents;
+ muc.joining = true;
+ muc.removeAllParticipants();
+
+ let password = aComponents.getValue("password");
+ let x = Stanza.node(
+ "x",
+ Stanza.NS.muc,
+ null,
+ password ? Stanza.node("password", null, null, password) : null
+ );
+ let logString;
+ if (password) {
+ logString =
+ "<presence .../> (Stanza containing password to join MUC " +
+ jid +
+ "/" +
+ nick +
+ " not logged)";
+ }
+ this.sendStanza(
+ Stanza.presence({ to: jid + "/" + nick }, x),
+ undefined,
+ undefined,
+ logString
+ );
+ return muc;
+ },
+
+ _idleSince: 0,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "idle-time-changed") {
+ let idleTime = parseInt(aData, 10);
+ if (idleTime) {
+ this._idleSince = Math.floor(Date.now() / 1000) - idleTime;
+ } else {
+ delete this._idleSince;
+ }
+ this._shouldSendPresenceForIdlenessChange = true;
+ executeSoon(
+ function () {
+ if ("_shouldSendPresenceForIdlenessChange" in this) {
+ this._sendPresence();
+ }
+ }.bind(this)
+ );
+ } else if (aTopic == "status-changed") {
+ this._sendPresence();
+ } else if (aTopic == "user-icon-changed") {
+ delete this._cachedUserIcon;
+ this._forceUserIconUpdate = true;
+ this._sendVCard();
+ } else if (aTopic == "user-display-name-changed") {
+ this._forceUserDisplayNameUpdate = true;
+ }
+ this._sendVCard();
+ },
+
+ /* GenericAccountPrototype events */
+ /* Connect to the server */
+ connect() {
+ this._jid = this._parseJID(this.name);
+
+ // For the resource, if the user has edited the option, always use that.
+ if (this.prefs.prefHasUserValue("resource")) {
+ let resource = this.getString("resource");
+
+ // this._jid needs to be updated. This value is however never used
+ // because while connected it's the jid of the session that's
+ // interesting.
+ this._jid = this._setJID(this._jid.domain, this._jid.node, resource);
+ } else if (this._jid.resource) {
+ // If there is a resource in the account name (inherited from libpurple),
+ // migrate it to the pref so it appears correctly in the advanced account
+ // options next time.
+ this.prefs.setStringPref("resource", this._jid.resource);
+ }
+
+ this._connection = new XMPPSession(
+ this.getString("server") || this._jid.domain,
+ this.getInt("port") || 5222,
+ this.getString("connection_security"),
+ this._jid,
+ this.imAccount.password,
+ this
+ );
+ },
+
+ remove() {
+ this._conv.forEach(conv => conv.close());
+ this._mucs.forEach(muc => muc.close());
+ this._buddies.forEach((buddy, jid) => this._forgetRosterItem(jid));
+ },
+
+ unInit() {
+ if (this._connection) {
+ this._disconnect(undefined, undefined, true);
+ }
+ delete this._jid;
+ delete this._conv;
+ delete this._buddies;
+ delete this._mucs;
+ },
+
+ /* Disconnect from the server */
+ disconnect() {
+ this._disconnect();
+ },
+
+ addBuddy(aTag, aName) {
+ if (!this._connection) {
+ throw new Error("The account isn't connected");
+ }
+
+ let jid = this.normalize(aName);
+ if (!jid || !jid.includes("@")) {
+ throw new Error("Invalid username");
+ }
+
+ if (this._buddies.has(jid)) {
+ let subscription = this._buddies.get(jid).subscription;
+ if (subscription && (subscription == "both" || subscription == "to")) {
+ this.DEBUG("not re-adding an existing buddy");
+ return;
+ }
+ } else {
+ let s = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node(
+ "query",
+ Stanza.NS.roster,
+ null,
+ Stanza.node(
+ "item",
+ null,
+ { jid },
+ Stanza.node("group", null, null, aTag.name)
+ )
+ )
+ );
+ this.sendStanza(
+ s,
+ this._handleResult({
+ default: aError => {
+ this.WARN(
+ "Unable to add a roster item due to " + aError + " error."
+ );
+ },
+ })
+ );
+ }
+ this.sendStanza(Stanza.presence({ to: jid, type: "subscribe" }));
+ },
+
+ /* Loads a buddy from the local storage.
+ * Called for each buddy locally stored before connecting
+ * to the server. */
+ loadBuddy(aBuddy, aTag) {
+ let buddy = new this._accountBuddyConstructor(this, aBuddy, aTag);
+ this._buddies.set(buddy.normalizedName, buddy);
+ return buddy;
+ },
+
+ /* Replies to a buddy request in order to accept it or deny it. */
+ replyToBuddyRequest(aReply, aRequest) {
+ if (!this._connection) {
+ return;
+ }
+ let s = Stanza.presence({ to: aRequest.userName, type: aReply });
+ this.sendStanza(s);
+ this.removeBuddyRequest(aRequest);
+ },
+
+ requestBuddyInfo(aJid) {
+ if (!this.connected) {
+ Services.obs.notifyObservers(EmptyEnumerator, "user-info-received", aJid);
+ return;
+ }
+
+ let userName;
+ let tooltipInfo = [];
+ let jid = this._parseJID(aJid);
+ let muc = this._mucs.get(jid.node + "@" + jid.domain);
+ let participant;
+ if (muc) {
+ participant = muc._participants.get(jid.resource);
+ if (participant) {
+ if (participant.accountJid) {
+ userName = participant.accountJid;
+ }
+ if (!muc.left) {
+ let statusType = participant.statusType;
+ let statusText = participant.statusText;
+ tooltipInfo.push(
+ new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status)
+ );
+
+ if (participant.buddyIconFilename) {
+ tooltipInfo.push(
+ new TooltipInfo(
+ null,
+ participant.buddyIconFilename,
+ Ci.prplITooltipInfo.icon
+ )
+ );
+ }
+ }
+ }
+ }
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ aJid
+ );
+
+ let iq = Stanza.iq(
+ "get",
+ null,
+ aJid,
+ Stanza.node("vCard", Stanza.NS.vcard)
+ );
+ this.sendStanza(iq, aStanza => {
+ let vCardInfo = {};
+ let vCardNode = aStanza.getElement(["vCard"]);
+
+ // In the case of an error response, we just notify the observers with
+ // what info we already have.
+ if (aStanza.attributes.type == "result" && vCardNode) {
+ vCardInfo = this.parseVCard(vCardNode);
+ }
+
+ // The real jid of participant which is of the form local@domain/resource.
+ // We consider the jid is provided by server is more correct than jid is
+ // set by the user.
+ if (userName) {
+ vCardInfo.userName = userName;
+ }
+
+ // vCard fields we want to display in the tooltip.
+ const kTooltipFields = [
+ "userName",
+ "fullName",
+ "nickname",
+ "title",
+ "organization",
+ "email",
+ "birthday",
+ "locality",
+ "country",
+ "telephone",
+ ];
+
+ let tooltipInfo = [];
+ for (let field of kTooltipFields) {
+ if (vCardInfo.hasOwnProperty(field)) {
+ tooltipInfo.push(
+ new TooltipInfo(lazy._("tooltip." + field), vCardInfo[field])
+ );
+ }
+ }
+ if (vCardInfo.photo) {
+ let dataURI = this._getPhotoURI(vCardInfo.photo);
+
+ // Store the photo URI for this participant.
+ if (participant) {
+ participant.buddyIconFilename = dataURI;
+ }
+
+ tooltipInfo.push(
+ new TooltipInfo(null, dataURI, Ci.prplITooltipInfo.icon)
+ );
+ }
+ Services.obs.notifyObservers(
+ new nsSimpleEnumerator(tooltipInfo),
+ "user-info-received",
+ aJid
+ );
+ });
+ },
+
+ // Parses the photo node of a received vCard if exists and returns string of
+ // data URI, otherwise returns null.
+ _getPhotoURI(aPhotoNode) {
+ if (!aPhotoNode) {
+ return null;
+ }
+
+ let type = aPhotoNode.getElement(["TYPE"]);
+ let value = aPhotoNode.getElement(["BINVAL"]);
+ if (!type || !value) {
+ return null;
+ }
+
+ return "data:" + type.innerText + ";base64," + value.innerText;
+ },
+
+ // Parses the vCard into the properties of the returned object.
+ parseVCard(aVCardNode) {
+ // XEP-0054: vcard-temp.
+ let aResult = {};
+ for (let node of aVCardNode.children.filter(
+ child => child.type == "node"
+ )) {
+ let localName = node.localName;
+ let innerText = node.innerText;
+ if (innerText) {
+ if (localName == "FN") {
+ aResult.fullName = innerText;
+ } else if (localName == "NICKNAME") {
+ aResult.nickname = innerText;
+ } else if (localName == "TITLE") {
+ aResult.title = innerText;
+ } else if (localName == "BDAY") {
+ aResult.birthday = innerText;
+ } else if (localName == "JABBERID") {
+ aResult.userName = innerText;
+ }
+ }
+ if (localName == "ORG") {
+ let organization = node.getElement(["ORGNAME"]);
+ if (organization && organization.innerText) {
+ aResult.organization = organization.innerText;
+ }
+ } else if (localName == "EMAIL") {
+ let userID = node.getElement(["USERID"]);
+ if (userID && userID.innerText) {
+ aResult.email = userID.innerText;
+ }
+ } else if (localName == "ADR") {
+ let locality = node.getElement(["LOCALITY"]);
+ if (locality && locality.innerText) {
+ aResult.locality = locality.innerText;
+ }
+
+ let country = node.getElement(["CTRY"]);
+ if (country && country.innerText) {
+ aResult.country = country.innerText;
+ }
+ } else if (localName == "PHOTO") {
+ aResult.photo = node;
+ } else if (localName == "TEL") {
+ let number = node.getElement(["NUMBER"]);
+ if (number && number.innerText) {
+ aResult.telephone = number.innerText;
+ }
+ }
+ // TODO: Parse the other fields of vCard and display it in system messages
+ // in response to /whois.
+ }
+ return aResult;
+ },
+
+ // Returns undefined if not an error stanza, and an object
+ // describing the error otherwise:
+ parseError(aStanza) {
+ if (aStanza.attributes.type != "error") {
+ return undefined;
+ }
+
+ let retval = { stanza: aStanza };
+ let error = aStanza.getElement(["error"]);
+
+ // RFC 6120 Section 8.3.2: Type must be one of
+ // auth -- retry after providing credentials
+ // cancel -- do not retry (the error cannot be remedied)
+ // continue -- proceed (the condition was only a warning)
+ // modify -- retry after changing the data sent
+ // wait -- retry after waiting (the error is temporary).
+ retval.type = error.attributes.type;
+
+ // RFC 6120 Section 8.3.3.
+ const kDefinedConditions = [
+ "bad-request",
+ "conflict",
+ "feature-not-implemented",
+ "forbidden",
+ "gone",
+ "internal-server-error",
+ "item-not-found",
+ "jid-malformed",
+ "not-acceptable",
+ "not-allowed",
+ "not-authorized",
+ "policy-violation",
+ "recipient-unavailable",
+ "redirect",
+ "registration-required",
+ "remote-server-not-found",
+ "remote-server-timeout",
+ "resource-constraint",
+ "service-unavailable",
+ "subscription-required",
+ "undefined-condition",
+ "unexpected-request",
+ ];
+ let condition = kDefinedConditions.find(c => error.getElement([c]));
+ if (!condition) {
+ // RFC 6120 Section 8.3.2.
+ this.WARN(
+ "Nonstandard or missing defined-condition element in error stanza."
+ );
+ condition = "undefined-condition";
+ }
+ retval.condition = condition;
+
+ let errortext = error.getElement(["text"]);
+ if (errortext) {
+ retval.text = errortext.innerText;
+ }
+
+ return retval;
+ },
+
+ // Returns an error-handling callback for use with sendStanza generated
+ // from aHandlers, an object defining the error handlers.
+ // If the stanza passed to the callback is an error stanza, it checks if
+ // aHandlers contains a property with the name of the defined condition
+ // of the error.
+ // * If the property is a function, it is called with the parsed error
+ // as its argument, bound to aThis (if provided).
+ // It should return true if the error was handled.
+ // * If the property is a string, it is displayed as a system message
+ // in the conversation given by aThis.
+ handleErrors(aHandlers, aThis) {
+ return aStanza => {
+ if (!aHandlers) {
+ return false;
+ }
+
+ let error = this.parseError(aStanza);
+ if (!error) {
+ return false;
+ }
+
+ let toCamelCase = aStr => {
+ // Turn defined condition string into a valid camelcase
+ // JS property name.
+ let capitalize = s => s[0].toUpperCase() + s.slice(1);
+ let uncapitalize = s => s[0].toLowerCase() + s.slice(1);
+ return uncapitalize(aStr.split("-").map(capitalize).join(""));
+ };
+ let condition = toCamelCase(error.condition);
+ // Check if we have a handler property for this kind of error or a
+ // default handler.
+ if (!(condition in aHandlers) && !("default" in aHandlers)) {
+ return false;
+ }
+
+ // Try to get the handler for condition, if we cannot get it, try to get
+ // the default handler.
+ let handler = aHandlers[condition];
+ if (!handler) {
+ handler = aHandlers.default;
+ }
+
+ if (typeof handler == "string") {
+ // The string is an error message to be displayed in the conversation.
+ if (!aThis || !aThis.writeMessage) {
+ this.ERROR(
+ "HandleErrors was passed an error message string, but " +
+ "no conversation to display it in:\n" +
+ handler
+ );
+ return true;
+ }
+ aThis.writeMessage(aThis.name, handler, { system: true, error: true });
+ return true;
+ } else if (typeof handler == "function") {
+ // If we're given a function, call this error handler.
+ return handler.call(aThis, error);
+ }
+
+ // If this happens, there's a bug somewhere.
+ this.ERROR(
+ "HandleErrors was passed a handler for '" +
+ condition +
+ "'' which is neither a function nor a string."
+ );
+ return false;
+ };
+ },
+
+ // Returns a callback suitable for use in sendStanza, to handle type==result
+ // responses. aHandlers and aThis are passed on to handleErrors for error
+ // handling.
+ _handleResult(aHandlers, aThis) {
+ return aStanza => {
+ if (aStanza.attributes.type == "result") {
+ return true;
+ }
+ return this.handleErrors(aHandlers, aThis)(aStanza);
+ };
+ },
+
+ /* XMPPSession events */
+
+ /* Called when the XMPP session is started */
+ onConnection() {
+ // Request the roster. The account will be marked as connected when this is
+ // complete.
+ this.reportConnecting(lazy._("connection.downloadingRoster"));
+ let s = Stanza.iq(
+ "get",
+ null,
+ null,
+ Stanza.node("query", Stanza.NS.roster)
+ );
+ this.sendStanza(s, this.onRoster, this);
+
+ // XEP-0030 and XEP-0045 (6): Service Discovery.
+ // Queries Server for Associated Services.
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._jid.domain,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onServiceDiscovery, this);
+
+ // XEP-0030: Service Discovery Information Features.
+ iq = Stanza.iq(
+ "get",
+ null,
+ this._jid.domain,
+ Stanza.node("query", Stanza.NS.disco_info)
+ );
+ this.sendStanza(iq, this.onServiceDiscoveryInfo, this);
+ },
+
+ /* Called whenever a stanza is received */
+ onXmppStanza(aStanza) {},
+
+ /* Called when a iq stanza is received */
+ onIQStanza(aStanza) {
+ let type = aStanza.attributes.type;
+ if (type == "set") {
+ for (let query of aStanza.getChildren("query")) {
+ if (query.uri != Stanza.NS.roster) {
+ continue;
+ }
+
+ // RFC 6121 2.1.6 (Roster push):
+ // A receiving client MUST ignore the stanza unless it has no 'from'
+ // attribute (i.e., implicitly from the bare JID of the user's
+ // account) or it has a 'from' attribute whose value matches the
+ // user's bare JID <user@domainpart>.
+ let from = aStanza.attributes.from;
+ if (from && from != this._jid.node + "@" + this._jid.domain) {
+ this.WARN("Ignoring potentially spoofed roster push.");
+ return;
+ }
+
+ for (let item of query.getChildren("item")) {
+ this._onRosterItem(item, true);
+ }
+ return;
+ }
+ } else if (type == "get") {
+ let id = aStanza.attributes.id;
+ let from = aStanza.attributes.from;
+
+ // XEP-0199: XMPP server-to-client ping (XEP-0199)
+ let ping = aStanza.getElement(["ping"]);
+ if (ping && ping.uri == Stanza.NS.ping) {
+ this.sendStanza(Stanza.iq("result", id, from));
+ return;
+ }
+
+ let query = aStanza.getElement(["query"]);
+ if (query && query.uri == Stanza.NS.version) {
+ // XEP-0092: Software Version.
+ let children = [];
+ children.push(Stanza.node("name", null, null, Services.appinfo.name));
+ children.push(
+ Stanza.node("version", null, null, Services.appinfo.version)
+ );
+ let versionQuery = Stanza.node(
+ "query",
+ Stanza.NS.version,
+ null,
+ children
+ );
+ this.sendStanza(Stanza.iq("result", id, from, versionQuery));
+ return;
+ }
+ if (query && query.uri == Stanza.NS.disco_info) {
+ // XEP-0030: Service Discovery.
+ let children = [];
+ if (aStanza.attributes.node == Stanza.NS.muc_rooms) {
+ // XEP-0045 (6.7): Room query.
+ // TODO: Currently, we return an empty <query/> element, but we
+ // should return non-private rooms.
+ } else {
+ children = SupportedFeatures.map(feature =>
+ Stanza.node("feature", null, { var: feature })
+ );
+ children.unshift(
+ Stanza.node("identity", null, {
+ category: "client",
+ type: "pc",
+ name: Services.appinfo.name,
+ })
+ );
+ }
+ let discoveryQuery = Stanza.node(
+ "query",
+ Stanza.NS.disco_info,
+ null,
+ children
+ );
+ this.sendStanza(Stanza.iq("result", id, from, discoveryQuery));
+ return;
+ }
+ }
+ this.WARN(`Unhandled IQ ${type} stanza.`);
+ if (type == "get" || type == "set") {
+ // RFC 6120 (section 8.2.3): An entity that receives an IQ request of
+ // type "get" or "set" MUST reply with an IQ response of type "result"
+ // or "error".
+ let id = aStanza.attributes.id;
+ let from = aStanza.attributes.from;
+ let condition = Stanza.node("service-unavailable", Stanza.NS.stanzas, {
+ type: "cancel",
+ });
+ let error = Stanza.node("error", null, { type: "cancel" }, condition);
+ this.sendStanza(Stanza.iq("error", id, from, error));
+ }
+ },
+
+ /* Called when a presence stanza is received */
+ onPresenceStanza(aStanza) {
+ let from = aStanza.attributes.from;
+ this.DEBUG("Received presence stanza for " + from);
+
+ let jid = this.normalize(from);
+ let type = aStanza.attributes.type;
+ if (type == "subscribe") {
+ this.addBuddyRequest(
+ jid,
+ this.replyToBuddyRequest.bind(this, "subscribed"),
+ this.replyToBuddyRequest.bind(this, "unsubscribed")
+ );
+ } else if (
+ type == "unsubscribe" ||
+ type == "unsubscribed" ||
+ type == "subscribed"
+ ) {
+ // Nothing useful to do for these presence stanzas, as we will also
+ // receive a roster push containing more or less the same information
+ } else if (this._buddies.has(jid)) {
+ this._buddies.get(jid).onPresenceStanza(aStanza);
+ } else if (this._mucs.has(jid)) {
+ this._mucs.get(jid).onPresenceStanza(aStanza);
+ } else if (jid != this.normalize(this._connection._jid.jid)) {
+ this.WARN("received presence stanza for unknown buddy " + from);
+ } else if (
+ jid == this._jid.node + "@" + this._jid.domain &&
+ this._connection._resource != this._parseJID(from).resource
+ ) {
+ // Ignore presence stanzas for another resource.
+ } else {
+ this.WARN("Unhandled presence stanza.");
+ }
+ },
+
+ // XEP-0030: Discovering services and their features that are supported by
+ // the server.
+ onServiceDiscovery(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_items
+ ) {
+ this.LOG("Could not get services for this server: " + this._jid.domain);
+ return true;
+ }
+
+ // Discovering the Features that are Supported by each service.
+ query.getElements(["item"]).forEach(item => {
+ let jid = item.attributes.jid;
+ if (!jid) {
+ return;
+ }
+ let iq = Stanza.iq(
+ "get",
+ null,
+ jid,
+ Stanza.node("query", Stanza.NS.disco_info)
+ );
+ this.sendStanza(iq, receivedStanza => {
+ let query = receivedStanza.getElement(["query"]);
+ let from = receivedStanza.attributes.from;
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_info
+ ) {
+ this.LOG("Could not get features for this service: " + from);
+ return true;
+ }
+ let features = query
+ .getElements(["feature"])
+ .map(elt => elt.attributes.var);
+ let identity = query.getElement(["identity"]);
+ if (
+ identity &&
+ identity.attributes.category == "conference" &&
+ identity.attributes.type == "text" &&
+ features.includes(Stanza.NS.muc)
+ ) {
+ // XEP-0045 (6.2): this feature is for a MUC Service.
+ // XEP-0045 (15.2): Service Discovery Category/Type.
+ this._mucService = from;
+ }
+ // TODO: Handle other services that are supported by XMPP through
+ // their features.
+
+ return true;
+ });
+ });
+ return true;
+ },
+
+ // XEP-0030: Discovering Service Information and its features that are
+ // supported by the server.
+ onServiceDiscoveryInfo(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (
+ aStanza.attributes.type != "result" ||
+ !query ||
+ query.uri != Stanza.NS.disco_info
+ ) {
+ this.LOG("Could not get features for this server: " + this._jid.domain);
+ return true;
+ }
+
+ let features = query
+ .getElements(["feature"])
+ .map(elt => elt.attributes.var);
+ if (features.includes(Stanza.NS.carbons)) {
+ // XEP-0280: Message Carbons.
+ // Enabling Carbons on server, as it's disabled by default on server.
+ if (Services.prefs.getBoolPref("chat.xmpp.messageCarbons")) {
+ let iqStanza = Stanza.iq(
+ "set",
+ null,
+ null,
+ Stanza.node("enable", Stanza.NS.carbons)
+ );
+ this.sendStanza(iqStanza, aStanza => {
+ let error = this.parseError(aStanza);
+ if (error) {
+ this.WARN(
+ "Unable to enable message carbons due to " +
+ error.condition +
+ " error."
+ );
+ return true;
+ }
+
+ let type = aStanza.attributes.type;
+ if (type != "result") {
+ this.WARN(
+ "Received unexpected stanza with " +
+ type +
+ " type " +
+ "while enabling message carbons."
+ );
+ return true;
+ }
+
+ this.LOG("Message carbons enabled.");
+ this._isCarbonsEnabled = true;
+ return true;
+ });
+ }
+ }
+ // TODO: Handle other features that are supported by the server.
+ return true;
+ },
+
+ requestRoomInfo(aCallback) {
+ if (this._roomInfoCallbacks.has(aCallback)) {
+ return;
+ }
+
+ if (this.isRoomInfoStale && !this._pendingList) {
+ this._roomList = new Map();
+ this._lastListTime = Date.now();
+ this._roomInfoCallback = aCallback;
+ this._pendingList = true;
+
+ // XEP-0045 (6.3): Discovering Rooms.
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._mucService,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onRoomDiscovery, this);
+ } else {
+ let rooms = [...this._roomList.keys()];
+ aCallback.onRoomInfoAvailable(rooms, !this._pendingList);
+ }
+
+ if (this._pendingList) {
+ this._roomInfoCallbacks.add(aCallback);
+ }
+ },
+
+ onRoomDiscovery(aStanza) {
+ let query = aStanza.getElement(["query"]);
+ if (!query || query.uri != Stanza.NS.disco_items) {
+ this.LOG("Could not get rooms for this server: " + this._jid.domain);
+ return;
+ }
+
+ // XEP-0059: Result Set Management.
+ let set = query.getElement(["set"]);
+ let last = set ? set.getElement(["last"]) : null;
+ if (last) {
+ let iq = Stanza.iq(
+ "get",
+ null,
+ this._mucService,
+ Stanza.node("query", Stanza.NS.disco_items)
+ );
+ this.sendStanza(iq, this.onRoomDiscovery, this);
+ } else {
+ this._pendingList = false;
+ }
+
+ let rooms = [];
+ query.getElements(["item"]).forEach(item => {
+ let jid = this._parseJID(item.attributes.jid);
+ if (!jid) {
+ return;
+ }
+
+ let name = item.attributes.name;
+ if (!name) {
+ name = jid.node ? jid.node : jid.jid;
+ }
+
+ this._roomList.set(name, jid.jid);
+ rooms.push(name);
+ });
+
+ this._roomInfoCallback.onRoomInfoAvailable(rooms, !this._pendingList);
+ },
+
+ getRoomInfo(aName) {
+ return new XMPPRoomInfo(aName, this);
+ },
+
+ // Returns null if not an invitation stanza, and an object
+ // describing the invitation otherwise.
+ parseInvitation(aStanza) {
+ let x = aStanza.getElement(["x"]);
+ if (!x) {
+ return null;
+ }
+ let retVal = {
+ shouldDecline: false,
+ };
+
+ // XEP-0045. Direct Invitation (7.8.1)
+ // Described in XEP-0249.
+ // jid (chatroom) is required.
+ // Password, reason, continue and thread are optional.
+ if (x.uri == Stanza.NS.conference) {
+ if (!x.attributes.jid) {
+ this.WARN("Received an invitation with missing MUC jid.");
+ return null;
+ }
+ retVal.mucJid = this.normalize(x.attributes.jid);
+ retVal.from = this.normalize(aStanza.attributes.from);
+ retVal.password = x.attributes.password;
+ retVal.reason = x.attributes.reason;
+ retVal.continue = x.attributes.continue;
+ retVal.thread = x.attributes.thread;
+ return retVal;
+ }
+
+ // XEP-0045. Mediated Invitation (7.8.2)
+ // Sent by the chatroom on behalf of someone in the chatroom.
+ // jid (chatroom) and from (inviter) are required.
+ // password and reason are optional.
+ if (x.uri == Stanza.NS.muc_user) {
+ let invite = x.getElement(["invite"]);
+ if (!invite || !invite.attributes.from) {
+ this.WARN("Received an invitation with missing MUC invite or from.");
+ return null;
+ }
+ retVal.mucJid = this.normalize(aStanza.attributes.from);
+ retVal.from = this.normalize(invite.attributes.from);
+ retVal.shouldDecline = true;
+ let continueElement = invite.getElement(["continue"]);
+ retVal.continue = !!continueElement;
+ if (continueElement) {
+ retVal.thread = continueElement.attributes.thread;
+ }
+ if (x.getElement(["password"])) {
+ retVal.password = x.getElement(["password"]).innerText;
+ }
+ if (invite.getElement(["reason"])) {
+ retVal.reason = invite.getElement(["reason"]).innerText;
+ }
+ return retVal;
+ }
+
+ return null;
+ },
+
+ /* Called when a message stanza is received */
+ onMessageStanza(aStanza) {
+ // XEP-0280: Message Carbons.
+ // Sending and Receiving Messages.
+ // Indicates that the forwarded message was sent or received.
+ let isSent = false;
+ let carbonStanza =
+ aStanza.getElement(["sent"]) || aStanza.getElement(["received"]);
+ if (carbonStanza) {
+ if (carbonStanza.uri != Stanza.NS.carbons) {
+ this.WARN(
+ "Received a forwarded message which does not '" +
+ Stanza.NS.carbons +
+ "' namespace."
+ );
+ return;
+ }
+
+ isSent = carbonStanza.localName == "sent";
+ carbonStanza = carbonStanza.getElement(["forwarded", "message"]);
+ if (this._isCarbonsEnabled) {
+ aStanza = carbonStanza;
+ } else {
+ this.WARN(
+ "Received an unexpected forwarded message while message " +
+ "carbons are not enabled."
+ );
+ return;
+ }
+ }
+
+ // For forwarded sent messages, we need to use "to" attribute to
+ // get the right conversation as from in this case is this account.
+ let convJid = isSent ? aStanza.attributes.to : aStanza.attributes.from;
+
+ let normConvJid = this.normalize(convJid);
+ let isMuc = this._mucs.has(normConvJid);
+
+ let type = aStanza.attributes.type;
+ let x = aStanza.getElement(["x"]);
+ let body;
+ let b = aStanza.getElement(["body"]);
+ if (b) {
+ // If there's a <body> child we have more than just typing notifications.
+ // Prefer HTML (in <html><body>) and use plain text (<body>) as fallback.
+ let htmlBody = aStanza.getElement(["html", "body"]);
+ if (htmlBody) {
+ body = htmlBody.innerXML;
+ } else {
+ // Even if the message is in plain text, the prplIMessage
+ // should contain a string that's correctly escaped for
+ // insertion in an HTML document.
+ body = lazy.TXTToHTML(b.innerText);
+ }
+ }
+
+ let subject = aStanza.getElement(["subject"]);
+ // Ignore subject when !isMuc. We're being permissive about subject changes
+ // in the comment below, so we need to be careful about where that makes
+ // sense. Psi+'s OTR plugin includes a subject and body in its message
+ // stanzas.
+ if (subject && isMuc) {
+ // XEP-0045 (7.2.16): Check for a subject element in the stanza and update
+ // the topic if it exists.
+ // We are breaking the spec because only a message that contains a
+ // <subject/> but no <body/> element shall be considered a subject change
+ // for MUC, but we ignore that to be compatible with ejabberd versions
+ // before 15.06.
+ let muc = this._mucs.get(normConvJid);
+ let nick = this._parseJID(convJid).resource;
+ // TODO There can be multiple subject elements with different xml:lang
+ // attributes.
+ muc.setTopic(subject.innerText, nick);
+ return;
+ }
+
+ let invitation = this.parseInvitation(aStanza);
+ if (invitation) {
+ let messageID;
+ if (invitation.reason) {
+ messageID = "conversation.muc.invitationWithReason2";
+ } else {
+ messageID = "conversation.muc.invitationWithoutReason";
+ }
+ if (invitation.password) {
+ messageID += ".password";
+ }
+ let params = [
+ invitation.from,
+ invitation.mucJid,
+ invitation.password,
+ invitation.reason,
+ ].filter(s => s);
+ let message = lazy._(messageID, ...params);
+
+ this.addChatRequest(
+ invitation.mucJid,
+ () => {
+ let chatRoomFields = this.getChatRoomDefaultFieldValues(
+ invitation.mucJid
+ );
+ if (invitation.password) {
+ chatRoomFields.setValue("password", invitation.password);
+ }
+ let muc = this.joinChat(chatRoomFields);
+ muc.writeMessage(muc.name, message, { system: true });
+ },
+ (request, tryToDeny) => {
+ // Only mediated invitations (XEP-0045) can explicitly decline.
+ if (invitation.shouldDecline && tryToDeny) {
+ let decline = Stanza.node(
+ "decline",
+ null,
+ { from: invitation.from },
+ null
+ );
+ let x = Stanza.node("x", Stanza.NS.muc_user, null, decline);
+ let s = Stanza.node("message", null, { to: invitation.mucJid }, x);
+ this.sendStanza(s);
+ }
+ // Always show invite reason or password, even if the invite wasn't
+ // automatically declined based on the setting.
+ if (!request || invitation.reason || invitation.password) {
+ let conv = this.createConversation(invitation.from);
+ if (conv) {
+ conv.writeMessage(invitation.from, message, { system: true });
+ }
+ }
+ }
+ );
+ }
+
+ if (body) {
+ let date = _getDelay(aStanza);
+ if (
+ type == "groupchat" ||
+ (type == "error" && isMuc && !this._conv.has(convJid))
+ ) {
+ if (!isMuc) {
+ this.WARN(
+ "Received a groupchat message for unknown MUC " + normConvJid
+ );
+ return;
+ }
+ let muc = this._mucs.get(normConvJid);
+ muc.incomingMessage(body, aStanza, date);
+ return;
+ }
+
+ let conv = this.createConversation(convJid);
+ if (!conv) {
+ return;
+ }
+
+ if (isSent) {
+ _displaySentMsg(conv, body, date);
+ return;
+ }
+ conv.incomingMessage(body, aStanza, date);
+ } else if (type == "error") {
+ let conv = this.createConversation(convJid);
+ if (conv) {
+ conv.incomingMessage(null, aStanza);
+ }
+ } else if (x && x.uri == Stanza.NS.muc_user) {
+ let muc = this._mucs.get(normConvJid);
+ if (!muc) {
+ this.WARN(
+ "Received a groupchat message for unknown MUC " + normConvJid
+ );
+ return;
+ }
+ muc.onMessageStanza(aStanza);
+ return;
+ }
+
+ // If this is a sent message carbon, the user is typing on another client.
+ if (isSent) {
+ return;
+ }
+
+ // Don't create a conversation to only display the typing notifications.
+ if (!this._conv.has(normConvJid) && !this._conv.has(convJid)) {
+ return;
+ }
+
+ // Ignore errors while delivering typing notifications.
+ if (type == "error") {
+ return;
+ }
+
+ let typingState = Ci.prplIConvIM.NOT_TYPING;
+ let state;
+ let s = aStanza.getChildrenByNS(Stanza.NS.chatstates);
+ if (s.length > 0) {
+ state = s[0].localName;
+ }
+ if (state) {
+ this.DEBUG(state);
+ if (state == "composing") {
+ typingState = Ci.prplIConvIM.TYPING;
+ } else if (state == "paused") {
+ typingState = Ci.prplIConvIM.TYPED;
+ }
+ }
+ let convName = normConvJid;
+
+ // If the bare JID is a MUC that we have joined, use the full JID as this
+ // is a private message to a MUC participant.
+ if (isMuc) {
+ convName = convJid;
+ }
+
+ let conv = this._conv.get(convName);
+ if (!conv) {
+ return;
+ }
+ conv.updateTyping(typingState, conv.shortName);
+ conv.supportChatStateNotifications = !!state;
+ },
+
+ /** Called when there is an error in the XMPP session */
+ onError(aError, aException) {
+ if (aError === null || aError === undefined) {
+ aError = Ci.prplIAccount.ERROR_OTHER_ERROR;
+ }
+ this._disconnect(aError, aException.toString());
+ },
+
+ onVCard(aStanza) {
+ let jid = this._pendingVCardRequests.shift();
+ this._requestNextVCard();
+ if (!this._buddies.has(jid) && !this._mucs.has(jid)) {
+ this.WARN("Received a vCard for unknown buddy " + jid);
+ return;
+ }
+
+ let vCard = aStanza.getElement(["vCard"]);
+ let error = this.parseError(aStanza);
+ if (
+ (error &&
+ (error.condition == "item-not-found" ||
+ error.condition == "service-unavailable")) ||
+ !vCard ||
+ !vCard.children.length
+ ) {
+ this.LOG("No vCard exists (or the user does not exist) for " + jid);
+ return;
+ } else if (error) {
+ this.WARN("Received unexpected vCard error " + error.condition);
+ return;
+ }
+
+ let stanzaJid = this.normalize(aStanza.attributes.from);
+ if (jid && jid != stanzaJid) {
+ this.ERROR(
+ "Received vCard for a different jid (" +
+ stanzaJid +
+ ") " +
+ "than the requested " +
+ jid
+ );
+ }
+
+ let foundFormattedName = false;
+ let vCardInfo = this.parseVCard(vCard);
+ if (this._mucs.has(jid)) {
+ const conv = this._mucs.get(jid);
+ if (vCardInfo.photo) {
+ conv._saveIcon(vCardInfo.photo);
+ }
+ return;
+ }
+ let buddy = this._buddies.get(jid);
+ if (vCardInfo.fullName) {
+ buddy.vCardFormattedName = vCardInfo.fullName;
+ foundFormattedName = true;
+ }
+ if (vCardInfo.photo) {
+ buddy._saveIcon(vCardInfo.photo);
+ }
+ if (!foundFormattedName && buddy._vCardFormattedName) {
+ buddy.vCardFormattedName = "";
+ }
+ buddy._vCardReceived = true;
+ },
+
+ /**
+ * Save the icon for a resource to the local file system.
+ *
+ * @param photo - The vcard photo node representing the icon.
+ * @param {prplIChatBuddy|prplIConversation} resource - Resource the icon is for.
+ * @returns {Promise<string>} Resolves with the file:// URI to the local icon file.
+ */
+ _saveResourceIcon(photo, resource) {
+ // Some servers seem to send a photo node without a type declared.
+ let type = photo.getElement(["TYPE"]);
+ if (!type) {
+ return Promise.reject(new Error("Missing image type"));
+ }
+ type = type.innerText;
+ const kExt = {
+ "image/gif": "gif",
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ };
+ if (!kExt.hasOwnProperty(type)) {
+ return Promise.reject(new Error("Unknown image type"));
+ }
+
+ let content = "",
+ data = "";
+ // Strip all characters not allowed in base64 before parsing.
+ let parseBase64 = aBase => atob(aBase.replace(/[^A-Za-z0-9\+\/\=]/g, ""));
+ for (let line of photo.getElement(["BINVAL"]).innerText.split("\n")) {
+ data += line;
+ // Mozilla's atob() doesn't handle padding with "=" or "=="
+ // unless it's at the end of the string, so we have to work around that.
+ if (line.endsWith("=")) {
+ content += parseBase64(data);
+ data = "";
+ }
+ }
+ content += parseBase64(data);
+
+ // Store a sha1 hash of the photo we have just received.
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ ch.init(ch.SHA1);
+ let dataArray = Object.keys(content).map(i => content.charCodeAt(i));
+ ch.update(dataArray, dataArray.length);
+ let hash = ch.finish(false);
+ function toHexString(charCode) {
+ return charCode.toString(16).padStart(2, "0");
+ }
+ resource._photoHash = Object.keys(hash)
+ .map(i => toHexString(hash.charCodeAt(i)))
+ .join("");
+
+ let istream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ istream.setData(content, content.length);
+
+ let fileName = resource._photoHash + "." + kExt[type];
+ let file = lazy.FileUtils.getFile("ProfD", [
+ "icons",
+ this.protocol.normalizedName,
+ this.normalizedName,
+ fileName,
+ ]);
+ let ostream = lazy.FileUtils.openSafeFileOutputStream(file);
+ return new Promise(resolve => {
+ lazy.NetUtil.asyncCopy(istream, ostream, rc => {
+ if (Components.isSuccessCode(rc)) {
+ resolve(Services.io.newFileURI(file).spec);
+ }
+ });
+ });
+ },
+
+ _requestNextVCard() {
+ if (!this._pendingVCardRequests.length) {
+ return;
+ }
+ let s = Stanza.iq(
+ "get",
+ null,
+ this._pendingVCardRequests[0],
+ Stanza.node("vCard", Stanza.NS.vcard)
+ );
+ this.sendStanza(s, this.onVCard, this);
+ },
+
+ _addVCardRequest(aJID) {
+ let requestPending = !!this._pendingVCardRequests.length;
+ this._pendingVCardRequests.push(aJID);
+ if (!requestPending) {
+ this._requestNextVCard();
+ }
+ },
+
+ // XEP-0029 (Section 2) and RFC 6122 (Section 2): The node and domain are
+ // lowercase, while resources are case sensitive and can contain spaces.
+ normalizeFullJid(aJID) {
+ return this._parseJID(aJID.trim()).jid;
+ },
+
+ // Standard normalization for XMPP removes the resource part of jids.
+ normalize(aJID) {
+ return aJID
+ .trim()
+ .split("/", 1)[0] // up to first slash
+ .toLowerCase();
+ },
+
+ // RFC 6122 (Section 2): [ localpart "@" ] domainpart [ "/" resourcepart ] is
+ // the form of jid.
+ // Localpart is parsed as node and optional.
+ // Domainpart is parsed as domain and required.
+ // resourcepart is parsed as resource and optional.
+ _parseJID(aJid) {
+ let match = /^(?:([^"&'/:<>@]+)@)?([^@/<>'\"]+)(?:\/(.*))?$/.exec(
+ aJid.trim()
+ );
+ if (!match) {
+ return null;
+ }
+
+ let result = {
+ node: match[1],
+ domain: match[2].toLowerCase(),
+ resource: match[3],
+ };
+ return this._setJID(result.domain, result.node, result.resource);
+ },
+
+ // Constructs jid as an object from domain, node and resource parts.
+ // The object has properties (node, domain, resource and jid).
+ // aDomain is required, but aNode and aResource are optional.
+ _setJID(aDomain, aNode = null, aResource = null) {
+ if (!aDomain) {
+ throw new Error("aDomain must have a value");
+ }
+
+ let result = {
+ node: aNode,
+ domain: aDomain.toLowerCase(),
+ resource: aResource,
+ };
+ let jid = result.domain;
+ if (result.node) {
+ result.node = result.node.toLowerCase();
+ jid = result.node + "@" + jid;
+ }
+ if (result.resource) {
+ jid += "/" + result.resource;
+ }
+ result.jid = jid;
+ return result;
+ },
+
+ _onRosterItem(aItem, aNotifyOfUpdates) {
+ let jid = aItem.attributes.jid;
+ if (!jid) {
+ this.WARN("Received a roster item without jid: " + aItem.getXML());
+ return "";
+ }
+ jid = this.normalize(jid);
+
+ let subscription = "";
+ if ("subscription" in aItem.attributes) {
+ subscription = aItem.attributes.subscription;
+ }
+ if (subscription == "remove") {
+ this._forgetRosterItem(jid);
+ return "";
+ }
+
+ let buddy;
+ if (this._buddies.has(jid)) {
+ buddy = this._buddies.get(jid);
+ let groups = aItem.getChildren("group");
+ if (groups.length) {
+ // If the server specified at least one group, ensure the group we use
+ // as the account buddy's tag is still a group on the server...
+ let tagName = buddy.tag.name;
+ if (!groups.some(g => g.innerText == tagName)) {
+ // ... otherwise we need to move our account buddy to a new group.
+ tagName = groups[0].innerText;
+ if (tagName) {
+ // Should always be true, but check just in case...
+ let oldTag = buddy.tag;
+ buddy._tag = IMServices.tags.createTag(tagName);
+ IMServices.contacts.accountBuddyMoved(buddy, oldTag, buddy._tag);
+ }
+ }
+ }
+ } else {
+ let tag;
+ for (let group of aItem.getChildren("group")) {
+ let name = group.innerText;
+ if (name) {
+ tag = IMServices.tags.createTag(name);
+ break; // TODO we should create an accountBuddy per group,
+ // but this._buddies would probably not like that...
+ }
+ }
+ buddy = new this._accountBuddyConstructor(
+ this,
+ null,
+ tag || IMServices.tags.defaultTag,
+ jid
+ );
+ }
+
+ // We request the vCard only if we haven't received it yet and are
+ // subscribed to presence for that contact.
+ if (
+ (subscription == "both" || subscription == "to") &&
+ !buddy._vCardReceived
+ ) {
+ this._addVCardRequest(jid);
+ }
+
+ let alias = "name" in aItem.attributes ? aItem.attributes.name : "";
+ if (alias) {
+ if (aNotifyOfUpdates && this._buddies.has(jid)) {
+ buddy.rosterAlias = alias;
+ } else {
+ buddy._rosterAlias = alias;
+ }
+ } else if (buddy._rosterAlias) {
+ buddy.rosterAlias = "";
+ }
+
+ if (subscription) {
+ buddy.subscription = subscription;
+ }
+ if (!this._buddies.has(jid)) {
+ this._buddies.set(jid, buddy);
+ IMServices.contacts.accountBuddyAdded(buddy);
+ } else if (aNotifyOfUpdates) {
+ buddy._notifyObservers("status-detail-changed");
+ }
+
+ // Keep the xml nodes of the item so that we don't have to
+ // recreate them when changing something (eg. the alias) in it.
+ buddy._rosterItem = aItem;
+
+ return jid;
+ },
+ _forgetRosterItem(aJID) {
+ IMServices.contacts.accountBuddyRemoved(this._buddies.get(aJID));
+ this._buddies.delete(aJID);
+ },
+
+ /* When the roster is received */
+ onRoster(aStanza) {
+ // For the first element that is a roster stanza.
+ for (let qe of aStanza.getChildren("query")) {
+ if (qe.uri != Stanza.NS.roster) {
+ continue;
+ }
+
+ // Find all the roster items in the new message.
+ let newRoster = new Set();
+ for (let item of qe.getChildren("item")) {
+ let jid = this._onRosterItem(item);
+ if (jid) {
+ newRoster.add(jid);
+ }
+ }
+ // If an item was in the old roster, but not in the new, forget it.
+ for (let jid of this._buddies.keys()) {
+ if (!newRoster.has(jid)) {
+ this._forgetRosterItem(jid);
+ }
+ }
+ break;
+ }
+
+ this._sendPresence();
+ this._buddies.forEach(b => {
+ if (b.subscription == "both" || b.subscription == "to") {
+ b.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, "");
+ }
+ });
+ this.reportConnected();
+ this._sendVCard();
+ },
+
+ /* Public methods */
+
+ sendStanza(aStanza, aCallback, aThis, aLogString) {
+ return this._connection.sendStanza(aStanza, aCallback, aThis, aLogString);
+ },
+
+ // Variations of the XMPP protocol can change these default constructors:
+ _conversationConstructor: XMPPConversation,
+ _MUCConversationConstructor: XMPPMUCConversation,
+ _accountBuddyConstructor: XMPPAccountBuddy,
+
+ /* Create a new conversation */
+ createConversation(aName) {
+ let convName = this.normalize(aName);
+
+ // Checks if conversation is with a participant of a MUC we are in. We do
+ // not want to strip the resource as it is of the form room@domain/nick.
+ let isMucParticipant = this._mucs.has(convName);
+ if (isMucParticipant) {
+ convName = this.normalizeFullJid(aName);
+ }
+
+ // Checking that the aName can be parsed and is not broken.
+ let jid = this._parseJID(convName);
+ if (
+ !jid ||
+ !jid.domain ||
+ (isMucParticipant && (!jid.node || !jid.resource))
+ ) {
+ this.ERROR("Could not create conversation as jid is broken: " + convName);
+ throw new Error("Invalid JID");
+ }
+
+ if (!this._conv.has(convName)) {
+ this._conv.set(
+ convName,
+ new this._conversationConstructor(this, convName, isMucParticipant)
+ );
+ }
+
+ return this._conv.get(convName);
+ },
+
+ /* Remove an existing conversation */
+ removeConversation(aNormalizedName) {
+ if (this._conv.has(aNormalizedName)) {
+ this._conv.delete(aNormalizedName);
+ } else if (this._mucs.has(aNormalizedName)) {
+ this._mucs.delete(aNormalizedName);
+ }
+ },
+
+ /* Private methods */
+
+ /**
+ * Disconnect from the server
+ *
+ * @param {number} aError - The error reason, passed to reportDisconnecting.
+ * @param {string} aErrorMessage - The error message, passed to reportDisconnecting.
+ * @param {boolean} aQuiet - True to avoid sending status change notifications
+ * during the uninitialization of the account.
+ */
+ _disconnect(
+ aError = Ci.prplIAccount.NO_ERROR,
+ aErrorMessage = "",
+ aQuiet = false
+ ) {
+ if (!this._connection) {
+ return;
+ }
+
+ this.reportDisconnecting(aError, aErrorMessage);
+
+ this._buddies.forEach(b => {
+ if (!aQuiet) {
+ b.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, "");
+ }
+ b.onAccountDisconnected();
+ });
+
+ this._mucs.forEach(muc => {
+ muc.joining = false; // In case we never finished joining.
+ muc.left = true;
+ });
+
+ this._connection.disconnect();
+ delete this._connection;
+
+ // We won't receive "user-icon-changed" notifications while the
+ // account isn't connected, so clear the cache to avoid keeping an
+ // obsolete icon.
+ delete this._cachedUserIcon;
+ // Also clear the cached user vCard, as we will want to redownload it
+ // after reconnecting.
+ delete this._userVCard;
+
+ // Clear vCard requests.
+ this._pendingVCardRequests = [];
+
+ this.reportDisconnected();
+ },
+
+ /* Set the user status on the server */
+ _sendPresence() {
+ delete this._shouldSendPresenceForIdlenessChange;
+
+ if (!this._connection) {
+ return;
+ }
+
+ let si = this.imAccount.statusInfo;
+ let statusType = si.statusType;
+ let show = "";
+ if (statusType == Ci.imIStatusInfo.STATUS_UNAVAILABLE) {
+ show = "dnd";
+ } else if (
+ statusType == Ci.imIStatusInfo.STATUS_AWAY ||
+ statusType == Ci.imIStatusInfo.STATUS_IDLE
+ ) {
+ show = "away";
+ }
+ let children = [];
+ if (show) {
+ children.push(Stanza.node("show", null, null, show));
+ }
+ let statusText = si.statusText;
+ if (statusText) {
+ children.push(Stanza.node("status", null, null, statusText));
+ }
+ if (this._idleSince) {
+ let time = Math.floor(Date.now() / 1000) - this._idleSince;
+ children.push(Stanza.node("query", Stanza.NS.last, { seconds: time }));
+ }
+ if (this.prefs.prefHasUserValue("priority")) {
+ let priority = Math.max(-128, Math.min(127, this.getInt("priority")));
+ if (priority) {
+ children.push(Stanza.node("priority", null, null, priority.toString()));
+ }
+ }
+ this.sendStanza(
+ Stanza.presence({ "xml:lang": "en" }, children),
+ aStanza => {
+ // As we are implicitly subscribed to our own presence (rfc6121#4), we
+ // will receive the presence stanza mirrored back to us. We don't need
+ // to do anything with this response.
+ return true;
+ }
+ );
+ },
+
+ _downloadingUserVCard: false,
+ _downloadUserVCard() {
+ // If a download is already in progress, don't start another one.
+ if (this._downloadingUserVCard) {
+ return;
+ }
+ this._downloadingUserVCard = true;
+ let s = Stanza.iq("get", null, null, Stanza.node("vCard", Stanza.NS.vcard));
+ this.sendStanza(s, this.onUserVCard, this);
+ },
+
+ onUserVCard(aStanza) {
+ delete this._downloadingUserVCard;
+ let userVCard = aStanza.getElement(["vCard"]) || null;
+ if (userVCard) {
+ // Strip any server-specific namespace off the incoming vcard
+ // before storing it.
+ this._userVCard = Stanza.node(
+ "vCard",
+ Stanza.NS.vcard,
+ null,
+ userVCard.children
+ );
+ }
+
+ // If a user icon exists in the vCard we received from the server,
+ // we need to ensure the line breaks in its binval are exactly the
+ // same as those we would include if we sent the icon, and that
+ // there isn't any other whitespace.
+ if (this._userVCard) {
+ let binval = this._userVCard.getElement(["PHOTO", "BINVAL"]);
+ if (binval && binval.children.length) {
+ binval = binval.children[0];
+ binval.text = binval.text
+ .replace(/[^A-Za-z0-9\+\/\=]/g, "")
+ .replace(/.{74}/g, "$&\n");
+ }
+ } else {
+ // Downloading the vCard failed.
+ if (
+ this.handleErrors({
+ itemNotFound: () => false, // OK, no vCard exists yet.
+ default: () => true,
+ })(aStanza)
+ ) {
+ this.WARN(
+ "Unexpected error retrieving the user's vcard, " +
+ "so we won't attempt to set it either."
+ );
+ return;
+ }
+ // Set this so that we don't get into an infinite loop trying to download
+ // the vcard again. The check in sendVCard is for hasOwnProperty.
+ this._userVCard = null;
+ }
+ this._sendVCard();
+ },
+
+ _cachingUserIcon: false,
+ _cacheUserIcon() {
+ if (this._cachingUserIcon) {
+ return;
+ }
+
+ let userIcon = this.imAccount.statusInfo.getUserIcon();
+ if (!userIcon) {
+ this._cachedUserIcon = null;
+ this._sendVCard();
+ return;
+ }
+
+ this._cachingUserIcon = true;
+ let channel = lazy.NetUtil.newChannel({
+ uri: userIcon,
+ loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ securityFlags:
+ Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE,
+ });
+ lazy.NetUtil.asyncFetch(channel, (inputStream, resultCode) => {
+ if (!Components.isSuccessCode(resultCode)) {
+ return;
+ }
+ try {
+ let type = channel.contentType;
+ let buffer = lazy.NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available()
+ );
+ let readImage = lazy.imgTools.decodeImageFromBuffer(
+ buffer,
+ buffer.length,
+ type
+ );
+ let scaledImage;
+ if (readImage.width <= 96 && readImage.height <= 96) {
+ scaledImage = lazy.imgTools.encodeImage(readImage, type);
+ } else {
+ if (type != "image/jpeg") {
+ type = "image/png";
+ }
+ scaledImage = lazy.imgTools.encodeScaledImage(
+ readImage,
+ type,
+ 64,
+ 64
+ );
+ }
+
+ let bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ bstream.setInputStream(scaledImage);
+
+ let data = bstream.readBytes(bstream.available());
+ this._cachedUserIcon = {
+ type,
+ binval: btoa(data).replace(/.{74}/g, "$&\n"),
+ };
+ } catch (e) {
+ console.error(e);
+ this._cachedUserIcon = null;
+ }
+ delete this._cachingUserIcon;
+ this._sendVCard();
+ });
+ },
+ _sendVCard() {
+ if (!this._connection) {
+ return;
+ }
+
+ // We have to download the user's existing vCard before updating it.
+ // This lets us preserve the fields that we don't change or don't know.
+ // Some servers may reject a new vCard if we don't do this first.
+ if (!this.hasOwnProperty("_userVCard")) {
+ // The download of the vCard is asynchronous and will call _sendVCard back
+ // when the user's vCard has been received.
+ this._downloadUserVCard();
+ return;
+ }
+
+ // Read the local user icon asynchronously from the disk.
+ // _cacheUserIcon will call _sendVCard back once the icon is ready.
+ if (!this.hasOwnProperty("_cachedUserIcon")) {
+ this._cacheUserIcon();
+ return;
+ }
+
+ // If the user currently doesn't have any vCard on the server or
+ // the download failed, an empty new one.
+ if (!this._userVCard) {
+ this._userVCard = Stanza.node("vCard", Stanza.NS.vcard);
+ }
+
+ // Keep a serialized copy of the existing user vCard so that we
+ // can avoid resending identical data to the server.
+ let existingVCard = this._userVCard.getXML();
+
+ let fn = this._userVCard.getElement(["FN"]);
+ let displayName = this.imAccount.statusInfo.displayName;
+ if (displayName) {
+ // If a display name is set locally, update or add an FN field to the vCard.
+ if (!fn) {
+ this._userVCard.addChild(
+ Stanza.node("FN", Stanza.NS.vcard, null, displayName)
+ );
+ } else if (fn.children.length) {
+ fn.children[0].text = displayName;
+ } else {
+ fn.addText(displayName);
+ }
+ } else if ("_forceUserDisplayNameUpdate" in this) {
+ // We remove a display name stored on the server without replacing
+ // it with a new value only if this _sendVCard call is the result of
+ // a user action. This is to avoid removing data from the server each
+ // time the user connects from a new profile.
+ this._userVCard.children = this._userVCard.children.filter(
+ n => n.qName != "FN"
+ );
+ }
+ delete this._forceUserDisplayNameUpdate;
+
+ if (this._cachedUserIcon) {
+ // If we have a local user icon, update or add it in the PHOTO field.
+ let photoChildren = [
+ Stanza.node("TYPE", Stanza.NS.vcard, null, this._cachedUserIcon.type),
+ Stanza.node(
+ "BINVAL",
+ Stanza.NS.vcard,
+ null,
+ this._cachedUserIcon.binval
+ ),
+ ];
+ let photo = this._userVCard.getElement(["PHOTO"]);
+ if (photo) {
+ photo.children = photoChildren;
+ } else {
+ this._userVCard.addChild(
+ Stanza.node("PHOTO", Stanza.NS.vcard, null, photoChildren)
+ );
+ }
+ } else if ("_forceUserIconUpdate" in this) {
+ // Like for the display name, we remove a photo without
+ // replacing it only if the call is caused by a user action.
+ this._userVCard.children = this._userVCard.children.filter(
+ n => n.qName != "PHOTO"
+ );
+ }
+ delete this._forceUserIconUpdate;
+
+ // Send the vCard only if it has really changed.
+ // We handle the result response from the server (it does not require
+ // any further action).
+ if (this._userVCard.getXML() != existingVCard) {
+ this.sendStanza(
+ Stanza.iq("set", null, null, this._userVCard),
+ this._handleResult()
+ );
+ } else {
+ this.LOG(
+ "Not sending the vCard because the server stored vCard is identical."
+ );
+ }
+ },
+};