summaryrefslogtreecommitdiffstats
path: root/comm/chat/components/src
diff options
context:
space:
mode:
Diffstat (limited to 'comm/chat/components/src')
-rw-r--r--comm/chat/components/src/components.conf50
-rw-r--r--comm/chat/components/src/imAccounts.sys.mjs1237
-rw-r--r--comm/chat/components/src/imCommands.sys.mjs289
-rw-r--r--comm/chat/components/src/imContacts.sys.mjs1809
-rw-r--r--comm/chat/components/src/imConversations.sys.mjs951
-rw-r--r--comm/chat/components/src/imCore.sys.mjs407
-rw-r--r--comm/chat/components/src/logger.sys.mjs971
-rw-r--r--comm/chat/components/src/moz.build19
-rw-r--r--comm/chat/components/src/test/test_accounts.js48
-rw-r--r--comm/chat/components/src/test/test_commands.js271
-rw-r--r--comm/chat/components/src/test/test_conversations.js239
-rw-r--r--comm/chat/components/src/test/test_init.js28
-rw-r--r--comm/chat/components/src/test/test_logger.js860
-rw-r--r--comm/chat/components/src/test/xpcshell.ini9
14 files changed, 7188 insertions, 0 deletions
diff --git a/comm/chat/components/src/components.conf b/comm/chat/components/src/components.conf
new file mode 100644
index 0000000000..cec63d9801
--- /dev/null
+++ b/comm/chat/components/src/components.conf
@@ -0,0 +1,50 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ "cid": "{a94b5427-cd8d-40cf-b47e-b67671953e70}",
+ "contract_ids": ["@mozilla.org/chat/accounts-service;1"],
+ 'esModule': "resource:///modules/imAccounts.sys.mjs",
+ "constructor": "AccountsService",
+ },
+ {
+ "cid": "{7cb20c68-ccc8-4a79-b6f1-0b4771ed6c23}",
+ "contract_ids": ["@mozilla.org/chat/commands-service;1"],
+ 'esModule': "resource:///modules/imCommands.sys.mjs",
+ "constructor": "CommandsService",
+ },
+ {
+ "cid": "{8c3725dd-ee26-489d-8135-736015af8c7f}",
+ "contract_ids": ["@mozilla.org/chat/contacts-service;1"],
+ 'esModule': "resource:///modules/imContacts.sys.mjs",
+ "constructor": "ContactsService",
+ },
+ {
+ "cid": "{1fa92237-4303-4384-b8ac-4e65b50810a5}",
+ "contract_ids": ["@mozilla.org/chat/tags-service;1"],
+ 'esModule': "resource:///modules/imContacts.sys.mjs",
+ "constructor": "TagsService",
+ },
+ {
+ "cid": "{b2397cd5-c76d-4618-8410-f344c7c6443a}",
+ "contract_ids": ["@mozilla.org/chat/conversations-service;1"],
+ 'esModule': "resource:///modules/imConversations.sys.mjs",
+ "constructor": "ConversationsService",
+ },
+ {
+ "cid": "{073f5953-853c-4a38-bd81-255510c31c2e}",
+ "contract_ids": ["@mozilla.org/chat/core-service;1"],
+ 'esModule': "resource:///modules/imCore.sys.mjs",
+ "constructor": "CoreService",
+ },
+ {
+ "cid": "{fb0dc220-2c7a-4216-9f19-6b8f3480eae9}",
+ "contract_ids": ["@mozilla.org/chat/logger;1"],
+ 'esModule': "resource:///modules/logger.sys.mjs",
+ "constructor": "Logger",
+ },
+]
diff --git a/comm/chat/components/src/imAccounts.sys.mjs b/comm/chat/components/src/imAccounts.sys.mjs
new file mode 100644
index 0000000000..f06b503fa6
--- /dev/null
+++ b/comm/chat/components/src/imAccounts.sys.mjs
@@ -0,0 +1,1237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ ClassInfo,
+ executeSoon,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ GenericAccountPrototype,
+ GenericAccountBuddyPrototype,
+} from "resource:///modules/jsProtoHelper.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/accounts.properties")
+);
+XPCOMUtils.defineLazyGetter(lazy, "_maxDebugMessages", () =>
+ Services.prefs.getIntPref("messenger.accounts.maxDebugMessages")
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "HttpProtocolHandler",
+ "@mozilla.org/network/protocol;1?name=http",
+ "nsIHttpProtocolHandler"
+);
+
+var kPrefAutologinPending = "messenger.accounts.autoLoginPending";
+let kPrefAccountOrder = "mail.accountmanager.accounts";
+var kPrefAccountPrefix = "messenger.account.";
+var kAccountKeyPrefix = "account";
+var kAccountOptionPrefPrefix = "options.";
+var kPrefAccountName = "name";
+var kPrefAccountPrpl = "prpl";
+var kPrefAccountAutoLogin = "autoLogin";
+var kPrefAccountAutoJoin = "autoJoin";
+var kPrefAccountAlias = "alias";
+var kPrefAccountFirstConnectionState = "firstConnectionState";
+
+var gUserCanceledPrimaryPasswordPrompt = false;
+
+var SavePrefTimer = {
+ saveNow() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ Services.prefs.savePrefFile(null);
+ },
+ _timer: null,
+ unInitTimer() {
+ if (this._timer) {
+ this.saveNow();
+ }
+ },
+ initTimer() {
+ if (!this._timer) {
+ this._timer = setTimeout(this.saveNow.bind(this), 5000);
+ }
+ },
+};
+
+var AutoLoginCounter = {
+ _count: 0,
+ startAutoLogin() {
+ ++this._count;
+ if (this._count != 1) {
+ return;
+ }
+ Services.prefs.setIntPref(kPrefAutologinPending, Date.now() / 1000);
+ SavePrefTimer.saveNow();
+ },
+ finishedAutoLogin() {
+ --this._count;
+ if (this._count != 0) {
+ return;
+ }
+ Services.prefs.clearUserPref(kPrefAutologinPending);
+ SavePrefTimer.initTimer();
+ },
+};
+
+function UnknownProtocol(aPrplId) {
+ this.id = aPrplId;
+}
+UnknownProtocol.prototype = {
+ __proto__: ClassInfo("prplIProtocol", "Unknown protocol"),
+ get name() {
+ return "";
+ },
+ get normalizedName() {
+ // Use the ID, but remove the 'prpl-' prefix.
+ return this.id.replace(/^prpl-/, "");
+ },
+ get iconBaseURI() {
+ return "chrome://chat/skin/prpl-unknown/";
+ },
+ getOptions() {
+ return [];
+ },
+ get usernamePrefix() {
+ return "";
+ },
+ getUsernameSplit() {
+ return [];
+ },
+ get usernameEmptyText() {
+ return "";
+ },
+
+ getAccount(aKey, aName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ accountExists() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ // false seems an acceptable default for all options
+ // (they should never be called anyway).
+ get chatHasTopic() {
+ return false;
+ },
+ get noPassword() {
+ return false;
+ },
+ get passwordOptional() {
+ return true;
+ },
+ get slashCommandsNative() {
+ return false;
+ },
+ get canEncrypt() {
+ return false;
+ },
+};
+
+// An unknown prplIAccount.
+function UnknownAccount(aAccount) {
+ this._init(aAccount.protocol, aAccount);
+}
+UnknownAccount.prototype = GenericAccountPrototype;
+
+function UnknownAccountBuddy(aAccount, aBuddy, aTag) {
+ this._init(new UnknownAccount(aAccount), aBuddy, aTag);
+}
+UnknownAccountBuddy.prototype = GenericAccountBuddyPrototype;
+
+/**
+ * @param {string} aKey - Account key for preferences.
+ * @param {string} [aName] - Name of the account if it is new. Will be stored
+ * in account preferences. If not provided, the value from the account
+ * preferences is used instead.
+ * @param {string} [aPrplId] - Protocol ID for this account if it is new. Will
+ * be stored in account preferences. If not provided, the value from the
+ * account preferences is used instead.
+ */
+function imAccount(aKey, aName, aPrplId) {
+ if (!aKey.startsWith(kAccountKeyPrefix)) {
+ throw Components.Exception(`Invalid key: ${aKey}`, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this.id = aKey;
+ this.numericId = parseInt(aKey.substr(kAccountKeyPrefix.length));
+ gAccountsService._keepAccount(this);
+ this.prefBranch = Services.prefs.getBranch(kPrefAccountPrefix + aKey + ".");
+
+ if (aName) {
+ this.name = aName;
+ this.prefBranch.setStringPref(kPrefAccountName, aName);
+
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ } else {
+ this.name = this.prefBranch.getStringPref(kPrefAccountName);
+ }
+
+ let prplId = aPrplId;
+ if (prplId) {
+ this.prefBranch.setCharPref(kPrefAccountPrpl, prplId);
+ } else {
+ prplId = this.prefBranch.getCharPref(kPrefAccountPrpl);
+ }
+
+ // Get the protocol plugin, or fallback to an UnknownProtocol instance.
+ this.protocol = IMServices.core.getProtocolById(prplId);
+ if (!this.protocol) {
+ this.protocol = new UnknownProtocol(prplId);
+ this._connectionErrorReason = Ci.imIAccount.ERROR_UNKNOWN_PRPL;
+ return;
+ }
+
+ // Ensure the account is correctly stored in blist.sqlite.
+ IMServices.contacts.storeAccount(this.numericId, this.name, prplId);
+
+ // Get the prplIAccount from the protocol plugin.
+ this.prplAccount = this.protocol.getAccount(this);
+
+ // Send status change notifications to the account.
+ this.observedStatusInfo = null; // (To execute the setter).
+
+ // If we have never finished the first connection attempt for this account,
+ // mark the account as having caused a crash.
+ if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_CRASHED;
+ }
+
+ Services.logins.initializationPromise.then(() => {
+ // If protocol is falsy remove() was called on this instance while waiting
+ // for the promise to resolve. Since the instance was disposed there is
+ // nothing to do.
+ if (!this.protocol) {
+ return;
+ }
+
+ // Check for errors that should prevent connection attempts.
+ if (this._passwordRequired && !this.password) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD;
+ } else if (
+ this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_CRASHED
+ ) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_CRASHED;
+ }
+ });
+}
+
+imAccount.prototype = {
+ __proto__: ClassInfo(["imIAccount", "prplIAccount"], "im account object"),
+
+ name: "",
+ id: "",
+ numericId: 0,
+ protocol: null,
+ prplAccount: null,
+ connectionState: Ci.imIAccount.STATE_DISCONNECTED,
+ connectionStateMsg: "",
+ connectionErrorMessage: "",
+ _connectionErrorReason: Ci.prplIAccount.NO_ERROR,
+ get connectionErrorReason() {
+ if (
+ this._connectionErrorReason != Ci.prplIAccount.NO_ERROR &&
+ (this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD ||
+ !this._password)
+ ) {
+ return this._connectionErrorReason;
+ }
+ return this.prplAccount.connectionErrorReason;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "account-connect-progress") {
+ this.connectionStateMsg = aData;
+ } else if (aTopic == "account-connecting") {
+ if (this.prplAccount.connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ delete this.connectionErrorMessage;
+ if (this.timeOfNextReconnect - Date.now() > 1000) {
+ // This is a manual reconnection, reset the auto-reconnect stuff
+ this.timeOfLastConnect = 0;
+ this._cancelReconnection();
+ }
+ }
+ if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_PENDING;
+ }
+ this.connectionState = Ci.imIAccount.STATE_CONNECTING;
+ } else if (aTopic == "account-connected") {
+ this.connectionState = Ci.imIAccount.STATE_CONNECTED;
+ this._finishedAutoLogin();
+ this.timeOfLastConnect = Date.now();
+ if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_OK;
+ }
+ delete this.connectionStateMsg;
+
+ if (
+ this.canJoinChat &&
+ this.prefBranch.prefHasUserValue(kPrefAccountAutoJoin)
+ ) {
+ let autojoin = this.prefBranch.getStringPref(kPrefAccountAutoJoin);
+ if (autojoin) {
+ for (let room of autojoin.trim().split(/,\s*/)) {
+ if (room) {
+ this.joinChat(this.getChatRoomDefaultFieldValues(room));
+ }
+ }
+ }
+ }
+ } else if (aTopic == "account-disconnecting") {
+ this.connectionState = Ci.imIAccount.STATE_DISCONNECTING;
+ this.connectionErrorMessage = aData;
+ delete this.connectionStateMsg;
+ this._finishedAutoLogin();
+
+ let firstConnectionState = this.firstConnectionState;
+ if (
+ firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK &&
+ firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_CRASHED
+ ) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ }
+
+ let connectionErrorReason = this.prplAccount.connectionErrorReason;
+ if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ if (
+ connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR
+ ) {
+ this._startReconnectTimer();
+ }
+ this._sendNotification("account-connect-error");
+ }
+ } else if (aTopic == "account-disconnected") {
+ this.connectionState = Ci.imIAccount.STATE_DISCONNECTED;
+ let connectionErrorReason = this.prplAccount.connectionErrorReason;
+ if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) {
+ // If the account was disconnected with an error, save the debug messages.
+ this._omittedDebugMessagesBeforeError += this._omittedDebugMessages;
+ if (this._debugMessagesBeforeError) {
+ this._omittedDebugMessagesBeforeError +=
+ this._debugMessagesBeforeError.length;
+ }
+ this._debugMessagesBeforeError = this._debugMessages;
+ } else {
+ // After a clean disconnection, drop the debug messages that
+ // could have been left by a previous error.
+ delete this._omittedDebugMessagesBeforeError;
+ delete this._debugMessagesBeforeError;
+ }
+ delete this._omittedDebugMessages;
+ delete this._debugMessages;
+ if (
+ this._statusObserver &&
+ connectionErrorReason == Ci.prplIAccount.NO_ERROR &&
+ this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ // If the status changed back to online while an account was still
+ // disconnecting, it was not reconnected automatically at that point,
+ // so we must do it now. (This happens for protocols like IRC where
+ // disconnection is not immediate.)
+ this._sendNotification(aTopic, aData);
+ this.connect();
+ return;
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ this._sendNotification(aTopic, aData);
+ },
+
+ _debugMessages: null,
+ _omittedDebugMessages: 0,
+ _debugMessagesBeforeError: null,
+ _omittedDebugMessagesBeforeError: 0,
+ logDebugMessage(aMessage, aLevel) {
+ if (!this._debugMessages) {
+ this._debugMessages = [];
+ }
+ if (
+ lazy._maxDebugMessages &&
+ this._debugMessages.length >= lazy._maxDebugMessages
+ ) {
+ this._debugMessages.shift();
+ ++this._omittedDebugMessages;
+ }
+ this._debugMessages.push({ logLevel: aLevel, message: aMessage });
+ },
+ _createDebugMessage(aMessage) {
+ let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.init(
+ aMessage,
+ "",
+ "",
+ 0,
+ null,
+ Ci.nsIScriptError.warningFlag,
+ "component javascript"
+ );
+ return { logLevel: 0, message: scriptError };
+ },
+ getDebugMessages() {
+ let messages = [];
+ if (this._omittedDebugMessagesBeforeError) {
+ let text = this._omittedDebugMessagesBeforeError + " messages omitted";
+ messages.push(this._createDebugMessage(text));
+ }
+ if (this._debugMessagesBeforeError) {
+ messages = messages.concat(this._debugMessagesBeforeError);
+ }
+ if (this._omittedDebugMessages) {
+ let text = this._omittedDebugMessages + " messages omitted";
+ messages.push(this._createDebugMessage(text));
+ }
+ if (this._debugMessages) {
+ messages = messages.concat(this._debugMessages);
+ }
+ if (messages.length) {
+ let appInfo = Services.appinfo;
+ let header =
+ `${appInfo.name} ${appInfo.version} (${appInfo.appBuildID}), ` +
+ `Gecko ${appInfo.platformVersion} (${appInfo.platformBuildID}) ` +
+ `on ${lazy.HttpProtocolHandler.oscpu}`;
+ messages.unshift(this._createDebugMessage(header));
+ }
+
+ return messages;
+ },
+
+ _observedStatusInfo: null,
+ get observedStatusInfo() {
+ return this._observedStatusInfo;
+ },
+ _statusObserver: null,
+ set observedStatusInfo(aUserStatusInfo) {
+ if (!this.prplAccount) {
+ return;
+ }
+ if (this._statusObserver) {
+ this.statusInfo.removeObserver(this._statusObserver);
+ }
+ this._observedStatusInfo = aUserStatusInfo;
+ if (this._statusObserver) {
+ this.statusInfo.addObserver(this._statusObserver);
+ }
+ },
+ _removeStatusObserver() {
+ if (this._statusObserver) {
+ this.statusInfo.removeObserver(this._statusObserver);
+ delete this._statusObserver;
+ }
+ },
+ get statusInfo() {
+ return this._observedStatusInfo || IMServices.core.globalUserStatus;
+ },
+
+ reconnectAttempt: 0,
+ timeOfLastConnect: 0,
+ timeOfNextReconnect: 0,
+ _reconnectTimer: null,
+ _startReconnectTimer() {
+ if (Services.io.offline) {
+ console.error("_startReconnectTimer called while offline");
+ return;
+ }
+
+ /* If the last successful connection is older than 10 seconds, reset the
+ number of reconnection attempts. */
+ const kTimeBeforeSuccessfulConnection = 10;
+ if (
+ this.timeOfLastConnect &&
+ this.timeOfLastConnect + kTimeBeforeSuccessfulConnection * 1000 <
+ Date.now()
+ ) {
+ delete this.reconnectAttempt;
+ delete this.timeOfLastConnect;
+ }
+
+ let timers = Services.prefs
+ .getCharPref("messenger.accounts.reconnectTimer")
+ .split(",");
+ let delay = timers[Math.min(this.reconnectAttempt, timers.length - 1)];
+ let msDelay = parseInt(delay) * 1000;
+ ++this.reconnectAttempt;
+ this.timeOfNextReconnect = Date.now() + msDelay;
+ this._reconnectTimer = setTimeout(this.connect.bind(this), msDelay);
+ },
+
+ _sendNotification(aTopic, aData) {
+ Services.obs.notifyObservers(this, aTopic, aData);
+ },
+
+ get firstConnectionState() {
+ try {
+ return this.prefBranch.getIntPref(kPrefAccountFirstConnectionState);
+ } catch (e) {
+ return Ci.imIAccount.FIRST_CONNECTION_OK;
+ }
+ },
+ set firstConnectionState(aState) {
+ if (aState == Ci.imIAccount.FIRST_CONNECTION_OK) {
+ this.prefBranch.clearUserPref(kPrefAccountFirstConnectionState);
+ } else {
+ this.prefBranch.setIntPref(kPrefAccountFirstConnectionState, aState);
+ // We want to save this pref immediately when trying to connect.
+ if (aState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ SavePrefTimer.saveNow();
+ } else {
+ SavePrefTimer.initTimer();
+ }
+ }
+ },
+
+ _pendingReconnectForConnectionInfoChange: false,
+ _connectionInfoChanged() {
+ // The next connection will be the first connection with these parameters.
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+
+ // We want to attempt to reconnect with the new settings only if a
+ // previous attempt failed or a connection attempt is currently
+ // pending (so we can return early if the account is currently
+ // connected or disconnected without error).
+ // The code doing the reconnection attempt is wrapped within an
+ // executeSoon call so that when multiple settings are changed at
+ // once we don't attempt to reconnect until they are all saved.
+ // If a reconnect attempt is already scheduled, we can also return early.
+ if (
+ this._pendingReconnectForConnectionInfoChange ||
+ this.connected ||
+ (this.disconnected &&
+ this.connectionErrorReason == Ci.prplIAccount.NO_ERROR)
+ ) {
+ return;
+ }
+
+ this._pendingReconnectForConnectionInfoChange = true;
+ executeSoon(
+ function () {
+ delete this._pendingReconnectForConnectionInfoChange;
+ // If the connection parameters have changed while we were
+ // trying to connect, cancel the ongoing connection attempt and
+ // try again with the new parameters.
+ if (this.connecting) {
+ this.disconnect();
+ this.connect();
+ return;
+ }
+ // If the account was disconnected because of a non-fatal
+ // connection error, retry now that we have new parameters.
+ let errorReason = this.connectionErrorReason;
+ if (
+ this.disconnected &&
+ errorReason != Ci.prplIAccount.NO_ERROR &&
+ errorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD &&
+ errorReason != Ci.imIAccount.ERROR_CRASHED &&
+ errorReason != Ci.imIAccount.ERROR_UNKNOWN_PRPL
+ ) {
+ this.connect();
+ }
+ }.bind(this)
+ );
+ },
+
+ // If the protocol plugin is missing, we can't access the normalizedName,
+ // but in lots of cases this.name is equivalent.
+ get normalizedName() {
+ return this.prplAccount ? this.prplAccount.normalizedName : this.name;
+ },
+ normalize(aName) {
+ return this.prplAccount ? this.prplAccount.normalize(aName) : aName;
+ },
+
+ _sendUpdateNotification() {
+ this._sendNotification("account-updated");
+ },
+
+ set alias(val) {
+ if (val) {
+ this.prefBranch.setStringPref(kPrefAccountAlias, val);
+ } else {
+ this.prefBranch.clearUserPref(kPrefAccountAlias);
+ }
+ this._sendUpdateNotification();
+ },
+ get alias() {
+ try {
+ return this.prefBranch.getStringPref(kPrefAccountAlias);
+ } catch (e) {
+ return "";
+ }
+ },
+
+ _password: "",
+ get password() {
+ if (this._password) {
+ return this._password;
+ }
+
+ // Avoid prompting the user for the primary password more than once at startup.
+ if (gUserCanceledPrimaryPasswordPrompt) {
+ return "";
+ }
+
+ let passwordURI = "im://" + this.protocol.id;
+ let logins;
+ try {
+ logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ } catch (e) {
+ this._handlePrimaryPasswordException(e);
+ return "";
+ }
+ let normalizedName = this.normalizedName;
+ for (let login of logins) {
+ if (login.username == normalizedName) {
+ this._password = login.password;
+ if (
+ this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD
+ ) {
+ // We have found a password for an account marked as missing password,
+ // re-check all others accounts missing a password. But first,
+ // remove the error on our own account to avoid re-checking it.
+ delete this._connectionErrorReason;
+ gAccountsService._checkIfPasswordStillMissing();
+ }
+ return this._password;
+ }
+ }
+ return "";
+ },
+ _checkIfPasswordStillMissing() {
+ if (
+ this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD ||
+ !this.password
+ ) {
+ return;
+ }
+
+ delete this._connectionErrorReason;
+ this._sendUpdateNotification();
+ },
+ get _passwordRequired() {
+ return !this.protocol.noPassword && !this.protocol.passwordOptional;
+ },
+ set password(aPassword) {
+ this._password = aPassword;
+ if (gUserCanceledPrimaryPasswordPrompt) {
+ return;
+ }
+ let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ let passwordURI = "im://" + this.protocol.id;
+ newLogin.init(
+ passwordURI,
+ null,
+ passwordURI,
+ this.normalizedName,
+ aPassword,
+ "",
+ ""
+ );
+ try {
+ let logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ let saved = false;
+ for (let login of logins) {
+ if (newLogin.matches(login, true)) {
+ if (aPassword) {
+ Services.logins.modifyLogin(login, newLogin);
+ } else {
+ Services.logins.removeLogin(login);
+ }
+ saved = true;
+ break;
+ }
+ }
+ if (!saved && aPassword) {
+ Services.logins.addLogin(newLogin);
+ }
+ } catch (e) {
+ this._handlePrimaryPasswordException(e);
+ }
+
+ this._connectionInfoChanged();
+ if (
+ aPassword &&
+ this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD
+ ) {
+ this._connectionErrorReason = Ci.imIAccount.NO_ERROR;
+ } else if (!aPassword && this._passwordRequired) {
+ this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD;
+ }
+ this._sendUpdateNotification();
+ },
+ _handlePrimaryPasswordException(aException) {
+ if (aException.result != Cr.NS_ERROR_ABORT) {
+ throw aException;
+ }
+
+ gUserCanceledPrimaryPasswordPrompt = true;
+ executeSoon(function () {
+ gUserCanceledPrimaryPasswordPrompt = false;
+ });
+ },
+
+ get autoLogin() {
+ return this.prefBranch.getBoolPref(kPrefAccountAutoLogin, true);
+ },
+ set autoLogin(val) {
+ this.prefBranch.setBoolPref(kPrefAccountAutoLogin, val);
+ SavePrefTimer.initTimer();
+ this._sendUpdateNotification();
+ },
+ _autoLoginPending: false,
+ checkAutoLogin() {
+ // No auto-login if: the account has an error at the imIAccount level
+ // (unknown protocol, missing password, first connection crashed),
+ // the account is already connected or connecting, or autoLogin is off.
+ if (
+ this._connectionErrorReason != Ci.prplIAccount.NO_ERROR ||
+ this.connecting ||
+ this.connected ||
+ !this.autoLogin
+ ) {
+ return;
+ }
+
+ this._autoLoginPending = true;
+ AutoLoginCounter.startAutoLogin();
+ try {
+ this.connect();
+ } catch (e) {
+ console.error(e);
+ this._finishedAutoLogin();
+ }
+ },
+ _finishedAutoLogin() {
+ if (!this.hasOwnProperty("_autoLoginPending")) {
+ return;
+ }
+ delete this._autoLoginPending;
+ AutoLoginCounter.finishedAutoLogin();
+ },
+
+ // Delete the account (from the preferences, mozStorage, and call unInit).
+ remove() {
+ let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ let passwordURI = "im://" + this.protocol.id;
+ // Note: the normalizedName may not be exactly right if the
+ // protocol plugin is missing.
+ login.init(passwordURI, null, passwordURI, this.normalizedName, "", "", "");
+ let logins = Services.logins.findLogins(passwordURI, null, passwordURI);
+ for (let l of logins) {
+ if (login.matches(l, true)) {
+ Services.logins.removeLogin(l);
+ break;
+ }
+ }
+ if (this.connected || this.connecting) {
+ this.disconnect();
+ }
+ if (this.prplAccount) {
+ this.prplAccount.remove();
+ }
+ this.unInit();
+ IMServices.contacts.forgetAccount(this.numericId);
+ for (let prefName of this.prefBranch.getChildList("")) {
+ this.prefBranch.clearUserPref(prefName);
+ }
+ },
+ unInit() {
+ // remove any pending reconnection timer.
+ this._cancelReconnection();
+
+ // Keeping a status observer could cause an immediate reconnection.
+ this._removeStatusObserver();
+
+ // remove any pending autologin preference used for crash detection.
+ this._finishedAutoLogin();
+
+ // If the first connection was pending on quit, we set it back to unknown.
+ if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) {
+ this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN;
+ }
+
+ // and make sure we cleanup the save pref timer.
+ SavePrefTimer.unInitTimer();
+
+ if (this.prplAccount) {
+ this.prplAccount.unInit();
+ }
+
+ delete this.protocol;
+ delete this.prplAccount;
+ },
+
+ get _ensurePrplAccount() {
+ if (this.prplAccount) {
+ return this.prplAccount;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ connect() {
+ if (!this.prplAccount) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ if (this._passwordRequired) {
+ // If the previous connection attempt failed because we have a wrong password,
+ // clear the passwor cache so that if there's no password in the password
+ // manager the user gets prompted again.
+ if (
+ this.connectionErrorReason ==
+ Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED
+ ) {
+ delete this._password;
+ }
+
+ let password = this.password;
+ if (!password) {
+ let prompts = Services.prompt;
+ let shouldSave = { value: false };
+ password = { value: "" };
+ if (
+ !prompts.promptPassword(
+ null,
+ lazy._("passwordPromptTitle", this.name),
+ lazy._("passwordPromptText", this.name),
+ password,
+ lazy._("passwordPromptSaveCheckbox"),
+ shouldSave
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSave.value) {
+ this.password = password.value;
+ } else {
+ this._password = password.value;
+ }
+ }
+ }
+
+ if (!this._statusObserver) {
+ this._statusObserver = {
+ observe: function (aSubject, aTopic, aData) {
+ // Disconnect or reconnect the account automatically, otherwise notify
+ // the prplAccount instance.
+ let statusType = aSubject.statusType;
+ let connectionErrorReason = this.connectionErrorReason;
+ if (statusType == Ci.imIStatusInfo.STATUS_OFFLINE) {
+ if (this.connected || this.connecting) {
+ this.prplAccount.disconnect();
+ }
+ this._cancelReconnection();
+ } else if (
+ statusType > Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.disconnected &&
+ (connectionErrorReason == Ci.prplIAccount.NO_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR ||
+ connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR)
+ ) {
+ this.prplAccount.connect();
+ } else if (this.connected) {
+ this.prplAccount.observe(aSubject, aTopic, aData);
+ }
+ }.bind(this),
+ };
+
+ this.statusInfo.addObserver(this._statusObserver);
+ }
+
+ if (
+ !Services.io.offline &&
+ this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE &&
+ this.disconnected
+ ) {
+ this.prplAccount.connect();
+ }
+ },
+ disconnect() {
+ this._removeStatusObserver();
+ if (!this.disconnected) {
+ this._ensurePrplAccount.disconnect();
+ }
+ },
+
+ get disconnected() {
+ return this.connectionState == Ci.imIAccount.STATE_DISCONNECTED;
+ },
+ get connected() {
+ return this.connectionState == Ci.imIAccount.STATE_CONNECTED;
+ },
+ get connecting() {
+ return this.connectionState == Ci.imIAccount.STATE_CONNECTING;
+ },
+ get disconnecting() {
+ return this.connectionState == Ci.imIAccount.STATE_DISCONNECTING;
+ },
+
+ _cancelReconnection() {
+ if (this._reconnectTimer) {
+ clearTimeout(this._reconnectTimer);
+ delete this._reconnectTimer;
+ }
+ delete this.reconnectAttempt;
+ delete this.timeOfNextReconnect;
+ },
+ cancelReconnection() {
+ if (!this.disconnected) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Ensure we don't keep a status observer that could re-enable the
+ // auto-reconnect timers.
+ this.disconnect();
+
+ this._cancelReconnection();
+ },
+ createConversation(aName) {
+ return this._ensurePrplAccount.createConversation(aName);
+ },
+ addBuddy(aTag, aName) {
+ this._ensurePrplAccount.addBuddy(aTag, aName);
+ },
+ loadBuddy(aBuddy, aTag) {
+ if (this.prplAccount) {
+ return this.prplAccount.loadBuddy(aBuddy, aTag);
+ }
+ // Generate dummy account buddies for unknown protocols.
+ return new UnknownAccountBuddy(this, aBuddy, aTag);
+ },
+ requestBuddyInfo(aBuddyName) {
+ this._ensurePrplAccount.requestBuddyInfo(aBuddyName);
+ },
+ getChatRoomFields() {
+ return this._ensurePrplAccount.getChatRoomFields();
+ },
+ getChatRoomDefaultFieldValues(aDefaultChatName) {
+ return this._ensurePrplAccount.getChatRoomDefaultFieldValues(
+ aDefaultChatName
+ );
+ },
+ get canJoinChat() {
+ return this.prplAccount ? this.prplAccount.canJoinChat : false;
+ },
+ joinChat(aComponents) {
+ this._ensurePrplAccount.joinChat(aComponents);
+ },
+ setBool(aName, aVal) {
+ this.prefBranch.setBoolPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setBool(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ setInt(aName, aVal) {
+ this.prefBranch.setIntPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setInt(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ setString(aName, aVal) {
+ this.prefBranch.setStringPref(kAccountOptionPrefPrefix + aName, aVal);
+ this._connectionInfoChanged();
+ if (this.prplAccount) {
+ this.prplAccount.setString(aName, aVal);
+ }
+ SavePrefTimer.initTimer();
+ },
+ save() {
+ SavePrefTimer.saveNow();
+ },
+
+ getSessions() {
+ return this._ensurePrplAccount.getSessions();
+ },
+ get encryptionStatus() {
+ return this._ensurePrplAccount.encryptionStatus;
+ },
+};
+
+var gAccountsService = null;
+
+export function AccountsService() {}
+AccountsService.prototype = {
+ initAccounts() {
+ this._initAutoLoginStatus();
+ this._accounts = [];
+ this._accountsById = {};
+ gAccountsService = this;
+ let accountIdArray = MailServices.accounts.accounts
+ .map(account => account.incomingServer.getCharValue("imAccount"))
+ .filter(accountKey => accountKey?.startsWith(kAccountKeyPrefix));
+ for (let account of accountIdArray) {
+ new imAccount(account);
+ }
+
+ this._prefObserver = this.observe.bind(this);
+ Services.prefs.addObserver(kPrefAccountOrder, this._prefObserver);
+ },
+
+ _prefObserver: null,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "nsPref:changed" || aData != kPrefAccountOrder) {
+ return;
+ }
+
+ const imAccounts = MailServices.accounts.accounts
+ .map(account => account.incomingServer.getCharValue("imAccount"))
+ .filter(k => k?.startsWith(kAccountKeyPrefix))
+ .map(k =>
+ this.getAccountByNumericId(parseInt(k.substr(kAccountKeyPrefix.length)))
+ )
+ .filter(a => a);
+
+ // Only update _accounts if it's a reorder operation
+ if (imAccounts.length == this._accounts.length) {
+ this._accounts = imAccounts;
+ Services.obs.notifyObservers(this, "account-list-updated");
+ }
+ },
+
+ unInitAccounts() {
+ for (let account of this._accounts) {
+ account.unInit();
+ }
+ gAccountsService = null;
+ delete this._accounts;
+ delete this._accountsById;
+ Services.prefs.removeObserver(kPrefAccountOrder, this._prefObserver);
+ delete this._prefObserver;
+ },
+
+ autoLoginStatus: Ci.imIAccountsService.AUTOLOGIN_ENABLED,
+ _initAutoLoginStatus() {
+ /* If auto-login is already disabled, do nothing */
+ if (this.autoLoginStatus != Ci.imIAccountsService.AUTOLOGIN_ENABLED) {
+ return;
+ }
+
+ let prefs = Services.prefs;
+ if (!prefs.getIntPref("messenger.startup.action")) {
+ // the value 0 means that we start without connecting the accounts
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_USER_DISABLED;
+ return;
+ }
+
+ /* Disable auto-login if we are running in safe mode */
+ if (Services.appinfo.inSafeMode) {
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_SAFE_MODE;
+ return;
+ }
+
+ /* Check if we crashed at the last startup during autologin */
+ let autoLoginPending;
+ if (
+ prefs.getPrefType(kPrefAutologinPending) == prefs.PREF_INVALID ||
+ !(autoLoginPending = prefs.getIntPref(kPrefAutologinPending))
+ ) {
+ // if the pref isn't set, then we haven't crashed: keep autologin enabled
+ return;
+ }
+
+ // Last autologin hasn't finished properly.
+ // For now, assume it's because of a crash.
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_CRASH;
+ prefs.deleteBranch(kPrefAutologinPending);
+
+ // If the crash reporter isn't built, we can't know anything more.
+ if (!("nsICrashReporter" in Ci)) {
+ return;
+ }
+
+ try {
+ // Try to get more info with breakpad
+ let lastCrashTime = 0;
+
+ /* Locate the LastCrash file */
+ let lastCrash = Services.dirsvc.get("UAppData", Ci.nsIFile);
+ lastCrash.append("Crash Reports");
+ lastCrash.append("LastCrash");
+ if (lastCrash.exists()) {
+ /* Ok, the file exists, now let's try to read it */
+ let is = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ is.init(lastCrash, -1, 0, 0);
+ sis.init(sis);
+
+ lastCrashTime = parseInt(sis.read(lastCrash.fileSize));
+
+ sis.close();
+ }
+ // The file not existing is totally acceptable, it just means that
+ // either we never crashed or breakpad is not enabled.
+ // In this case, lastCrashTime will keep its 0 initialization value.
+
+ /* dump("autoLoginPending = " + autoLoginPending +
+ ", lastCrash = " + lastCrashTime +
+ ", difference = " + lastCrashTime - autoLoginPending + "\n");*/
+
+ if (lastCrashTime < autoLoginPending) {
+ // the last crash caught by breakpad is older than our last autologin
+ // attempt.
+ // If breakpad is currently enabled, we can be confident that
+ // autologin was interrupted for an exterior reason
+ // (application killed by the user, power outage, ...)
+ try {
+ Services.appinfo
+ .QueryInterface(Ci.nsICrashReporter)
+ .annotateCrashReport("=", "");
+ } catch (e) {
+ // This should fail with NS_ERROR_INVALID_ARG if breakpad is enabled,
+ // and NS_ERROR_NOT_INITIALIZED if it is not.
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED;
+ }
+ }
+ }
+ } catch (e) {
+ // if we failed to get the last crash time, then keep the
+ // AUTOLOGIN_CRASH value in mAutoLoginStatus and return.
+ }
+ },
+
+ processAutoLogin() {
+ if (!this._accounts) {
+ // if we're already shutting down
+ return;
+ }
+
+ for (let account of this._accounts) {
+ account.checkAutoLogin();
+ }
+
+ // Make sure autologin is now enabled, so that we don't display a
+ // message stating that it is disabled and asking the user if it
+ // should be processed now.
+ this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED;
+
+ // Notify observers so that any message stating that autologin is
+ // disabled can be removed
+ Services.obs.notifyObservers(this, "autologin-processed");
+ },
+
+ _checkingIfPasswordStillMissing: false,
+ _checkIfPasswordStillMissing() {
+ // Avoid recursion.
+ if (this._checkingIfPasswordStillMissing) {
+ return;
+ }
+
+ this._checkingIfPasswordStillMissing = true;
+ for (let account of this._accounts) {
+ account._checkIfPasswordStillMissing();
+ }
+ delete this._checkingIfPasswordStillMissing;
+ },
+
+ getAccountById(aAccountId) {
+ if (!aAccountId.startsWith(kAccountKeyPrefix)) {
+ throw Components.Exception(
+ `Invalid id: ${aAccountId}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let id = parseInt(aAccountId.substr(kAccountKeyPrefix.length));
+ return this.getAccountByNumericId(id);
+ },
+
+ _keepAccount(aAccount) {
+ this._accounts.push(aAccount);
+ this._accountsById[aAccount.numericId] = aAccount;
+ },
+ getAccountByNumericId(aAccountId) {
+ return this._accountsById[aAccountId];
+ },
+ getAccounts() {
+ return this._accounts;
+ },
+
+ createAccount(aName, aPrpl) {
+ // Ensure an account with the same name and protocol doesn't already exist.
+ let prpl = IMServices.core.getProtocolById(aPrpl);
+ if (!prpl) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ if (prpl.accountExists(aName)) {
+ console.error("Attempted to create a duplicate account!");
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+
+ /* First get a unique id for the new account. */
+ let id;
+ for (id = 1; ; ++id) {
+ if (this._accountsById.hasOwnProperty(id)) {
+ continue;
+ }
+
+ /* id isn't used by a known account, double check it isn't
+ already used in the sqlite database. This should never
+ happen, except if we have a corrupted profile. */
+ if (!IMServices.contacts.accountIdExists(id)) {
+ break;
+ }
+ Services.console.logStringMessage(
+ "No account " +
+ id +
+ " but there is some data in the buddy list for an account with this number. Your profile may be corrupted."
+ );
+ }
+
+ /* Actually create the new account. */
+ let key = kAccountKeyPrefix + id;
+ let account = new imAccount(key, aName, aPrpl);
+
+ Services.obs.notifyObservers(account, "account-added");
+ return account;
+ },
+
+ deleteAccount(aAccountId) {
+ let account = this.getAccountById(aAccountId);
+ if (!account) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let index = this._accounts.indexOf(account);
+ if (index == -1) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let id = account.numericId;
+ account.remove();
+ this._accounts.splice(index, 1);
+ delete this._accountsById[id];
+ Services.obs.notifyObservers(account, "account-removed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIAccountsService"]),
+ classDescription: "Accounts",
+};
diff --git a/comm/chat/components/src/imCommands.sys.mjs b/comm/chat/components/src/imCommands.sys.mjs
new file mode 100644
index 0000000000..d28bd9a592
--- /dev/null
+++ b/comm/chat/components/src/imCommands.sys.mjs
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/commands.properties")
+);
+
+export function CommandsService() {}
+CommandsService.prototype = {
+ initCommands() {
+ this._commands = {};
+ // The say command is directly implemented in the UI layer, but has a
+ // dummy command registered here so it shows up as a command (e.g. when
+ // using the /help command).
+ this.registerCommand({
+ name: "say",
+ get helpString() {
+ return lazy._("sayHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_HIGH,
+ run(aMsg, aConv) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ });
+
+ this.registerCommand({
+ name: "raw",
+ get helpString() {
+ return lazy._("rawHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_DEFAULT,
+ run(aMsg, aConv) {
+ let conv = IMServices.conversations.getUIConversation(aConv);
+ if (!conv) {
+ return false;
+ }
+ conv.sendMsg(aMsg);
+ return true;
+ },
+ });
+
+ this.registerCommand({
+ // Reference the command service so we can use the internal properties
+ // directly.
+ cmdSrv: this,
+
+ name: "help",
+ get helpString() {
+ return lazy._("helpHelpString");
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_DEFAULT,
+ run(aMsg, aConv) {
+ aMsg = aMsg.trim();
+ let conv = IMServices.conversations.getUIConversation(aConv);
+ if (!conv) {
+ return false;
+ }
+
+ // Handle when no command is given, list all possible commands that are
+ // available for this conversation (alphabetically).
+ if (!aMsg) {
+ let commands = this.cmdSrv.listCommandsForConversation(aConv);
+ if (!commands.length) {
+ return false;
+ }
+
+ // Concatenate the command names (separated by a comma and space).
+ let cmds = commands
+ .map(aCmd => aCmd.name)
+ .sort()
+ .join(", ");
+ let message = lazy._("commands", cmds);
+
+ // Display the message
+ conv.systemMessage(message);
+ return true;
+ }
+
+ // A command name was given, find the commands that match.
+ let cmdArray = this.cmdSrv._findCommands(aConv, aMsg);
+
+ if (!cmdArray.length) {
+ // No command that matches.
+ let message = lazy._("noCommand", aMsg);
+ conv.systemMessage(message);
+ return true;
+ }
+
+ // Only show the help for the one of the highest priority.
+ let cmd = cmdArray[0];
+
+ let text = cmd.helpString;
+ if (!text) {
+ text = lazy._("noHelp", cmd.name);
+ }
+
+ // Display the message.
+ conv.systemMessage(text);
+ return true;
+ },
+ });
+
+ // Status commands
+ let status = {
+ back: "AVAILABLE",
+ away: "AWAY",
+ busy: "UNAVAILABLE",
+ dnd: "UNAVAILABLE",
+ offline: "OFFLINE",
+ };
+ for (let cmd in status) {
+ let statusValue = Ci.imIStatusInfo["STATUS_" + status[cmd]];
+ this.registerCommand({
+ name: cmd,
+ get helpString() {
+ return lazy._("statusCommand", this.name, lazy._(this.name));
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_HIGH,
+ run(aMsg) {
+ IMServices.core.globalUserStatus.setStatus(statusValue, aMsg);
+ return true;
+ },
+ });
+ }
+ },
+ unInitCommands() {
+ delete this._commands;
+ },
+
+ registerCommand(aCommand, aPrplId) {
+ let name = aCommand.name;
+ if (!name) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (!this._commands.hasOwnProperty(name)) {
+ this._commands[name] = {};
+ }
+ this._commands[name][aPrplId || ""] = aCommand;
+ },
+ unregisterCommand(aCommandName, aPrplId) {
+ if (this._commands.hasOwnProperty(aCommandName)) {
+ let prplId = aPrplId || "";
+ let commands = this._commands[aCommandName];
+ if (commands.hasOwnProperty(prplId)) {
+ delete commands[prplId];
+ }
+ if (!Object.keys(commands).length) {
+ delete this._commands[aCommandName];
+ }
+ }
+ },
+ listCommandsForConversation(aConversation) {
+ let result = [];
+ let prplId = aConversation && aConversation.account.protocol.id;
+ for (let name in this._commands) {
+ let commands = this._commands[name];
+ if (commands.hasOwnProperty("")) {
+ result.push(commands[""]);
+ }
+ if (prplId && commands.hasOwnProperty(prplId)) {
+ result.push(commands[prplId]);
+ }
+ }
+ if (aConversation) {
+ result = result.filter(this._usageContextFilter(aConversation));
+ }
+ return result;
+ },
+ // List only the commands for a protocol (excluding the global commands).
+ listCommandsForProtocol(aPrplId) {
+ if (!aPrplId) {
+ throw new Error("You must provide a prpl ID.");
+ }
+
+ let result = [];
+ for (let name in this._commands) {
+ let commands = this._commands[name];
+ if (commands.hasOwnProperty(aPrplId)) {
+ result.push(commands[aPrplId]);
+ }
+ }
+ return result;
+ },
+ _usageContextFilter(aConversation) {
+ let usageContext =
+ Ci.imICommand["CMD_CONTEXT_" + (aConversation.isChat ? "CHAT" : "IM")];
+ return c => c.usageContext & usageContext;
+ },
+ _findCommands(aConversation, aName) {
+ let prplId = null;
+ if (aConversation) {
+ let account = aConversation.account;
+ if (account.connected) {
+ prplId = account.protocol.id;
+ }
+ }
+
+ let commandNames;
+ // If there is an exact match for the given command name,
+ // don't look at any other commands.
+ if (this._commands.hasOwnProperty(aName)) {
+ commandNames = [aName];
+ } else {
+ // Otherwise, check if there is a partial match.
+ commandNames = Object.keys(this._commands).filter(command =>
+ command.startsWith(aName)
+ );
+ }
+
+ // If a single full command name matches the given (partial)
+ // command name, return the results for that command name. Otherwise,
+ // return an empty array (don't assume a certain command).
+ let cmdArray = [];
+ for (let commandName of commandNames) {
+ let matches = [];
+
+ // Get the 2 possible commands (the global and the proto specific).
+ let commands = this._commands[commandName];
+ if (commands.hasOwnProperty("")) {
+ matches.push(commands[""]);
+ }
+ if (prplId && commands.hasOwnProperty(prplId)) {
+ matches.push(commands[prplId]);
+ }
+
+ // Remove the commands that can't apply in this context.
+ if (aConversation) {
+ matches = matches.filter(this._usageContextFilter(aConversation));
+ }
+
+ if (!matches.length) {
+ continue;
+ }
+
+ // If we have found a second matching command name, return the empty array.
+ if (cmdArray.length) {
+ return [];
+ }
+
+ cmdArray = matches;
+ }
+
+ // Sort the matching commands by priority before returning the array.
+ return cmdArray.sort((a, b) => b.priority - a.priority);
+ },
+ executeCommand(aMessage, aConversation, aReturnedConv) {
+ if (!aMessage) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let matchResult;
+ if (
+ aMessage[0] != "/" ||
+ !(matchResult = /^\/([a-z0-9]+)(?: |$)([\s\S]*)/.exec(aMessage))
+ ) {
+ return false;
+ }
+
+ let [, name, args] = matchResult;
+
+ let cmdArray = this._findCommands(aConversation, name);
+ if (!cmdArray.length) {
+ return false;
+ }
+
+ // cmdArray contains commands sorted by priority, attempt to apply
+ // them in order until one succeeds.
+ if (!cmdArray.some(aCmd => aCmd.run(args, aConversation, aReturnedConv))) {
+ // If they all failed, print help message.
+ this.executeCommand("/help " + name, aConversation);
+ }
+ return true;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imICommandsService"]),
+ classDescription: "Commands",
+};
diff --git a/comm/chat/components/src/imContacts.sys.mjs b/comm/chat/components/src/imContacts.sys.mjs
new file mode 100644
index 0000000000..c902cf4623
--- /dev/null
+++ b/comm/chat/components/src/imContacts.sys.mjs
@@ -0,0 +1,1809 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ executeSoon,
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/contacts.properties")
+);
+
+var gDBConnection = null;
+
+function executeAsyncThenFinalize(statement) {
+ statement.executeAsync();
+ statement.finalize();
+}
+
+function getDBConnection() {
+ const NS_APP_USER_PROFILE_50_DIR = "ProfD";
+ let dbFile = Services.dirsvc.get(NS_APP_USER_PROFILE_50_DIR, Ci.nsIFile);
+ dbFile.append("blist.sqlite");
+
+ let conn = Services.storage.openDatabase(dbFile);
+ if (!conn.connectionReady) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Grow blist db in 512KB increments.
+ try {
+ conn.setGrowthIncrement(512 * 1024, "");
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FILE_TOO_BIG) {
+ Services.console.logStringMessage(
+ "Not setting growth increment on " +
+ "blist.sqlite because the available " +
+ "disk space is limited"
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ // Create tables and indexes.
+ [
+ "CREATE TABLE IF NOT EXISTS accounts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "name VARCHAR, " +
+ "prpl VARCHAR)",
+
+ "CREATE TABLE IF NOT EXISTS contacts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "firstname VARCHAR, " +
+ "lastname VARCHAR, " +
+ "alias VARCHAR)",
+
+ "CREATE TABLE IF NOT EXISTS buddies (" +
+ "id INTEGER PRIMARY KEY, " +
+ "key VARCHAR NOT NULL, " +
+ "name VARCHAR NOT NULL, " +
+ "srv_alias VARCHAR, " +
+ "position INTEGER, " +
+ "icon BLOB, " +
+ "contact_id INTEGER)",
+ "CREATE INDEX IF NOT EXISTS buddies_contactindex " +
+ "ON buddies (contact_id)",
+
+ "CREATE TABLE IF NOT EXISTS tags (" +
+ "id INTEGER PRIMARY KEY, " +
+ "name VARCHAR UNIQUE NOT NULL, " +
+ "position INTEGER)",
+
+ "CREATE TABLE IF NOT EXISTS contact_tag (" +
+ "contact_id INTEGER NOT NULL, " +
+ "tag_id INTEGER NOT NULL)",
+ "CREATE INDEX IF NOT EXISTS contact_tag_contactindex " +
+ "ON contact_tag (contact_id)",
+ "CREATE INDEX IF NOT EXISTS contact_tag_tagindex " +
+ "ON contact_tag (tag_id)",
+
+ "CREATE TABLE IF NOT EXISTS account_buddy (" +
+ "account_id INTEGER NOT NULL, " +
+ "buddy_id INTEGER NOT NULL, " +
+ "status VARCHAR, " +
+ "tag_id INTEGER)",
+ "CREATE INDEX IF NOT EXISTS account_buddy_accountindex " +
+ "ON account_buddy (account_id)",
+ "CREATE INDEX IF NOT EXISTS account_buddy_buddyindex " +
+ "ON account_buddy (buddy_id)",
+ ].forEach(conn.executeSimpleSQL);
+
+ return conn;
+}
+
+// Wrap all the usage of DBConn inside a transaction that will be
+// committed automatically at the end of the event loop spin so that
+// we flush buddy list data to disk only once per event loop spin.
+var gDBConnWithPendingTransaction = null;
+Object.defineProperty(lazy, "DBConn", {
+ configurable: true,
+ enumerable: true,
+
+ get() {
+ if (gDBConnWithPendingTransaction) {
+ return gDBConnWithPendingTransaction;
+ }
+
+ if (!gDBConnection) {
+ gDBConnection = getDBConnection();
+ Services.obs.addObserver(function dbClose(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(dbClose, aTopic);
+ if (gDBConnection) {
+ gDBConnection.asyncClose();
+ gDBConnection = null;
+ }
+ }, "profile-before-change");
+ }
+ gDBConnWithPendingTransaction = gDBConnection;
+ gDBConnection.beginTransaction();
+ executeSoon(function () {
+ gDBConnWithPendingTransaction.commitTransaction();
+ gDBConnWithPendingTransaction = null;
+ });
+ return gDBConnection;
+ },
+});
+
+export function TagsService() {}
+TagsService.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+ get defaultTag() {
+ return this.createTag(lazy._("defaultGroup"));
+ },
+ createTag(aName) {
+ // If the tag already exists, we don't want to create a duplicate.
+ let tag = this.getTagByName(aName);
+ if (tag) {
+ return tag;
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO tags (name, position) VALUES(:name, 0)"
+ );
+ try {
+ statement.params.name = aName;
+ statement.executeStep();
+ } finally {
+ statement.finalize();
+ }
+
+ tag = new Tag(lazy.DBConn.lastInsertRowID, aName);
+ Tags.push(tag);
+ return tag;
+ },
+ // Get an existing tag by (numeric) id. Returns null if not found.
+ getTagById: aId => TagsById[aId],
+ // Get an existing tag by name (will do an SQL query). Returns null
+ // if not found.
+ getTagByName(aName) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT id FROM tags where name = :name"
+ );
+ statement.params.name = aName;
+ try {
+ if (!statement.executeStep()) {
+ return null;
+ }
+ return this.getTagById(statement.row.id);
+ } finally {
+ statement.finalize();
+ }
+ },
+ // Get an array of all existing tags.
+ getTags() {
+ if (Tags.length) {
+ Tags.sort((a, b) =>
+ a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+ );
+ } else {
+ this.defaultTag;
+ }
+
+ return Tags;
+ },
+
+ isTagHidden: aTag => aTag.id in otherContactsTag._hiddenTags,
+ hideTag(aTag) {
+ otherContactsTag.hideTag(aTag);
+ },
+ showTag(aTag) {
+ otherContactsTag.showTag(aTag);
+ },
+ get otherContactsTag() {
+ otherContactsTag._initContacts();
+ return otherContactsTag;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imITagsService"]),
+ classDescription: "Tags",
+};
+
+// TODO move into the tagsService
+var Tags = [];
+var TagsById = {};
+
+function Tag(aId, aName) {
+ this._id = aId;
+ this._name = aName;
+ this._contacts = [];
+ this._observers = [];
+
+ TagsById[this.id] = this;
+}
+Tag.prototype = {
+ __proto__: ClassInfo("imITag", "Tag"),
+ get id() {
+ return this._id;
+ },
+ get name() {
+ return this._name;
+ },
+ set name(aNewName) {
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE tags SET name = :name WHERE id = :id"
+ );
+ try {
+ statement.params.name = aNewName;
+ statement.params.id = this._id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // FIXME move the account buddies if some use this tag as their group
+ },
+ getContacts() {
+ return this._contacts.filter(c => !c._empty);
+ },
+ _addContact(aContact) {
+ this._contacts.push(aContact);
+ },
+ _removeContact(aContact) {
+ let index = this._contacts.indexOf(aContact);
+ if (index != -1) {
+ this._contacts.splice(index, 1);
+ }
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ },
+};
+
+var otherContactsTag = {
+ __proto__: ClassInfo(["nsIObserver", "imITag"], "Other Contacts Tag"),
+ hiddenTagsPref: "messenger.buddies.hiddenTags",
+ _hiddenTags: {},
+ _contactsInitialized: false,
+ _saveHiddenTagsPref() {
+ Services.prefs.setCharPref(
+ this.hiddenTagsPref,
+ Object.keys(this._hiddenTags).join(",")
+ );
+ },
+ showTag(aTag) {
+ let id = aTag.id;
+ delete this._hiddenTags[id];
+ let contacts = Object.keys(this._contacts).map(id => this._contacts[id]);
+ for (let contact of contacts) {
+ if (contact.getTags().some(t => t.id == id)) {
+ this._removeContact(contact);
+ }
+ }
+
+ aTag.notifyObservers(aTag, "tag-shown");
+ Services.obs.notifyObservers(aTag, "tag-shown");
+ this._saveHiddenTagsPref();
+ },
+ hideTag(aTag) {
+ if (aTag.id < 0 || aTag.id in otherContactsTag._hiddenTags) {
+ return;
+ }
+
+ this._hiddenTags[aTag.id] = aTag;
+ if (this._contactsInitialized) {
+ this._hideTag(aTag);
+ }
+
+ aTag.notifyObservers(aTag, "tag-hidden");
+ Services.obs.notifyObservers(aTag, "tag-hidden");
+ this._saveHiddenTagsPref();
+ },
+ _hideTag(aTag) {
+ for (let contact of aTag.getContacts()) {
+ if (
+ !(contact.id in this._contacts) &&
+ contact.getTags().every(t => t.id in this._hiddenTags)
+ ) {
+ this._addContact(contact);
+ }
+ }
+ },
+ observe(aSubject, aTopic, aData) {
+ aSubject.QueryInterface(Ci.imIContact);
+ if (aTopic == "contact-tag-removed" || aTopic == "contact-added") {
+ if (
+ !(aSubject.id in this._contacts) &&
+ !(parseInt(aData) in this._hiddenTags) &&
+ aSubject.getTags().every(t => t.id in this._hiddenTags)
+ ) {
+ this._addContact(aSubject);
+ }
+ } else if (
+ aSubject.id in this._contacts &&
+ (aTopic == "contact-removed" ||
+ (aTopic == "contact-tag-added" &&
+ !(parseInt(aData) in this._hiddenTags)))
+ ) {
+ this._removeContact(aSubject);
+ }
+ },
+
+ _initHiddenTags() {
+ let pref = Services.prefs.getCharPref(this.hiddenTagsPref);
+ if (!pref) {
+ return;
+ }
+ for (let tagId of pref.split(",")) {
+ this._hiddenTags[tagId] = TagsById[tagId];
+ }
+ },
+ _initContacts() {
+ if (this._contactsInitialized) {
+ return;
+ }
+ this._observers = [];
+ this._observer = {
+ self: this,
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "contact-moved-in" && !(aSubject instanceof Contact)) {
+ return;
+ }
+
+ this.self.notifyObservers(aSubject, aTopic, aData);
+ },
+ };
+ this._contacts = {};
+ this._contactsInitialized = true;
+ for (let id in this._hiddenTags) {
+ let tag = this._hiddenTags[id];
+ this._hideTag(tag);
+ }
+ Services.obs.addObserver(this, "contact-tag-added");
+ Services.obs.addObserver(this, "contact-tag-removed");
+ Services.obs.addObserver(this, "contact-added");
+ Services.obs.addObserver(this, "contact-removed");
+ },
+
+ // imITag implementation
+ get id() {
+ return -1;
+ },
+ get name() {
+ return "__others__";
+ },
+ set name(aNewName) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ },
+ getContacts() {
+ return Object.keys(this._contacts).map(id => this._contacts[id]);
+ },
+ _addContact(aContact) {
+ this._contacts[aContact.id] = aContact;
+ this.notifyObservers(aContact, "contact-moved-in");
+ for (let observer of ContactsById[aContact.id]._observers) {
+ observer.observe(this, "contact-moved-in", null);
+ }
+ aContact.addObserver(this._observer);
+ },
+ _removeContact(aContact) {
+ delete this._contacts[aContact.id];
+ aContact.removeObserver(this._observer);
+ this.notifyObservers(aContact, "contact-moved-out");
+ for (let observer of ContactsById[aContact.id]._observers) {
+ observer.observe(this, "contact-moved-out", null);
+ }
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ },
+};
+
+var ContactsById = {};
+var LastDummyContactId = 0;
+function Contact(aId, aAlias) {
+ // Assign a negative id to dummy contacts that have a single buddy
+ this._id = aId || --LastDummyContactId;
+ this._alias = aAlias;
+ this._tags = [];
+ this._buddies = [];
+ this._observers = [];
+
+ ContactsById[this._id] = this;
+}
+Contact.prototype = {
+ __proto__: ClassInfo("imIContact", "Contact"),
+ _id: 0,
+ get id() {
+ return this._id;
+ },
+ get alias() {
+ return this._alias;
+ },
+ set alias(aNewAlias) {
+ this._ensureNotDummy();
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE contacts SET alias = :alias WHERE id = :id"
+ );
+ statement.params.alias = aNewAlias;
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+
+ let oldDisplayName = this.displayName;
+ this._alias = aNewAlias;
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ for (let buddy of this._buddies) {
+ for (let accountBuddy of buddy._accounts) {
+ accountBuddy.serverAlias = aNewAlias;
+ }
+ }
+ },
+ _ensureNotDummy() {
+ if (this._id >= 0) {
+ return;
+ }
+
+ // Create a real contact for this dummy contact
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO contacts DEFAULT VALUES"
+ );
+ try {
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ delete ContactsById[this._id];
+ let oldId = this._id;
+ this._id = lazy.DBConn.lastInsertRowID;
+ ContactsById[this._id] = this;
+ this._notifyObservers("no-longer-dummy", oldId.toString());
+ // Update the contact_id for the single existing buddy of this contact
+ statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET contact_id = :id WHERE id = :buddy_id"
+ );
+ statement.params.id = this._id;
+ statement.params.buddy_id = this._buddies[0].id;
+ executeAsyncThenFinalize(statement);
+ },
+
+ getTags() {
+ return this._tags;
+ },
+ addTag(aTag, aInherited) {
+ if (this.hasTag(aTag)) {
+ return;
+ }
+
+ if (!aInherited) {
+ this._ensureNotDummy();
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO contact_tag (contact_id, tag_id) " +
+ "VALUES(:contactId, :tagId)"
+ );
+ statement.params.contactId = this.id;
+ statement.params.tagId = aTag.id;
+ executeAsyncThenFinalize(statement);
+ }
+
+ aTag = TagsById[aTag.id];
+ this._tags.push(aTag);
+ aTag._addContact(this);
+
+ aTag.notifyObservers(this, "contact-moved-in");
+ for (let observer of this._observers) {
+ observer.observe(aTag, "contact-moved-in", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-added", aTag.id);
+ },
+ /* Remove a tag from the local tags of the contact. */
+ _removeTag(aTag) {
+ if (!this.hasTag(aTag) || this._isTagInherited(aTag)) {
+ return;
+ }
+
+ this._removeContactTagRow(aTag);
+
+ this._tags = this._tags.filter(tag => tag.id != aTag.id);
+ aTag = TagsById[aTag.id];
+ aTag._removeContact(this);
+
+ aTag.notifyObservers(this, "contact-moved-out");
+ for (let observer of this._observers) {
+ observer.observe(aTag, "contact-moved-out", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-removed", aTag.id);
+ },
+ _removeContactTagRow(aTag) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contact_tag " +
+ "WHERE contact_id = :contactId " +
+ "AND tag_id = :tagId"
+ );
+ statement.params.contactId = this.id;
+ statement.params.tagId = aTag.id;
+ executeAsyncThenFinalize(statement);
+ },
+ hasTag(aTag) {
+ return this._tags.some(t => t.id == aTag.id);
+ },
+ _massMove: false,
+ removeTag(aTag) {
+ if (!this.hasTag(aTag)) {
+ throw new Error(
+ "Attempting to remove a tag that the contact doesn't have"
+ );
+ }
+ if (this._tags.length == 1) {
+ throw new Error("Attempting to remove the last tag of a contact");
+ }
+
+ this._massMove = true;
+ let hasTag = this.hasTag.bind(this);
+ let newTag = this._tags[this._tags[0].id != aTag.id ? 0 : 1];
+ let moved = false;
+ this._buddies.forEach(function (aBuddy) {
+ aBuddy._accounts.forEach(function (aAccountBuddy) {
+ if (aAccountBuddy.tag.id == aTag.id) {
+ if (
+ aBuddy._accounts.some(
+ ab =>
+ ab.account.numericId == aAccountBuddy.account.numericId &&
+ ab.tag.id != aTag.id &&
+ hasTag(ab.tag)
+ )
+ ) {
+ // A buddy that already has an accountBuddy of the same
+ // account with another tag of the contact shouldn't be
+ // moved to newTag, just remove the accountBuddy
+ // associated to the tag we are removing.
+ aAccountBuddy.remove();
+ moved = true;
+ } else {
+ try {
+ aAccountBuddy.tag = newTag;
+ moved = true;
+ } catch (e) {
+ // Ignore failures. Some protocol plugins may not implement this.
+ }
+ }
+ }
+ });
+ });
+ this._massMove = false;
+ if (moved) {
+ this._moved(aTag, newTag);
+ } else {
+ // If we are here, the old tag is not inherited from a buddy, so
+ // just remove the local tag.
+ this._removeTag(aTag);
+ }
+ },
+ _isTagInherited(aTag) {
+ for (let buddy of this._buddies) {
+ for (let accountBuddy of buddy._accounts) {
+ if (accountBuddy.tag.id == aTag.id) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ _moved(aOldTag, aNewTag) {
+ if (this._massMove) {
+ return;
+ }
+
+ // Avoid xpconnect wrappers.
+ aNewTag = aNewTag && TagsById[aNewTag.id];
+ aOldTag = aOldTag && TagsById[aOldTag.id];
+
+ // Decide what we need to do. Return early if nothing to do.
+ let shouldRemove =
+ aOldTag && this.hasTag(aOldTag) && !this._isTagInherited(aOldTag);
+ let shouldAdd =
+ aNewTag && !this.hasTag(aNewTag) && this._isTagInherited(aNewTag);
+ if (!shouldRemove && !shouldAdd) {
+ return;
+ }
+
+ // Apply the changes.
+ let tags = this._tags;
+ if (shouldRemove) {
+ tags = tags.filter(aTag => aTag.id != aOldTag.id);
+ aOldTag._removeContact(this);
+ }
+ if (shouldAdd) {
+ tags.push(aNewTag);
+ aNewTag._addContact(this);
+ }
+ this._tags = tags;
+
+ // Finally, notify of the changes.
+ if (shouldRemove) {
+ aOldTag.notifyObservers(this, "contact-moved-out");
+ for (let observer of this._observers) {
+ observer.observe(aOldTag, "contact-moved-out", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-removed", aOldTag.id);
+ }
+ if (shouldAdd) {
+ aNewTag.notifyObservers(this, "contact-moved-in");
+ for (let observer of this._observers) {
+ observer.observe(aNewTag, "contact-moved-in", null);
+ }
+ Services.obs.notifyObservers(this, "contact-tag-added", aNewTag.id);
+ }
+ Services.obs.notifyObservers(this, "contact-moved");
+ },
+
+ getBuddies() {
+ return this._buddies;
+ },
+ get _empty() {
+ return this._buddies.length == 0 || this._buddies.every(b => b._empty);
+ },
+
+ mergeContact(aContact) {
+ // Avoid merging the contact with itself or merging into an
+ // already removed contact.
+ if (aContact.id == this.id || !(this.id in ContactsById)) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this._ensureNotDummy();
+ let contact = ContactsById[aContact.id]; // remove XPConnect wrapper
+
+ // Copy all the contact-only tags first, otherwise they would be lost.
+ for (let tag of contact.getTags()) {
+ if (!contact._isTagInherited(tag)) {
+ this.addTag(tag);
+ }
+ }
+
+ // Adopt each buddy. Removing the last one will delete the contact.
+ for (let buddy of contact.getBuddies()) {
+ buddy.contact = this;
+ }
+ this._updatePreferredBuddy();
+ },
+ moveBuddyBefore(aBuddy, aBeforeBuddy) {
+ let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
+ let oldPosition = this._buddies.indexOf(buddy);
+ if (oldPosition == -1) {
+ throw new Error("aBuddy isn't attached to this contact");
+ }
+
+ let newPosition = -1;
+ if (aBeforeBuddy) {
+ newPosition = this._buddies.indexOf(BuddiesById[aBeforeBuddy.id]);
+ }
+ if (newPosition == -1) {
+ newPosition = this._buddies.length - 1;
+ }
+
+ if (oldPosition == newPosition) {
+ return;
+ }
+
+ this._buddies.splice(oldPosition, 1);
+ this._buddies.splice(newPosition, 0, buddy);
+ this._updatePositions(
+ Math.min(oldPosition, newPosition),
+ Math.max(oldPosition, newPosition)
+ );
+ buddy._notifyObservers("position-changed", String(newPosition));
+ this._updatePreferredBuddy(buddy);
+ },
+ adoptBuddy(aBuddy) {
+ if (aBuddy.contact.id == this.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper
+ buddy.contact = this;
+ this._updatePreferredBuddy(buddy);
+ },
+ _massRemove: false,
+ _removeBuddy(aBuddy) {
+ if (this._buddies.length == 1) {
+ if (this._id > 0) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contacts WHERE id = :id"
+ );
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+ }
+ this._notifyObservers("removed");
+ delete ContactsById[this._id];
+
+ for (let tag of this._tags) {
+ tag._removeContact(this);
+ }
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM contact_tag WHERE contact_id = :id"
+ );
+ statement.params.id = this._id;
+ executeAsyncThenFinalize(statement);
+
+ delete this._tags;
+ delete this._buddies;
+ delete this._observers;
+ } else {
+ let index = this._buddies.indexOf(aBuddy);
+ if (index == -1) {
+ throw new Error("Removing an unknown buddy from contact " + this._id);
+ }
+
+ this._buddies = this._buddies.filter(b => b !== aBuddy);
+
+ // If we are actually removing the whole contact, don't bother updating
+ // the positions or the preferred buddy.
+ if (this._massRemove) {
+ return;
+ }
+
+ // No position to update if the removed buddy is at the last position.
+ if (index < this._buddies.length) {
+ this._updatePositions(index);
+ }
+
+ if (this._preferredBuddy.id == aBuddy.id) {
+ this._updatePreferredBuddy();
+ }
+ }
+ },
+ _updatePositions(aIndexBegin, aIndexEnd) {
+ if (aIndexEnd === undefined) {
+ aIndexEnd = this._buddies.length - 1;
+ }
+ if (aIndexBegin > aIndexEnd) {
+ throw new Error("_updatePositions: Invalid indexes");
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET position = :position WHERE id = :buddyId"
+ );
+ for (let i = aIndexBegin; i <= aIndexEnd; ++i) {
+ statement.params.position = i;
+ statement.params.buddyId = this._buddies[i].id;
+ statement.executeAsync();
+ }
+ statement.finalize();
+ },
+
+ detachBuddy(aBuddy) {
+ // Should return a new contact with the same list of tags.
+ let buddy = BuddiesById[aBuddy.id];
+ if (buddy.contact.id != this.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (buddy.contact._buddies.length == 1) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Save the list of tags, it may be destroyed if the buddy was the last one.
+ let tags = buddy.contact.getTags();
+
+ // Create a new dummy contact and use it for the detached buddy.
+ buddy.contact = new Contact();
+ buddy.contact._notifyObservers("added");
+
+ // The first tag was inherited during the contact setter.
+ // This will copy the remaining tags.
+ for (let tag of tags) {
+ buddy.contact.addTag(tag);
+ }
+
+ return buddy.contact;
+ },
+ remove() {
+ this._massRemove = true;
+ for (let buddy of this._buddies) {
+ buddy.remove();
+ }
+ },
+
+ // imIStatusInfo implementation
+ _preferredBuddy: null,
+ get preferredBuddy() {
+ if (!this._preferredBuddy) {
+ this._updatePreferredBuddy();
+ }
+ return this._preferredBuddy;
+ },
+ set preferredBuddy(aBuddy) {
+ let shouldNotify = this._preferredBuddy != null;
+ let oldDisplayName =
+ this._preferredBuddy && this._preferredBuddy.displayName;
+ this._preferredBuddy = aBuddy;
+ if (shouldNotify) {
+ this._notifyObservers("preferred-buddy-changed");
+ }
+ if (oldDisplayName && this._preferredBuddy.displayName != oldDisplayName) {
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ }
+ this._updateStatus();
+ },
+ // aBuddy indicate which buddy's availability has changed.
+ _updatePreferredBuddy(aBuddy) {
+ if (aBuddy) {
+ aBuddy = BuddiesById[aBuddy.id]; // remove potential XPConnect wrapper
+
+ if (!this._preferredBuddy) {
+ this.preferredBuddy = aBuddy;
+ return;
+ }
+
+ if (aBuddy.id == this._preferredBuddy.id) {
+ // The suggested buddy is already preferred, check if its
+ // availability has changed.
+ if (
+ aBuddy.statusType > this._statusType ||
+ (aBuddy.statusType == this._statusType &&
+ aBuddy.availabilityDetails >= this._availabilityDetails)
+ ) {
+ // keep the currently preferred buddy, only update the status.
+ this._updateStatus();
+ return;
+ }
+ // We aren't sure that the currently preferred buddy should
+ // still be preferred. Let's go through the list!
+ } else {
+ // The suggested buddy is not currently preferred. If it is
+ // more available or at a better position, prefer it!
+ if (
+ aBuddy.statusType > this._statusType ||
+ (aBuddy.statusType == this._statusType &&
+ (aBuddy.availabilityDetails > this._availabilityDetails ||
+ (aBuddy.availabilityDetails == this._availabilityDetails &&
+ this._buddies.indexOf(aBuddy) <
+ this._buddies.indexOf(this.preferredBuddy))))
+ ) {
+ this.preferredBuddy = aBuddy;
+ }
+ return;
+ }
+ }
+
+ let preferred;
+ // |this._buddies| is ordered by user preference, so in case of
+ // equal availability, keep the current value of |preferred|.
+ for (let buddy of this._buddies) {
+ if (
+ !preferred ||
+ preferred.statusType < buddy.statusType ||
+ (preferred.statusType == buddy.statusType &&
+ preferred.availabilityDetails < buddy.availabilityDetails)
+ ) {
+ preferred = buddy;
+ }
+ }
+ if (
+ preferred &&
+ (!this._preferredBuddy || preferred.id != this._preferredBuddy.id)
+ ) {
+ this.preferredBuddy = preferred;
+ }
+ },
+ _updateStatus() {
+ let buddy = this._preferredBuddy; // for convenience
+
+ // Decide which notifications should be fired.
+ let notifications = [];
+ if (
+ this._statusType != buddy.statusType ||
+ this._availabilityDetails != buddy.availabilityDetails
+ ) {
+ notifications.push("availability-changed");
+ }
+ if (
+ this._statusType != buddy.statusType ||
+ this._statusText != buddy.statusText
+ ) {
+ notifications.push("status-changed");
+ if (this.online && buddy.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-off");
+ }
+ if (!this.online && buddy.statusType > Ci.imIStatusInfo.STATUS_OFFLINE) {
+ notifications.push("signed-on");
+ }
+ }
+
+ // Actually change the stored status.
+ [this._statusType, this._statusText, this._availabilityDetails] = [
+ buddy.statusType,
+ buddy.statusText,
+ buddy.availabilityDetails,
+ ];
+
+ // Fire the notifications.
+ notifications.forEach(function (aTopic) {
+ this._notifyObservers(aTopic);
+ }, this);
+ },
+ get displayName() {
+ return this._alias || this.preferredBuddy.displayName;
+ },
+ get buddyIconFilename() {
+ return this.preferredBuddy.buddyIconFilename;
+ },
+ _statusType: 0,
+ get statusType() {
+ return this._statusType;
+ },
+ get online() {
+ return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
+ },
+ get available() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
+ },
+ get idle() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
+ },
+ get mobile() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
+ },
+ _statusText: "",
+ get statusText() {
+ return this._statusText;
+ },
+ _availabilityDetails: 0,
+ get availabilityDetails() {
+ return this._availabilityDetails;
+ },
+ get canSendMessage() {
+ return this.preferredBuddy.canSendMessage;
+ },
+ // XXX should we list the buddies in the tooltip?
+ getTooltipInfo() {
+ return this.preferredBuddy.getTooltipInfo();
+ },
+ createConversation() {
+ let uiConv = IMServices.conversations.getUIConversationByContactId(this.id);
+ if (uiConv) {
+ return uiConv.target;
+ }
+ return this.preferredBuddy.createConversation();
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ if (!this.hasOwnProperty("_observers")) {
+ return;
+ }
+
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ // internal calls + calls from add-ons
+ notifyObservers(aSubject, aTopic, aData) {
+ for (let observer of this._observers) {
+ if ("observe" in observer) {
+ // avoid failing on destructed XBL bindings...
+ observer.observe(aSubject, aTopic, aData);
+ }
+ }
+ for (let tag of this._tags) {
+ tag.notifyObservers(aSubject, aTopic, aData);
+ }
+ Services.obs.notifyObservers(aSubject, aTopic, aData);
+ },
+ _notifyObservers(aTopic, aData) {
+ this.notifyObservers(this, "contact-" + aTopic, aData);
+ },
+
+ // This is called by the imIBuddy implementations.
+ _observe(aSubject, aTopic, aData) {
+ // Forward the notification.
+ this.notifyObservers(aSubject, aTopic, aData);
+
+ let isPreferredBuddy =
+ aSubject instanceof Buddy && aSubject.id == this.preferredBuddy.id;
+ switch (aTopic) {
+ case "buddy-availability-changed":
+ this._updatePreferredBuddy(aSubject);
+ break;
+ case "buddy-status-changed":
+ if (isPreferredBuddy) {
+ this._updateStatus();
+ }
+ break;
+ case "buddy-display-name-changed":
+ if (isPreferredBuddy && !this._alias) {
+ this._notifyObservers("display-name-changed", aData);
+ }
+ break;
+ case "buddy-icon-changed":
+ if (isPreferredBuddy) {
+ this._notifyObservers("icon-changed");
+ }
+ break;
+ case "buddy-added":
+ // Currently buddies are always added in dummy empty contacts,
+ // later we may want to check this._buddies.length == 1.
+ this._notifyObservers("added");
+ break;
+ case "buddy-removed":
+ this._removeBuddy(aSubject);
+ }
+ },
+};
+
+var BuddiesById = {};
+function Buddy(aId, aKey, aName, aSrvAlias, aContactId) {
+ this._id = aId;
+ this._key = aKey;
+ this._name = aName;
+ if (aSrvAlias) {
+ this._srvAlias = aSrvAlias;
+ }
+ this._accounts = [];
+ this._observers = [];
+
+ if (aContactId) {
+ this._contact = ContactsById[aContactId];
+ }
+ // Avoid failure if aContactId was invalid.
+ if (!this._contact) {
+ this._contact = new Contact(null, null);
+ }
+
+ this._contact._buddies.push(this);
+
+ BuddiesById[this._id] = this;
+}
+Buddy.prototype = {
+ __proto__: ClassInfo("imIBuddy", "Buddy"),
+ get id() {
+ return this._id;
+ },
+ destroy() {
+ for (let ab of this._accounts) {
+ ab.unInit();
+ }
+ delete this._accounts;
+ delete this._observers;
+ delete this._preferredAccount;
+ },
+ get protocol() {
+ return this._accounts[0].account.protocol;
+ },
+ get userName() {
+ return this._name;
+ },
+ get normalizedName() {
+ return this._key;
+ },
+ _srvAlias: "",
+ _contact: null,
+ get contact() {
+ return this._contact;
+ },
+ set contact(aContact) /* not in imIBuddy */ {
+ if (aContact.id == this._contact.id) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ this._notifyObservers("moved-out-of-contact");
+ this._contact._removeBuddy(this);
+
+ this._contact = aContact;
+ this._contact._buddies.push(this);
+
+ // Ensure all the inherited tags are in the new contact.
+ for (let accountBuddy of this._accounts) {
+ this._contact.addTag(TagsById[accountBuddy.tag.id], true);
+ }
+
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET contact_id = :contactId, " +
+ "position = :position " +
+ "WHERE id = :buddyId"
+ );
+ statement.params.contactId = aContact.id > 0 ? aContact.id : 0;
+ statement.params.position = aContact._buddies.length - 1;
+ statement.params.buddyId = this.id;
+ executeAsyncThenFinalize(statement);
+
+ this._notifyObservers("moved-into-contact");
+ },
+ _hasAccountBuddy(aAccountId, aTagId) {
+ for (let ab of this._accounts) {
+ if (ab.account.numericId == aAccountId && ab.tag.id == aTagId) {
+ return true;
+ }
+ }
+ return false;
+ },
+ getAccountBuddies() {
+ return this._accounts;
+ },
+
+ _addAccount(aAccountBuddy, aTag) {
+ this._accounts.push(aAccountBuddy);
+ let contact = this._contact;
+ if (!this._contact._tags.includes(aTag)) {
+ this._contact._tags.push(aTag);
+ aTag._addContact(contact);
+ }
+
+ if (!this._preferredAccount) {
+ this._preferredAccount = aAccountBuddy;
+ }
+ },
+ get _empty() {
+ return this._accounts.length == 0;
+ },
+
+ remove() {
+ for (let account of this._accounts) {
+ account.remove();
+ }
+ },
+
+ // imIStatusInfo implementation
+ _preferredAccount: null,
+ get preferredAccountBuddy() {
+ return this._preferredAccount;
+ },
+ _isPreferredAccount(aAccountBuddy) {
+ if (
+ aAccountBuddy.account.numericId !=
+ this._preferredAccount.account.numericId
+ ) {
+ return false;
+ }
+
+ // In case we have more than one accountBuddy for the same buddy
+ // and account (possible if the buddy is in several groups on the
+ // server), the protocol plugin may be broken and not update all
+ // instances, so ensure we handle the notifications on the instance
+ // that is currently being notified of a change:
+ this._preferredAccount = aAccountBuddy;
+
+ return true;
+ },
+ set preferredAccount(aAccount) {
+ let oldDisplayName =
+ this._preferredAccount && this._preferredAccount.displayName;
+ this._preferredAccount = aAccount;
+ this._notifyObservers("preferred-account-changed");
+ if (
+ oldDisplayName &&
+ this._preferredAccount.displayName != oldDisplayName
+ ) {
+ this._notifyObservers("display-name-changed", oldDisplayName);
+ }
+ this._updateStatus();
+ },
+ // aAccount indicate which account's availability has changed.
+ _updatePreferredAccount(aAccount) {
+ if (aAccount) {
+ if (
+ aAccount.account.numericId == this._preferredAccount.account.numericId
+ ) {
+ // The suggested account is already preferred, check if its
+ // availability has changed.
+ if (
+ aAccount.statusType > this._statusType ||
+ (aAccount.statusType == this._statusType &&
+ aAccount.availabilityDetails >= this._availabilityDetails)
+ ) {
+ // keep the currently preferred account, only update the status.
+ this._updateStatus();
+ return;
+ }
+ // We aren't sure that the currently preferred account should
+ // still be preferred. Let's go through the list!
+ } else {
+ // The suggested account is not currently preferred. If it is
+ // more available, prefer it!
+ if (
+ aAccount.statusType > this._statusType ||
+ (aAccount.statusType == this._statusType &&
+ aAccount.availabilityDetails > this._availabilityDetails)
+ ) {
+ this.preferredAccount = aAccount;
+ }
+ return;
+ }
+ }
+
+ let preferred;
+ // TODO take into account the order of the account-manager list.
+ for (let account of this._accounts) {
+ if (
+ !preferred ||
+ preferred.statusType < account.statusType ||
+ (preferred.statusType == account.statusType &&
+ preferred.availabilityDetails < account.availabilityDetails)
+ ) {
+ preferred = account;
+ }
+ }
+ if (!this._preferredAccount) {
+ if (preferred) {
+ this.preferredAccount = preferred;
+ }
+ return;
+ }
+ if (
+ preferred.account.numericId != this._preferredAccount.account.numericId
+ ) {
+ this.preferredAccount = preferred;
+ } else {
+ this._updateStatus();
+ }
+ },
+ _updateStatus() {
+ let account = this._preferredAccount; // for convenience
+
+ // Decide which notifications should be fired.
+ let notifications = [];
+ if (
+ this._statusType != account.statusType ||
+ this._availabilityDetails != account.availabilityDetails
+ ) {
+ notifications.push("availability-changed");
+ }
+ if (
+ this._statusType != account.statusType ||
+ this._statusText != account.statusText
+ ) {
+ notifications.push("status-changed");
+ if (
+ this.online &&
+ account.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ notifications.push("signed-off");
+ }
+ if (
+ !this.online &&
+ account.statusType > Ci.imIStatusInfo.STATUS_OFFLINE
+ ) {
+ notifications.push("signed-on");
+ }
+ }
+
+ // Actually change the stored status.
+ [this._statusType, this._statusText, this._availabilityDetails] = [
+ account.statusType,
+ account.statusText,
+ account.availabilityDetails,
+ ];
+
+ // Fire the notifications.
+ notifications.forEach(function (aTopic) {
+ this._notifyObservers(aTopic);
+ }, this);
+ },
+ get displayName() {
+ return (
+ (this._preferredAccount && this._preferredAccount.displayName) ||
+ this._srvAlias ||
+ this._name
+ );
+ },
+ get buddyIconFilename() {
+ return this._preferredAccount.buddyIconFilename;
+ },
+ _statusType: 0,
+ get statusType() {
+ return this._statusType;
+ },
+ get online() {
+ return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE;
+ },
+ get available() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE;
+ },
+ get idle() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_IDLE;
+ },
+ get mobile() {
+ return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE;
+ },
+ _statusText: "",
+ get statusText() {
+ return this._statusText;
+ },
+ _availabilityDetails: 0,
+ get availabilityDetails() {
+ return this._availabilityDetails;
+ },
+ get canSendMessage() {
+ return this._preferredAccount.canSendMessage;
+ },
+ // XXX should we list the accounts in the tooltip?
+ getTooltipInfo() {
+ return this._preferredAccount.getTooltipInfo();
+ },
+ createConversation() {
+ return this._preferredAccount.createConversation();
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ if (!this._observers) {
+ return;
+ }
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ // internal calls + calls from add-ons
+ notifyObservers(aSubject, aTopic, aData) {
+ try {
+ for (let observer of this._observers) {
+ observer.observe(aSubject, aTopic, aData);
+ }
+ this._contact._observe(aSubject, aTopic, aData);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ _notifyObservers(aTopic, aData) {
+ this.notifyObservers(this, "buddy-" + aTopic, aData);
+ },
+
+ // This is called by the prplIAccountBuddy implementations.
+ observe(aSubject, aTopic, aData) {
+ // Forward the notification.
+ this.notifyObservers(aSubject, aTopic, aData);
+
+ switch (aTopic) {
+ case "account-buddy-availability-changed":
+ this._updatePreferredAccount(aSubject);
+ break;
+ case "account-buddy-status-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._updateStatus();
+ }
+ break;
+ case "account-buddy-display-name-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._srvAlias =
+ this.displayName != this.userName ? this.displayName : "";
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE buddies SET srv_alias = :srvAlias WHERE id = :buddyId"
+ );
+ statement.params.buddyId = this.id;
+ statement.params.srvAlias = this._srvAlias;
+ executeAsyncThenFinalize(statement);
+ this._notifyObservers("display-name-changed", aData);
+ }
+ break;
+ case "account-buddy-icon-changed":
+ if (this._isPreferredAccount(aSubject)) {
+ this._notifyObservers("icon-changed");
+ }
+ break;
+ case "account-buddy-added":
+ if (this._accounts.length == 0) {
+ // Add the new account in the empty buddy instance.
+ // The TagsById hack is to bypass the xpconnect wrapper.
+ this._addAccount(aSubject, TagsById[aSubject.tag.id]);
+ this._updateStatus();
+ this._notifyObservers("added");
+ } else {
+ this._accounts.push(aSubject);
+ this.contact._moved(null, aSubject.tag);
+ this._updatePreferredAccount(aSubject);
+ }
+ break;
+ case "account-buddy-removed":
+ if (this._accounts.length == 1) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM buddies WHERE id = :id"
+ );
+ try {
+ statement.params.id = this.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ this._notifyObservers("removed");
+
+ delete BuddiesById[this._id];
+ this.destroy();
+ } else {
+ this._accounts = this._accounts.filter(function (ab) {
+ return (
+ ab.account.numericId != aSubject.account.numericId ||
+ ab.tag.id != aSubject.tag.id
+ );
+ });
+ if (
+ this._preferredAccount.account.numericId ==
+ aSubject.account.numericId &&
+ this._preferredAccount.tag.id == aSubject.tag.id
+ ) {
+ this._preferredAccount = null;
+ this._updatePreferredAccount();
+ }
+ this.contact._moved(aSubject.tag);
+ }
+ break;
+ }
+ },
+};
+
+export function ContactsService() {}
+ContactsService.prototype = {
+ initContacts() {
+ let statement = lazy.DBConn.createStatement("SELECT id, name FROM tags");
+ try {
+ while (statement.executeStep()) {
+ Tags.push(new Tag(statement.getInt32(0), statement.getUTF8String(1)));
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement("SELECT id, alias FROM contacts");
+ try {
+ while (statement.executeStep()) {
+ new Contact(statement.getInt32(0), statement.getUTF8String(1));
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT contact_id, tag_id FROM contact_tag"
+ );
+ try {
+ while (statement.executeStep()) {
+ let contact = ContactsById[statement.getInt32(0)];
+ let tag = TagsById[statement.getInt32(1)];
+ contact._tags.push(tag);
+ tag._addContact(contact);
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT id, key, name, srv_alias, contact_id FROM buddies ORDER BY position"
+ );
+ try {
+ while (statement.executeStep()) {
+ new Buddy(
+ statement.getInt32(0),
+ statement.getUTF8String(1),
+ statement.getUTF8String(2),
+ statement.getUTF8String(3),
+ statement.getInt32(4)
+ );
+ // FIXME is there a way to enforce that all AccountBuddies of a Buddy have the same protocol?
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ statement = lazy.DBConn.createStatement(
+ "SELECT account_id, buddy_id, tag_id FROM account_buddy"
+ );
+ try {
+ while (statement.executeStep()) {
+ let accountId = statement.getInt32(0);
+ let buddyId = statement.getInt32(1);
+ let tagId = statement.getInt32(2);
+
+ let account = IMServices.accounts.getAccountByNumericId(accountId);
+ // If the account was deleted without properly cleaning up the
+ // account_buddy, skip loading this account buddy.
+ if (!account) {
+ continue;
+ }
+
+ if (!BuddiesById.hasOwnProperty(buddyId)) {
+ console.error(
+ "Corrupted database: account_buddy entry for account " +
+ accountId +
+ " and tag " +
+ tagId +
+ " references unknown buddy with id " +
+ buddyId
+ );
+ continue;
+ }
+
+ let buddy = BuddiesById[buddyId];
+ if (buddy._hasAccountBuddy(accountId, tagId)) {
+ console.error(
+ "Corrupted database: duplicated account_buddy entry: " +
+ "account_id = " +
+ accountId +
+ ", buddy_id = " +
+ buddyId +
+ ", tag_id = " +
+ tagId
+ );
+ continue;
+ }
+
+ let tag = TagsById[tagId];
+ try {
+ buddy._addAccount(account.loadBuddy(buddy, tag), tag);
+ } catch (e) {
+ console.error(e);
+ dump(e + "\n");
+ }
+ }
+ } finally {
+ statement.finalize();
+ }
+ otherContactsTag._initHiddenTags();
+ },
+ unInitContacts() {
+ Tags = [];
+ TagsById = {};
+ // Avoid shutdown leaks caused by references to native components
+ // implementing prplIAccountBuddy.
+ for (let buddyId in BuddiesById) {
+ let buddy = BuddiesById[buddyId];
+ buddy.destroy();
+ }
+ BuddiesById = {};
+ ContactsById = {};
+ },
+
+ getContactById: aId => ContactsById[aId],
+ // Get an array of all existing contacts.
+ getContacts() {
+ return Object.keys(ContactsById)
+ .filter(id => !ContactsById[id]._empty)
+ .map(id => ContactsById[id]);
+ },
+ getBuddyById: aId => BuddiesById[aId],
+ getBuddyByNameAndProtocol(aNormalizedName, aPrpl) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT b.id FROM buddies b " +
+ "JOIN account_buddy ab ON buddy_id = b.id " +
+ "JOIN accounts a ON account_id = a.id " +
+ "WHERE b.key = :buddyName and a.prpl = :prplId"
+ );
+ statement.params.buddyName = aNormalizedName;
+ statement.params.prplId = aPrpl.id;
+ try {
+ if (!statement.executeStep()) {
+ return null;
+ }
+ return BuddiesById[statement.row.id];
+ } finally {
+ statement.finalize();
+ }
+ },
+ getAccountBuddyByNameAndAccount(aNormalizedName, aAccount) {
+ let buddy = this.getBuddyByNameAndProtocol(
+ aNormalizedName,
+ aAccount.protocol
+ );
+ if (buddy) {
+ let id = aAccount.id;
+ for (let accountBuddy of buddy.getAccountBuddies()) {
+ if (accountBuddy.account.id == id) {
+ return accountBuddy;
+ }
+ }
+ }
+ return null;
+ },
+
+ accountBuddyAdded(aAccountBuddy) {
+ let account = aAccountBuddy.account;
+ let normalizedName = aAccountBuddy.normalizedName;
+ let buddy = this.getBuddyByNameAndProtocol(
+ normalizedName,
+ account.protocol
+ );
+ if (!buddy) {
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO buddies " +
+ "(key, name, srv_alias, position) " +
+ "VALUES(:key, :name, :srvAlias, 0)"
+ );
+ try {
+ let name = aAccountBuddy.userName;
+ let srvAlias = aAccountBuddy.serverAlias;
+ statement.params.key = normalizedName;
+ statement.params.name = name;
+ statement.params.srvAlias = srvAlias;
+ statement.execute();
+ buddy = new Buddy(
+ lazy.DBConn.lastInsertRowID,
+ normalizedName,
+ name,
+ srvAlias,
+ 0
+ );
+ } finally {
+ statement.finalize();
+ }
+ }
+
+ // Initialize the 'buddy' field of the prplIAccountBuddy instance.
+ aAccountBuddy.buddy = buddy;
+
+ // Ensure we aren't storing a duplicate entry.
+ let accountId = account.numericId;
+ let tagId = aAccountBuddy.tag.id;
+ if (buddy._hasAccountBuddy(accountId, tagId)) {
+ console.error(
+ "Attempting to store a duplicate account buddy " +
+ normalizedName +
+ ", account id = " +
+ accountId +
+ ", tag id = " +
+ tagId
+ );
+ return;
+ }
+
+ // Store the new account buddy.
+ let statement = lazy.DBConn.createStatement(
+ "INSERT INTO account_buddy " +
+ "(account_id, buddy_id, tag_id) " +
+ "VALUES(:accountId, :buddyId, :tagId)"
+ );
+ try {
+ statement.params.accountId = accountId;
+ statement.params.buddyId = buddy.id;
+ statement.params.tagId = tagId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // Fire the notifications.
+ buddy.observe(aAccountBuddy, "account-buddy-added");
+ },
+ accountBuddyRemoved(aAccountBuddy) {
+ let buddy = aAccountBuddy.buddy;
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM account_buddy " +
+ "WHERE account_id = :accountId AND " +
+ "buddy_id = :buddyId AND " +
+ "tag_id = :tagId"
+ );
+ try {
+ statement.params.accountId = aAccountBuddy.account.numericId;
+ statement.params.buddyId = buddy.id;
+ statement.params.tagId = aAccountBuddy.tag.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ buddy.observe(aAccountBuddy, "account-buddy-removed");
+ },
+
+ accountBuddyMoved(aAccountBuddy, aOldTag, aNewTag) {
+ let buddy = aAccountBuddy.buddy;
+ let statement = lazy.DBConn.createStatement(
+ "UPDATE account_buddy " +
+ "SET tag_id = :newTagId " +
+ "WHERE account_id = :accountId AND " +
+ "buddy_id = :buddyId AND " +
+ "tag_id = :oldTagId"
+ );
+ try {
+ statement.params.accountId = aAccountBuddy.account.numericId;
+ statement.params.buddyId = buddy.id;
+ statement.params.oldTagId = aOldTag.id;
+ statement.params.newTagId = aNewTag.id;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ let contact = ContactsById[buddy.contact.id];
+
+ // aNewTag is now inherited by the contact from an account buddy, so avoid
+ // keeping direct tag <-> contact links in the contact_tag table.
+ contact._removeContactTagRow(aNewTag);
+
+ buddy.observe(aAccountBuddy, "account-buddy-moved");
+ contact._moved(aOldTag, aNewTag);
+ },
+
+ storeAccount(aId, aUserName, aPrplId) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT name, prpl FROM accounts WHERE id = :id"
+ );
+ statement.params.id = aId;
+ try {
+ if (statement.executeStep()) {
+ if (
+ statement.getUTF8String(0) == aUserName &&
+ statement.getUTF8String(1) == aPrplId
+ ) {
+ // The account is already stored correctly.
+ return;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); // Corrupted database?!?
+ }
+ } finally {
+ statement.finalize();
+ }
+
+ // Actually store the account.
+ statement = lazy.DBConn.createStatement(
+ "INSERT INTO accounts (id, name, prpl) " +
+ "VALUES(:id, :userName, :prplId)"
+ );
+ try {
+ statement.params.id = aId;
+ statement.params.userName = aUserName;
+ statement.params.prplId = aPrplId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ },
+ accountIdExists(aId) {
+ let statement = lazy.DBConn.createStatement(
+ "SELECT id FROM accounts WHERE id = :id"
+ );
+ try {
+ statement.params.id = aId;
+ return statement.executeStep();
+ } finally {
+ statement.finalize();
+ }
+ },
+ forgetAccount(aId) {
+ let statement = lazy.DBConn.createStatement(
+ "DELETE FROM accounts WHERE id = :accountId"
+ );
+ try {
+ statement.params.accountId = aId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+
+ // removing the account from the accounts table is not enough,
+ // we need to remove all the associated account_buddy entries too
+ statement = lazy.DBConn.createStatement(
+ "DELETE FROM account_buddy WHERE account_id = :accountId"
+ );
+ try {
+ statement.params.accountId = aId;
+ statement.execute();
+ } finally {
+ statement.finalize();
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIContactsService"]),
+ classDescription: "Contacts",
+};
diff --git a/comm/chat/components/src/imConversations.sys.mjs b/comm/chat/components/src/imConversations.sys.mjs
new file mode 100644
index 0000000000..069ef24fd9
--- /dev/null
+++ b/comm/chat/components/src/imConversations.sys.mjs
@@ -0,0 +1,951 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Status } from "resource:///modules/imStatusUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { ClassInfo } from "resource:///modules/imXPCOMUtils.sys.mjs";
+import { Message } from "resource:///modules/jsProtoHelper.sys.mjs";
+
+var gLastUIConvId = 0;
+var gLastPrplConvId = 0;
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "bundle", () =>
+ Services.strings.createBundle("chrome://chat/locale/conversations.properties")
+);
+
+export function imMessage(aPrplMessage) {
+ this.prplMessage = aPrplMessage;
+}
+
+imMessage.prototype = {
+ __proto__: ClassInfo(["imIMessage", "prplIMessage"], "IM Message"),
+ cancelled: false,
+ color: "",
+ _displayMessage: null,
+ otrEncrypted: false,
+
+ get displayMessage() {
+ // Explicitly test for null so that blank messages don't fall back to
+ // the original. Especially problematic in encryption extensions like OTR.
+ return this._displayMessage !== null
+ ? this._displayMessage
+ : this.prplMessage.originalMessage;
+ },
+ set displayMessage(aMsg) {
+ this._displayMessage = aMsg;
+ },
+
+ get message() {
+ return this.prplMessage.message;
+ },
+ set message(aMsg) {
+ this.prplMessage.message = aMsg;
+ },
+
+ // from prplIMessage
+ get who() {
+ return this.prplMessage.who;
+ },
+ get time() {
+ return this.prplMessage.time;
+ },
+ get id() {
+ return this.prplMessage.id;
+ },
+ get remoteId() {
+ return this.prplMessage.remoteId;
+ },
+ get alias() {
+ return this.prplMessage.alias;
+ },
+ get iconURL() {
+ return this.prplMessage.iconURL;
+ },
+ get conversation() {
+ return this.prplMessage.conversation;
+ },
+ set conversation(aConv) {
+ this.prplMessage.conversation = aConv;
+ },
+ get outgoing() {
+ return this.prplMessage.outgoing;
+ },
+ get incoming() {
+ return this.prplMessage.incoming;
+ },
+ get system() {
+ return this.prplMessage.system;
+ },
+ get autoResponse() {
+ return this.prplMessage.autoResponse;
+ },
+ get containsNick() {
+ return this.prplMessage.containsNick;
+ },
+ get noLog() {
+ return this.prplMessage.noLog;
+ },
+ get error() {
+ return this.prplMessage.error;
+ },
+ get delayed() {
+ return this.prplMessage.delayed;
+ },
+ get noFormat() {
+ return this.prplMessage.noFormat;
+ },
+ get containsImages() {
+ return this.prplMessage.containsImages;
+ },
+ get notification() {
+ return this.prplMessage.notification;
+ },
+ get noLinkification() {
+ return this.prplMessage.noLinkification;
+ },
+ get noCollapse() {
+ return this.prplMessage.noCollapse;
+ },
+ get isEncrypted() {
+ return this.prplMessage.isEncrypted || this.otrEncrypted;
+ },
+ get action() {
+ return this.prplMessage.action;
+ },
+ get deleted() {
+ return this.prplMessage.deleted;
+ },
+ get originalMessage() {
+ return this.prplMessage.originalMessage;
+ },
+ getActions() {
+ return this.prplMessage.getActions();
+ },
+ whenDisplayed() {
+ return this.prplMessage.whenDisplayed();
+ },
+ whenRead() {
+ return this.prplMessage.whenRead();
+ },
+};
+
+/**
+ * @param {prplIConversation} aPrplConversation
+ * @param {number} [idToReuse] - ID to use for this UI conversation if it replaces another UI conversation.
+ */
+export function UIConversation(aPrplConversation, idToReuse) {
+ this._prplConv = {};
+ if (idToReuse) {
+ this.id = idToReuse;
+ } else {
+ this.id = ++gLastUIConvId;
+ }
+ // Observers listening to this instance's notifications.
+ this._observers = [];
+ // Observers this instance has attached to prplIConversations.
+ this._convObservers = new WeakMap();
+ this._messages = [];
+ this.changeTargetTo(aPrplConversation);
+ let iface = Ci["prplIConv" + (aPrplConversation.isChat ? "Chat" : "IM")];
+ this._interfaces = this._interfaces.concat(iface);
+ // XPConnect will create a wrapper around 'this' after here,
+ // so the list of exposed interfaces shouldn't change anymore.
+ this.updateContactObserver();
+ if (!idToReuse) {
+ Services.obs.notifyObservers(this, "new-ui-conversation");
+ }
+}
+
+UIConversation.prototype = {
+ __proto__: ClassInfo(
+ ["imIConversation", "prplIConversation", "nsIObserver"],
+ "UI conversation"
+ ),
+ _observedContact: null,
+ get contact() {
+ let target = this.target;
+ if (!target.isChat && target.buddy) {
+ return target.buddy.buddy.contact;
+ }
+ return null;
+ },
+ updateContactObserver() {
+ let contact = this.contact;
+ if (contact && !this._observedContact) {
+ contact.addObserver(this);
+ this._observedContact = contact;
+ } else if (!contact && this.observedContact) {
+ this._observedContact.removeObserver(this);
+ delete this._observedContact;
+ }
+ },
+ /**
+ * @type {prplIConversation}
+ */
+ get target() {
+ return this._prplConv[this._currentTargetId];
+ },
+ set target(aPrplConversation) {
+ this.changeTargetTo(aPrplConversation);
+ },
+ get hasMultipleTargets() {
+ return Object.keys(this._prplConv).length > 1;
+ },
+ getTargetByAccount(aAccount) {
+ let accountId = aAccount.id;
+ for (let id in this._prplConv) {
+ let prplConv = this._prplConv[id];
+ if (prplConv.account.id == accountId) {
+ return prplConv;
+ }
+ }
+ return null;
+ },
+ _currentTargetId: 0,
+ changeTargetTo(aPrplConversation) {
+ let id = aPrplConversation.id;
+ if (this._currentTargetId == id) {
+ return;
+ }
+
+ if (!(id in this._prplConv)) {
+ this._prplConv[id] = aPrplConversation;
+ let observeConv = this.observeConv.bind(this, id);
+ this._convObservers.set(aPrplConversation, observeConv);
+ aPrplConversation.addObserver(observeConv);
+ }
+
+ let shouldNotify = this._currentTargetId;
+ this._currentTargetId = id;
+ if (!this.isChat) {
+ let buddy = this.buddy;
+ if (buddy) {
+ ({ statusType: this.statusType, statusText: this.statusText } = buddy);
+ }
+ }
+ if (shouldNotify) {
+ this.notifyObservers(this, "target-prpl-conversation-changed");
+ let target = this.target;
+ let params = [target.title, target.account.protocol.name];
+ this.systemMessage(
+ lazy.bundle.formatStringFromName("targetChanged", params)
+ );
+ }
+ },
+ // Returns a boolean indicating if the ui-conversation was closed.
+ // If the conversation was closed, aContactId.value is set to the contact id
+ // or 0 if no contact was associated with the conversation.
+ removeTarget(aPrplConversation, aContactId) {
+ let id = aPrplConversation.id;
+ if (!(id in this._prplConv)) {
+ throw new Error("unknown prpl conversation");
+ }
+
+ delete this._prplConv[id];
+ if (this._currentTargetId != id) {
+ return false;
+ }
+
+ for (let newId in this._prplConv) {
+ this.changeTargetTo(this._prplConv[newId]);
+ return false;
+ }
+
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ aContactId.value = this._observedContact.id;
+ delete this._observedContact;
+ } else {
+ aContactId.value = 0;
+ }
+
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-closed");
+ return true;
+ },
+
+ _unreadMessageCount: 0,
+ get unreadMessageCount() {
+ return this._unreadMessageCount;
+ },
+ _unreadTargetedMessageCount: 0,
+ get unreadTargetedMessageCount() {
+ return this._unreadTargetedMessageCount;
+ },
+ _unreadIncomingMessageCount: 0,
+ get unreadIncomingMessageCount() {
+ return this._unreadIncomingMessageCount;
+ },
+ _unreadOTRNotificationCount: 0,
+ get unreadOTRNotificationCount() {
+ return this._unreadOTRNotificationCount;
+ },
+ markAsRead() {
+ delete this._unreadMessageCount;
+ delete this._unreadTargetedMessageCount;
+ delete this._unreadIncomingMessageCount;
+ delete this._unreadOTRNotificationCount;
+ if (this._messages.length) {
+ this._messages[this._messages.length - 1].whenDisplayed();
+ }
+ this._notifyUnreadCountChanged();
+ },
+ _lastNotifiedUnreadCount: 0,
+ _notifyUnreadCountChanged() {
+ if (this._unreadIncomingMessageCount == this._lastNotifiedUnreadCount) {
+ return;
+ }
+
+ this._lastNotifiedUnreadCount = this._unreadIncomingMessageCount;
+ for (let observer of this._observers) {
+ observer.observe(
+ this,
+ "unread-message-count-changed",
+ this._unreadIncomingMessageCount.toString()
+ );
+ }
+ },
+ getMessages() {
+ return this._messages;
+ },
+ checkClose() {
+ if (!this._currentTargetId) {
+ // Already closed.
+ return true;
+ }
+
+ if (
+ !Services.prefs.getBoolPref("messenger.conversations.alwaysClose") &&
+ ((this.isChat && !this.left) ||
+ (!this.isChat &&
+ (this.unreadIncomingMessageCount != 0 ||
+ Services.prefs.getBoolPref(
+ "messenger.conversations.holdByDefault"
+ ))))
+ ) {
+ return false;
+ }
+
+ this.close();
+ return true;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "contact-no-longer-dummy") {
+ let oldId = parseInt(aData);
+ // gConversationsService is ugly... :(
+ delete gConversationsService._uiConvByContactId[oldId];
+ gConversationsService._uiConvByContactId[aSubject.id] = this;
+ } else if (aTopic == "account-buddy-status-changed") {
+ if (
+ !this._statusUpdatePending &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this._statusUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this.updateBuddyStatus.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+ } else if (aTopic == "account-buddy-icon-changed") {
+ if (
+ !this._statusUpdatePending &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this._iconUpdatePending = true;
+ Services.tm.mainThread.dispatch(
+ this.updateIcon.bind(this),
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+ }
+ } else if (
+ aTopic == "account-buddy-display-name-changed" &&
+ aSubject.account.id == this.account.id &&
+ aSubject.buddy.id == this.buddy.buddy.id
+ ) {
+ this.notifyObservers(this, "update-buddy-display-name");
+ }
+ },
+
+ _iconUpdatePending: false,
+ updateIcon() {
+ delete this._iconUpdatePending;
+ this.notifyObservers(this, "update-buddy-icon");
+ },
+
+ _statusUpdatePending: false,
+ updateBuddyStatus() {
+ delete this._statusUpdatePending;
+ let { statusType: statusType, statusText: statusText } = this.buddy;
+
+ if (
+ "statusType" in this &&
+ this.statusType == statusType &&
+ this.statusText == statusText
+ ) {
+ return;
+ }
+
+ let wasUnknown = this.statusType == Ci.imIStatusInfo.STATUS_UNKNOWN;
+ this.statusType = statusType;
+ this.statusText = statusText;
+
+ this.notifyObservers(this, "update-buddy-status");
+
+ let msg;
+ if (statusType == Ci.imIStatusInfo.STATUS_UNKNOWN) {
+ msg = lazy.bundle.formatStringFromName("statusUnknown", [this.title]);
+ } else {
+ let status = Status.toLabel(statusType);
+ let stringId = wasUnknown ? "statusChangedFromUnknown" : "statusChanged";
+ if (this._justReconnected) {
+ stringId = "statusKnown";
+ delete this._justReconnected;
+ }
+ if (statusText) {
+ msg = lazy.bundle.formatStringFromName(stringId + "WithStatusText", [
+ this.title,
+ status,
+ statusText,
+ ]);
+ } else {
+ msg = lazy.bundle.formatStringFromName(stringId, [this.title, status]);
+ }
+ }
+ this.systemMessage(msg);
+ },
+
+ _disconnected: false,
+ disconnecting() {
+ if (this._disconnected) {
+ return;
+ }
+
+ this._disconnected = true;
+ if (this.contact) {
+ // Handled by the contact observer.
+ return;
+ }
+
+ if (this.isChat && this.left) {
+ this._wasLeft = true;
+ } else {
+ this.systemMessage(lazy.bundle.GetStringFromName("accountDisconnected"));
+ }
+ this.notifyObservers(this, "update-buddy-status");
+ },
+ connected() {
+ if (this._disconnected) {
+ delete this._disconnected;
+ let msg = lazy.bundle.GetStringFromName("accountReconnected");
+ if (this.isChat) {
+ if (!this._wasLeft) {
+ this.systemMessage(msg);
+ // Reconnect chat if possible.
+ let chatRoomFields = this.target.chatRoomFields;
+ if (chatRoomFields) {
+ this.account.joinChat(chatRoomFields);
+ }
+ }
+ delete this._wasLeft;
+ } else {
+ this._justReconnected = true;
+ // Exclude convs with contacts, these receive presence info updates
+ // (and therefore a reconnected message).
+ if (!this.contact) {
+ this.systemMessage(msg);
+ }
+ }
+ }
+ this.notifyObservers(this, "update-buddy-status");
+ },
+
+ observeConv(aTargetId, aSubject, aTopic, aData) {
+ if (
+ aTargetId != this._currentTargetId &&
+ (aTopic == "new-text" ||
+ aTopic == "update-text" ||
+ aTopic == "remove-text" ||
+ (aTopic == "update-typing" &&
+ this._prplConv[aTargetId].typingState == Ci.prplIConvIM.TYPING))
+ ) {
+ this.target = this._prplConv[aTargetId];
+ }
+
+ this.notifyObservers(aSubject, aTopic, aData);
+ },
+
+ systemMessage(aText, aIsError, aNoCollapse) {
+ let flags = {
+ system: true,
+ noLog: true,
+ error: !!aIsError,
+ noCollapse: !!aNoCollapse,
+ };
+ const message = new Message("system", aText, flags, this);
+ this.notifyObservers(message, "new-text");
+ },
+
+ /**
+ * Emit a notification sound for a new chat message and trigger the
+ * global notificationbox to prompt the user with the verifiation request.
+ *
+ * @param String aText - The system message.
+ */
+ notifyVerifyOTR(aText) {
+ this._unreadOTRNotificationCount++;
+ this.systemMessage(aText, false, true);
+ for (let observer of this._observers) {
+ observer.observe(
+ this,
+ "unread-message-count-changed",
+ this._unreadOTRNotificationCount.toString()
+ );
+ }
+ },
+
+ // prplIConversation
+ get isChat() {
+ return this.target.isChat;
+ },
+ get account() {
+ return this.target.account;
+ },
+ get name() {
+ return this.target.name;
+ },
+ get normalizedName() {
+ return this.target.normalizedName;
+ },
+ get title() {
+ return this.target.title;
+ },
+ get startDate() {
+ return this.target.startDate;
+ },
+ get convIconFilename() {
+ return this.target.convIconFilename;
+ },
+ get encryptionState() {
+ return this.target.encryptionState;
+ },
+ initializeEncryption() {
+ this.target.initializeEncryption();
+ },
+ sendMsg(aMsg, aAction = false, aNotice = false) {
+ this.target.sendMsg(aMsg, aAction, aNotice);
+ },
+ unInit() {
+ for (let id in this._prplConv) {
+ let conv = this._prplConv[id];
+ gConversationsService.forgetConversation(conv);
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ delete this._observedContact;
+ }
+ this._prplConv = {}; // Prevent .close from failing.
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-destroyed");
+ },
+ close() {
+ for (let id in this._prplConv) {
+ let conv = this._prplConv[id];
+ conv.close();
+ }
+ if (!this.hasOwnProperty("_currentTargetId")) {
+ return;
+ }
+ delete this._currentTargetId;
+ this.notifyObservers(this, "ui-conversation-closed");
+ Services.obs.notifyObservers(this, "ui-conversation-closed");
+ },
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ notifyObservers(aSubject, aTopic, aData) {
+ if (aTopic == "new-text" || aTopic == "update-text") {
+ aSubject = new imMessage(aSubject);
+ this.notifyObservers(aSubject, "received-message");
+ if (aSubject.cancelled) {
+ return;
+ }
+ if (!aSubject.system) {
+ aSubject.conversation.prepareForDisplaying(aSubject);
+ }
+ }
+ if (aTopic == "new-text") {
+ this._messages.push(aSubject);
+ ++this._unreadMessageCount;
+ if (aSubject.incoming && !aSubject.system) {
+ ++this._unreadIncomingMessageCount;
+ if (!this.isChat || aSubject.containsNick) {
+ ++this._unreadTargetedMessageCount;
+ }
+ }
+ } else if (aTopic == "update-text") {
+ const index = this._messages.findIndex(
+ msg => msg.remoteId == aSubject.remoteId
+ );
+ if (index != -1) {
+ this._messages.splice(index, 1, aSubject);
+ }
+ } else if (aTopic == "remove-text") {
+ const index = this._messages.findIndex(msg => msg.remoteId == aData);
+ if (index != -1) {
+ this._messages.splice(index, 1);
+ }
+ }
+
+ if (aTopic == "chat-update-type") {
+ // bail if there is no change of the conversation type
+ if (
+ (this.target.isChat && this._interfaces.includes(Ci.prplIConvChat)) ||
+ (!this.target.isChat && this._interfaces.includes(Ci.prplIConvIM))
+ ) {
+ return;
+ }
+ if (this._observedContact) {
+ this._observedContact.removeObserver(this);
+ }
+ this.target.removeObserver(this._convObservers.get(this.target));
+ gConversationsService.updateConversation(this.target);
+ return;
+ }
+
+ for (let observer of this._observers) {
+ if (!observer.observe && !this._observers.includes(observer)) {
+ // Observer removed by a previous call to another observer.
+ continue;
+ }
+ observer.observe(aSubject, aTopic, aData);
+ }
+ this._notifyUnreadCountChanged();
+
+ if (aTopic == "new-text" || aTopic == "update-text") {
+ // Even updated messages should be treated as new message for logs.
+ // TODO proper handling in logs is bug 1735353
+ Services.obs.notifyObservers(aSubject, "new-text", aData);
+ if (
+ aTopic == "new-text" &&
+ aSubject.incoming &&
+ !aSubject.system &&
+ (!this.isChat || aSubject.containsNick)
+ ) {
+ this.notifyObservers(aSubject, "new-directed-incoming-message", aData);
+ Services.obs.notifyObservers(
+ aSubject,
+ "new-directed-incoming-message",
+ aData
+ );
+ }
+ }
+ },
+
+ // Used above when notifying of new-texts originating in the
+ // UIConversation. This happens when this.systemMessage() is called. The
+ // conversation for the message is set as the UIConversation.
+ prepareForDisplaying(aMsg) {},
+
+ // prplIConvIM
+ get buddy() {
+ return this.target.buddy;
+ },
+ get typingState() {
+ return this.target.typingState;
+ },
+ sendTyping(aString) {
+ return this.target.sendTyping(aString);
+ },
+
+ // Chat only
+ getParticipants() {
+ return this.target.getParticipants();
+ },
+ get topic() {
+ return this.target.topic;
+ },
+ set topic(aTopic) {
+ this.target.topic = aTopic;
+ },
+ get topicSetter() {
+ return this.target.topicSetter;
+ },
+ get topicSettable() {
+ return this.target.topicSettable;
+ },
+ get noTopicString() {
+ return lazy.bundle.GetStringFromName("noTopic");
+ },
+ get nick() {
+ return this.target.nick;
+ },
+ get left() {
+ return this.target.left;
+ },
+ get joining() {
+ return this.target.joining;
+ },
+};
+
+var gConversationsService;
+
+export function ConversationsService() {
+ gConversationsService = this;
+}
+
+ConversationsService.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+
+ initConversations() {
+ this._uiConv = {};
+ this._uiConvByContactId = {};
+ this._prplConversations = [];
+ Services.obs.addObserver(this, "account-disconnecting");
+ Services.obs.addObserver(this, "account-connected");
+ Services.obs.addObserver(this, "account-buddy-added");
+ Services.obs.addObserver(this, "account-buddy-removed");
+ },
+
+ unInitConversations() {
+ let UIConvs = this.getUIConversations();
+ for (let UIConv of UIConvs) {
+ UIConv.unInit();
+ }
+ delete this._uiConv;
+ delete this._uiConvByContactId;
+ // This should already be empty, but just to be sure...
+ for (let prplConv of this._prplConversations) {
+ prplConv.unInit();
+ }
+ delete this._prplConversations;
+ Services.obs.removeObserver(this, "account-disconnecting");
+ Services.obs.removeObserver(this, "account-connected");
+ Services.obs.removeObserver(this, "account-buddy-added");
+ Services.obs.removeObserver(this, "account-buddy-removed");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "account-connected") {
+ for (let id in this._uiConv) {
+ let conv = this._uiConv[id];
+ if (conv.account.id == aSubject.id) {
+ conv.connected();
+ }
+ }
+ } else if (aTopic == "account-disconnecting") {
+ for (let id in this._uiConv) {
+ let conv = this._uiConv[id];
+ if (conv.account.id == aSubject.id) {
+ conv.disconnecting();
+ }
+ }
+ } else if (aTopic == "account-buddy-added") {
+ let accountBuddy = aSubject;
+ let prplConversation = this.getConversationByNameAndAccount(
+ accountBuddy.normalizedName,
+ accountBuddy.account,
+ false
+ );
+ if (!prplConversation) {
+ return;
+ }
+
+ let uiConv = this.getUIConversation(prplConversation);
+ let contactId = accountBuddy.buddy.contact.id;
+ if (contactId in this._uiConvByContactId) {
+ // Trouble! There is an existing uiConv for this contact.
+ // We should avoid having two uiConvs with the same contact.
+ // This is ugly UX, but at least can only happen if there is
+ // already an accountBuddy with the same name for the same
+ // protocol on a different account, which should be rare.
+ this.removeConversation(prplConversation);
+ return;
+ }
+ // Link the existing uiConv to the contact.
+ this._uiConvByContactId[contactId] = uiConv;
+ uiConv.updateContactObserver();
+ uiConv.notifyObservers(uiConv, "update-conv-buddy");
+ } else if (aTopic == "account-buddy-removed") {
+ let accountBuddy = aSubject;
+ let contactId = accountBuddy.buddy.contact.id;
+ if (!(contactId in this._uiConvByContactId)) {
+ return;
+ }
+ let uiConv = this._uiConvByContactId[contactId];
+
+ // If there is more than one target on the uiConv, close the
+ // prplConv as we can't dissociate the uiConv from the contact.
+ // The conversation with the contact will continue with a different
+ // target.
+ if (uiConv.hasMultipleTargets) {
+ let prplConversation = uiConv.getTargetByAccount(accountBuddy.account);
+ if (prplConversation) {
+ this.removeConversation(prplConversation);
+ }
+ return;
+ }
+
+ delete this._uiConvByContactId[contactId];
+ uiConv.updateContactObserver();
+ uiConv.notifyObservers(uiConv, "update-conv-buddy");
+ }
+ },
+
+ addConversation(aPrplConversation) {
+ // Give an id to the new conversation.
+ aPrplConversation.id = ++gLastPrplConvId;
+ this._prplConversations.push(aPrplConversation);
+
+ // Notify observers.
+ Services.obs.notifyObservers(aPrplConversation, "new-conversation");
+
+ // Update or create the corresponding UI conversation.
+ let contactId;
+ if (!aPrplConversation.isChat) {
+ let accountBuddy = aPrplConversation.buddy;
+ if (accountBuddy) {
+ contactId = accountBuddy.buddy.contact.id;
+ }
+ }
+
+ if (contactId) {
+ if (contactId in this._uiConvByContactId) {
+ let uiConv = this._uiConvByContactId[contactId];
+ uiConv.target = aPrplConversation;
+ this._uiConv[aPrplConversation.id] = uiConv;
+ return;
+ }
+ }
+
+ let newUIConv = new UIConversation(aPrplConversation);
+ this._uiConv[aPrplConversation.id] = newUIConv;
+ if (contactId) {
+ this._uiConvByContactId[contactId] = newUIConv;
+ }
+ },
+ /**
+ * Informs the conversation service that the type of the conversation changed, which then lets the
+ * UI components know to use a new UI conversation instance.
+ *
+ * @param {prplIConversation} aPrplConversation - The prpl conversation to update the UI conv for.
+ */
+ updateConversation(aPrplConversation) {
+ let contactId;
+ let uiConv = this.getUIConversation(aPrplConversation);
+
+ if (!aPrplConversation.isChat) {
+ let accountBuddy = aPrplConversation.buddy;
+ if (accountBuddy) {
+ contactId = accountBuddy.buddy.contact.id;
+ }
+ }
+ // Ensure conv is not in the by contact ID map
+ for (const [contactId, uiConversation] of Object.entries(
+ this._uiConvByContactId
+ )) {
+ if (uiConversation === uiConv) {
+ delete this._uiConvByContactId[contactId];
+ break;
+ }
+ }
+ Services.obs.notifyObservers(uiConv, "ui-conversation-replaced");
+ let uiConvId = uiConv.id;
+ // create new UI conv with correct interfaces.
+ uiConv = new UIConversation(aPrplConversation, uiConvId);
+ this._uiConv[aPrplConversation.id] = uiConv;
+
+ // Ensure conv is in the by contact ID map if it has a contact
+ if (contactId) {
+ this._uiConvByContactId[contactId] = uiConv;
+ }
+ Services.obs.notifyObservers(uiConv, "conversation-update-type");
+ },
+ removeConversation(aPrplConversation) {
+ Services.obs.notifyObservers(aPrplConversation, "conversation-closed");
+
+ let uiConv = this.getUIConversation(aPrplConversation);
+ delete this._uiConv[aPrplConversation.id];
+ let contactId = {};
+ if (uiConv.removeTarget(aPrplConversation, contactId)) {
+ if (contactId.value) {
+ delete this._uiConvByContactId[contactId.value];
+ }
+ Services.obs.notifyObservers(uiConv, "ui-conversation-closed");
+ }
+ this.forgetConversation(aPrplConversation);
+ },
+ forgetConversation(aPrplConversation) {
+ aPrplConversation.unInit();
+
+ this._prplConversations = this._prplConversations.filter(
+ c => c !== aPrplConversation
+ );
+ },
+
+ getUIConversations() {
+ let rv = [];
+ if (this._uiConv) {
+ for (let prplConvId in this._uiConv) {
+ // Since an UIConversation may be linked to multiple prplConversations,
+ // we must ensure we don't return the same UIConversation twice,
+ // by checking the id matches that of the active prplConversation.
+ let uiConv = this._uiConv[prplConvId];
+ if (prplConvId == uiConv.target.id) {
+ rv.push(uiConv);
+ }
+ }
+ }
+ return rv;
+ },
+ getUIConversation(aPrplConversation) {
+ let id = aPrplConversation.id;
+ if (this._uiConv && id in this._uiConv) {
+ return this._uiConv[id];
+ }
+ throw new Error("Unknown conversation");
+ },
+ getUIConversationByContactId(aId) {
+ return aId in this._uiConvByContactId ? this._uiConvByContactId[aId] : null;
+ },
+
+ getConversations() {
+ return this._prplConversations;
+ },
+ getConversationById(aId) {
+ for (let conv of this._prplConversations) {
+ if (conv.id == aId) {
+ return conv;
+ }
+ }
+ return null;
+ },
+ getConversationByNameAndAccount(aName, aAccount, aIsChat) {
+ let normalizedName = aAccount.normalize(aName);
+ for (let conv of this._prplConversations) {
+ if (
+ aAccount.normalize(conv.name) == normalizedName &&
+ aAccount.numericId == conv.account.numericId &&
+ conv.isChat == aIsChat
+ ) {
+ return conv;
+ }
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imIConversationsService"]),
+ classDescription: "Conversations",
+};
diff --git a/comm/chat/components/src/imCore.sys.mjs b/comm/chat/components/src/imCore.sys.mjs
new file mode 100644
index 0000000000..ba05bd4b63
--- /dev/null
+++ b/comm/chat/components/src/imCore.sys.mjs
@@ -0,0 +1,407 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import {
+ ClassInfo,
+ initLogModule,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+var kQuitApplicationGranted = "quit-application-granted";
+var kProtocolPluginCategory = "im-protocol-plugin";
+
+var kPrefReportIdle = "messenger.status.reportIdle";
+var kPrefUserIconFilename = "messenger.status.userIconFileName";
+var kPrefUserDisplayname = "messenger.status.userDisplayName";
+var kPrefTimeBeforeIdle = "messenger.status.timeBeforeIdle";
+var kPrefAwayWhenIdle = "messenger.status.awayWhenIdle";
+var kPrefDefaultMessage = "messenger.status.defaultIdleAwayMessage";
+
+var NS_IOSERVICE_GOING_OFFLINE_TOPIC = "network:offline-about-to-go-offline";
+var NS_IOSERVICE_OFFLINE_STATUS_TOPIC = "network:offline-status-changed";
+
+function UserStatus() {
+ this._observers = [];
+
+ if (Services.prefs.getBoolPref(kPrefReportIdle)) {
+ this._addIdleObserver();
+ }
+ Services.prefs.addObserver(kPrefReportIdle, this);
+
+ if (Services.io.offline) {
+ this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ }
+ Services.obs.addObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC);
+ Services.obs.addObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC);
+}
+UserStatus.prototype = {
+ __proto__: ClassInfo("imIUserStatusInfo", "User status info"),
+
+ unInit() {
+ this._observers = [];
+ Services.prefs.removeObserver(kPrefReportIdle, this);
+ if (this._observingIdleness) {
+ this._removeIdleObserver();
+ }
+ Services.obs.removeObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC);
+ Services.obs.removeObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC);
+ },
+ _observingIdleness: false,
+ _addIdleObserver() {
+ this._observingIdleness = true;
+ this._idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(
+ Ci.nsIUserIdleService
+ );
+ Services.obs.addObserver(this, "im-sent");
+
+ this._timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle);
+ if (this._timeBeforeIdle < 0) {
+ this._timeBeforeIdle = 0;
+ }
+ Services.prefs.addObserver(kPrefTimeBeforeIdle, this);
+ if (this._timeBeforeIdle) {
+ this._idleService.addIdleObserver(this, this._timeBeforeIdle);
+ }
+ },
+ _removeIdleObserver() {
+ if (this._timeBeforeIdle) {
+ this._idleService.removeIdleObserver(this, this._timeBeforeIdle);
+ }
+
+ Services.prefs.removeObserver(kPrefTimeBeforeIdle, this);
+ delete this._timeBeforeIdle;
+
+ Services.obs.removeObserver(this, "im-sent");
+ delete this._idleService;
+ delete this._observingIdleness;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ if (aData == kPrefReportIdle) {
+ let reportIdle = Services.prefs.getBoolPref(kPrefReportIdle);
+ if (reportIdle && !this._observingIdleness) {
+ this._addIdleObserver();
+ } else if (!reportIdle && this._observingIdleness) {
+ this._removeIdleObserver();
+ }
+ } else if (aData == kPrefTimeBeforeIdle) {
+ let timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle);
+ if (timeBeforeIdle != this._timeBeforeIdle) {
+ if (this._timeBeforeIdle) {
+ this._idleService.removeIdleObserver(this, this._timeBeforeIdle);
+ }
+ this._timeBeforeIdle = timeBeforeIdle;
+ if (this._timeBeforeIdle) {
+ this._idleService.addIdleObserver(this, this._timeBeforeIdle);
+ }
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ } else if (aTopic == NS_IOSERVICE_GOING_OFFLINE_TOPIC) {
+ this.offline = true;
+ } else if (
+ aTopic == NS_IOSERVICE_OFFLINE_STATUS_TOPIC &&
+ aData == "online"
+ ) {
+ this.offline = false;
+ } else {
+ this._checkIdle();
+ }
+ },
+
+ _offlineStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ set offline(aOffline) {
+ let statusType = this.statusType;
+ let statusText = this.statusText;
+ if (aOffline) {
+ this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE;
+ } else {
+ delete this._offlineStatusType;
+ }
+ if (this.statusType != statusType || this.statusText != statusText) {
+ this._notifyObservers("status-changed", this.statusText);
+ }
+ },
+
+ _idleTime: 0,
+ get idleTime() {
+ return this._idleTime;
+ },
+ set idleTime(aIdleTime) {
+ this._idleTime = aIdleTime;
+ this._notifyObservers("idle-time-changed", aIdleTime);
+ },
+ _idle: false,
+ _idleStatusText: "",
+ _idleStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ _checkIdle() {
+ let idleTime = Math.floor(this._idleService.idleTime / 1000);
+ let idle = this._timeBeforeIdle && idleTime >= this._timeBeforeIdle;
+ if (idle == this._idle) {
+ return;
+ }
+
+ let statusType = this.statusType;
+ let statusText = this.statusText;
+ this._idle = idle;
+ if (idle) {
+ this.idleTime = idleTime;
+ if (Services.prefs.getBoolPref(kPrefAwayWhenIdle)) {
+ this._idleStatusType = Ci.imIStatusInfo.STATUS_AWAY;
+ this._idleStatusText = Services.prefs.getComplexValue(
+ kPrefDefaultMessage,
+ Ci.nsIPrefLocalizedString
+ ).data;
+ }
+ } else {
+ this.idleTime = 0;
+ delete this._idleStatusType;
+ delete this._idleStatusText;
+ }
+ if (this.statusType != statusType || this.statusText != statusText) {
+ this._notifyObservers("status-changed", this.statusText);
+ }
+ },
+
+ _statusText: "",
+ get statusText() {
+ return this._statusText || this._idleStatusText;
+ },
+ _statusType: Ci.imIStatusInfo.STATUS_AVAILABLE,
+ get statusType() {
+ return Math.min(
+ this._statusType,
+ this._idleStatusType,
+ this._offlineStatusType
+ );
+ },
+ setStatus(aStatus, aMessage) {
+ if (aStatus != Ci.imIStatusInfo.STATUS_UNKNOWN) {
+ this._statusType = aStatus;
+ }
+ if (aStatus != Ci.imIStatusInfo.STATUS_OFFLINE) {
+ this._statusText = aMessage;
+ }
+ this._notifyObservers("status-changed", aMessage);
+ },
+
+ _getProfileDir: () => Services.dirsvc.get("ProfD", Ci.nsIFile),
+ setUserIcon(aIconFile) {
+ let folder = this._getProfileDir();
+
+ let newName = "";
+ if (aIconFile) {
+ // Get the extension (remove trailing dots - invalid Windows extension).
+ let ext = aIconFile.leafName.replace(/.*(\.[a-z0-9]+)\.*/i, "$1");
+ // newName = userIcon-<timestamp(now)>.<aIconFile.extension>
+ newName = "userIcon-" + Math.floor(Date.now() / 1000) + ext;
+
+ // Copy the new icon file to newName in the profile folder.
+ aIconFile.copyTo(folder, newName);
+ }
+
+ // Get the previous file name before saving the new file name.
+ let oldFileName = Services.prefs.getCharPref(kPrefUserIconFilename);
+ Services.prefs.setCharPref(kPrefUserIconFilename, newName);
+
+ // Now that the new icon has been copied to the profile directory
+ // and the pref value changed, we can remove the old icon. Ignore
+ // failures so that we always fire the user-icon-changed notification.
+ try {
+ if (oldFileName) {
+ folder.append(oldFileName);
+ if (folder.exists()) {
+ folder.remove(false);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this._notifyObservers("user-icon-changed", newName);
+ },
+ getUserIcon() {
+ let filename = Services.prefs.getCharPref(kPrefUserIconFilename);
+ if (!filename) {
+ // No icon has been set.
+ return null;
+ }
+
+ let file = this._getProfileDir();
+ file.append(filename);
+
+ if (!file.exists()) {
+ Services.console.logStringMessage("Invalid userIconFileName preference");
+ return null;
+ }
+
+ return Services.io.newFileURI(file);
+ },
+
+ get displayName() {
+ return Services.prefs.getStringPref(kPrefUserDisplayname);
+ },
+ set displayName(aDisplayName) {
+ Services.prefs.setStringPref(kPrefUserDisplayname, aDisplayName);
+ this._notifyObservers("user-display-name-changed", aDisplayName);
+ },
+
+ addObserver(aObserver) {
+ if (!this._observers.includes(aObserver)) {
+ this._observers.push(aObserver);
+ }
+ },
+ removeObserver(aObserver) {
+ this._observers = this._observers.filter(o => o !== aObserver);
+ },
+ _notifyObservers(aTopic, aData) {
+ for (let observer of this._observers) {
+ observer.observe(this, aTopic, aData);
+ }
+ },
+};
+
+export function CoreService() {}
+CoreService.prototype = {
+ globalUserStatus: null,
+
+ _initialized: false,
+ get initialized() {
+ return this._initialized;
+ },
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ initLogModule("core", this);
+
+ Services.obs.addObserver(this, kQuitApplicationGranted);
+ this._initialized = true;
+
+ IMServices.cmd.initCommands();
+ this._protos = {};
+
+ this.globalUserStatus = new UserStatus();
+ this.globalUserStatus.addObserver({
+ observe(aSubject, aTopic, aData) {
+ Services.obs.notifyObservers(aSubject, aTopic, aData);
+ },
+ });
+
+ IMServices.accounts.initAccounts();
+ IMServices.contacts.initContacts();
+ IMServices.conversations.initConversations();
+ Services.obs.notifyObservers(this, "prpl-init");
+
+ // Wait with automatic connections until the password service
+ // is available.
+ if (
+ IMServices.accounts.autoLoginStatus ==
+ Ci.imIAccountsService.AUTOLOGIN_ENABLED
+ ) {
+ Services.logins.initializationPromise.then(() => {
+ IMServices.accounts.processAutoLogin();
+ });
+ }
+ },
+ observe(aObject, aTopic, aData) {
+ if (aTopic == kQuitApplicationGranted) {
+ this.quit();
+ }
+ },
+ quit() {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ Services.obs.removeObserver(this, kQuitApplicationGranted);
+ Services.obs.notifyObservers(this, "prpl-quit");
+
+ IMServices.conversations.unInitConversations();
+ IMServices.accounts.unInitAccounts();
+ IMServices.contacts.unInitContacts();
+ IMServices.cmd.unInitCommands();
+
+ this.globalUserStatus.unInit();
+ delete this.globalUserStatus;
+ delete this._protos;
+ delete this._initialized;
+ },
+
+ getProtocols() {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ let protocols = [];
+ for (let entry of Services.catMan.enumerateCategory(
+ kProtocolPluginCategory
+ )) {
+ let id = entry.data;
+
+ // If the preference is set to disable this prpl, don't show it in the
+ // full list of protocols.
+ let pref = "chat.prpls." + id + ".disable";
+ if (
+ Services.prefs.getPrefType(pref) == Services.prefs.PREF_BOOL &&
+ Services.prefs.getBoolPref(pref)
+ ) {
+ this.LOG("Disabling prpl: " + id);
+ continue;
+ }
+
+ let proto = this.getProtocolById(id);
+ if (proto) {
+ protocols.push(proto);
+ }
+ }
+ return protocols;
+ },
+
+ getProtocolById(aPrplId) {
+ if (!this._initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+
+ if (this._protos.hasOwnProperty(aPrplId)) {
+ return this._protos[aPrplId];
+ }
+
+ let cid;
+ try {
+ cid = Services.catMan.getCategoryEntry(kProtocolPluginCategory, aPrplId);
+ } catch (e) {
+ return null; // no protocol registered for this id.
+ }
+
+ let proto = null;
+ try {
+ proto = Cc[cid].createInstance(Ci.prplIProtocol);
+ } catch (e) {
+ // This is a real error, the protocol is registered and failed to init.
+ let error = "failed to create an instance of " + cid + ": " + e;
+ dump(error + "\n");
+ console.error(error);
+ }
+ if (!proto) {
+ return null;
+ }
+
+ try {
+ proto.init(aPrplId);
+ } catch (e) {
+ console.error("Could not initialize protocol " + aPrplId + ": " + e);
+ return null;
+ }
+
+ this._protos[aPrplId] = proto;
+ return proto;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["imICoreService"]),
+ classDescription: "Core",
+};
diff --git a/comm/chat/components/src/logger.sys.mjs b/comm/chat/components/src/logger.sys.mjs
new file mode 100644
index 0000000000..bde2e2945e
--- /dev/null
+++ b/comm/chat/components/src/logger.sys.mjs
@@ -0,0 +1,971 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IMServices } from "resource:///modules/IMServices.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { GenericMessagePrototype } from "resource:///modules/jsProtoHelper.sys.mjs";
+import {
+ ClassInfo,
+ l10nHelper,
+} from "resource:///modules/imXPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "_", () =>
+ l10nHelper("chrome://chat/locale/logger.properties")
+);
+
+/*
+ * Maps file paths to promises returned by ongoing IOUtils operations on them.
+ * This is so that a file can be read after a pending write operation completes
+ * and vice versa (opening a file multiple times concurrently may fail on Windows).
+ */
+export var gFilePromises = new Map();
+/**
+ * Set containing log file paths that are scheduled to have deleted messages
+ * removed.
+ *
+ * @type {Set<string>}
+ */
+export var gPendingCleanup = new Set();
+
+const kPendingLogCleanupPref = "chat.logging.cleanup.pending";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SHOULD_CLEANUP_LOGS",
+ "chat.logging.cleanup",
+ true
+);
+
+// Uses above map to queue operations on a file.
+export function queueFileOperation(aPath, aOperation) {
+ // Ensure the operation is queued regardless of whether the last one succeeded.
+ // This is safe since the promise is returned and consumers are expected to
+ // handle any errors. If there's no promise existing for the given path already,
+ // queue the operation on a dummy pre-resolved promise.
+ let promise = (gFilePromises.get(aPath) || Promise.resolve()).then(
+ aOperation,
+ aOperation
+ );
+ gFilePromises.set(aPath, promise);
+
+ let cleanup = () => {
+ // If no further operations have been queued, remove the reference from the map.
+ if (gFilePromises.get(aPath) === promise) {
+ gFilePromises.delete(aPath);
+ }
+ };
+ // Ensure we clear unused promises whether they resolved or rejected.
+ promise.then(cleanup, cleanup);
+
+ return promise;
+}
+
+/**
+ * Convenience method to append to a file using the above queue system. If any of
+ * the I/O operations reject, the returned promise will reject with the same reason.
+ * We open the file, append, and close it immediately. The alternative is to keep
+ * it open and append as required, but we want to make sure we don't open a file
+ * for reading while it's already open for writing, so we close it every time
+ * (opening a file multiple times concurrently may fail on Windows).
+ * Note: This function creates parent directories if required.
+ */
+export function appendToFile(aPath, aString, aCreate) {
+ return queueFileOperation(aPath, async function () {
+ await IOUtils.makeDirectory(PathUtils.parent(aPath));
+ const mode = aCreate ? "create" : "append";
+ try {
+ await IOUtils.writeUTF8(aPath, aString, {
+ mode,
+ });
+ } catch (error) {
+ // Ignore existing file when adding the header.
+ if (
+ aCreate &&
+ error.name == "NoModificationAllowedError" &&
+ error.message.startsWith("Refusing to overwrite the file")
+ ) {
+ return;
+ }
+ throw error;
+ }
+ });
+}
+
+// This function checks names against OS naming conventions and alters them
+// accordingly so that they can be used as file/folder names.
+export function encodeName(aName) {
+ // Reserved device names by Windows (prefixing "%").
+ let reservedNames = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i;
+ if (reservedNames.test(aName)) {
+ return "%" + aName;
+ }
+
+ // "." and " " must not be at the end of a file or folder name (appending "_").
+ if (/[\. _]/.test(aName.slice(-1))) {
+ aName += "_";
+ }
+
+ // Reserved characters are replaced by %[hex value]. encodeURIComponent() is
+ // not sufficient, nevertheless decodeURIComponent() can be used to decode.
+ function encodeReservedChars(match) {
+ return "%" + match.charCodeAt(0).toString(16);
+ }
+ return aName.replace(/[<>:"\/\\|?*&%]/g, encodeReservedChars);
+}
+
+export function getLogFolderPathForAccount(aAccount) {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs",
+ aAccount.protocol.normalizedName,
+ encodeName(aAccount.normalizedName)
+ );
+}
+
+export function getLogFilePathForConversation(aConv, aStartTime) {
+ if (!aStartTime) {
+ aStartTime = aConv.startDate / 1000;
+ }
+ let path = getLogFolderPathForAccount(aConv.account);
+ let name = aConv.normalizedName;
+ if (aConv.isChat) {
+ name += ".chat";
+ }
+ return PathUtils.join(path, encodeName(name), getNewLogFileName(aStartTime));
+}
+
+export function getNewLogFileName(aStartTime) {
+ let date = aStartTime ? new Date(aStartTime) : new Date();
+ let dateTime = lazy.ToLocaleFormat("%Y-%m-%d.%H%M%S", date);
+ let offset = date.getTimezoneOffset();
+ if (offset < 0) {
+ dateTime += "+";
+ offset *= -1;
+ } else {
+ dateTime += "-";
+ }
+ let minutes = offset % 60;
+ offset = (offset - minutes) / 60;
+ function twoDigits(number) {
+ if (number == 0) {
+ return "00";
+ }
+ return number < 10 ? "0" + number : number;
+ }
+ return dateTime + twoDigits(offset) + twoDigits(minutes) + ".json";
+}
+
+/**
+ * Schedules a cleanup of the logfiles contents, removing the message texts
+ * from messages that were marked as deleted. This can be disabled by a pref.
+ *
+ * @param {string} path - Path to the logfile to clean.
+ */
+function queueLogFileCleanup(path) {
+ if (gPendingCleanup.has(path) || !lazy.SHOULD_CLEANUP_LOGS) {
+ return;
+ }
+ let idleCallback = () => {
+ if (gFilePromises.has(path)) {
+ gFilePromises.get(path).finally(() => {
+ ChromeUtils.idleDispatch(idleCallback);
+ });
+ return;
+ }
+ // Queue a new file operation to ensure nothing gets appended between
+ // reading the log and writing it back. This means we might run this when
+ // the application isn't idle, but due to the async operations that is
+ // very hard to guarantee either way.
+ queueFileOperation(path, async () => {
+ try {
+ let logContents = await IOUtils.readUTF8(path);
+ let logLines = logContents.split("\n").map(line => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ return line;
+ }
+ });
+ let lastDeletionIndex = 0;
+ let deletedMessages = new Set(
+ logLines
+ .filter((message, index) => {
+ if (message.flags?.includes("deleted") && message.remoteId) {
+ lastDeletionIndex = index;
+ return true;
+ }
+ return false;
+ })
+ .map(message => message.remoteId)
+ );
+ for (let [index, message] of logLines.entries()) {
+ // If we are past the last deletion in the logs, there is no more
+ // work to be done.
+ if (index >= lastDeletionIndex) {
+ break;
+ }
+ if (
+ deletedMessages.has(message.remoteId) &&
+ !message.flags?.includes("deleted")
+ ) {
+ // Void the text of deleted messages but keep the message
+ // metadata for journaling.
+ message.text = "";
+ }
+ }
+ let cleanedLog = logLines
+ .map(line => {
+ if (typeof line === "string") {
+ return line;
+ }
+ return JSON.stringify(line);
+ })
+ .join("\n");
+ await IOUtils.writeUTF8(path, cleanedLog);
+ } catch (error) {
+ console.error(
+ "Error cleaning up log file contents for " + path + ": " + error
+ );
+ } finally {
+ gPendingCleanup.delete(path);
+ Services.prefs.setStringPref(
+ kPendingLogCleanupPref,
+ JSON.stringify(Array.from(gPendingCleanup.values()))
+ );
+ }
+ });
+ };
+ ChromeUtils.idleDispatch(idleCallback);
+ gPendingCleanup.add(path);
+ Services.prefs.setStringPref(
+ kPendingLogCleanupPref,
+ JSON.stringify(Array.from(gPendingCleanup.values()))
+ );
+}
+
+/**
+ * Schedule pending log cleanups that weren't completed last time the
+ * application was running.
+ */
+function initLogCleanup() {
+ if (!lazy.SHOULD_CLEANUP_LOGS) {
+ return;
+ }
+ // Capture the value of the pending cleanups before it gets overridden by
+ // newly scheduled cleanups.
+ let pendingCleanupPathValue = Services.prefs.getStringPref(
+ kPendingLogCleanupPref,
+ "[]"
+ );
+ // We are in no hurry to queue these cleanups, worst case we try to schedule
+ // a cleanup for a file that is already scheduled.
+ ChromeUtils.idleDispatch(() => {
+ let pendingCleanupPaths = JSON.parse(pendingCleanupPathValue) ?? [];
+ if (!Array.isArray(pendingCleanupPaths)) {
+ console.error(
+ "Pending chat log cleanup pref is not a valid array. " +
+ "Assuming all chat logs are clean."
+ );
+ return;
+ }
+ for (const path of pendingCleanupPaths) {
+ if (typeof path === "string") {
+ queueLogFileCleanup(path);
+ }
+ }
+ });
+}
+
+// One of these is maintained for every conversation being logged. It initializes
+// a log file and appends to it as required.
+function LogWriter(aConversation) {
+ this._conv = aConversation;
+ this.paths = [];
+ this.startNewFile(this._conv.startDate / 1000);
+}
+LogWriter.prototype = {
+ // All log file paths used by this LogWriter.
+ paths: [],
+ // Path of the log file that is currently being written to.
+ get currentPath() {
+ return this.paths[this.paths.length - 1];
+ },
+ // Constructor sets this to a promise that will resolve when the log header
+ // has been written.
+ _initialized: null,
+ _startTime: null,
+ _lastMessageTime: null,
+ _messageCount: 0,
+ startNewFile(aStartTime, aContinuedSession) {
+ // We start a new log file every 1000 messages. The start time of this new
+ // log file is the time of the next message. Since message times are in seconds,
+ // if we receive 1000 messages within a second after starting the new file,
+ // we will create another file, using the same start time - and so the same
+ // file name. To avoid this, ensure the new start time is at least one second
+ // greater than the current one. This is ugly, but should rarely be needed.
+ aStartTime = Math.max(aStartTime, this._startTime + 1000);
+ this._startTime = this._lastMessageTime = aStartTime;
+ this._messageCount = 0;
+ this.paths.push(getLogFilePathForConversation(this._conv, aStartTime));
+ let account = this._conv.account;
+ let header = {
+ date: new Date(this._startTime),
+ name: this._conv.name,
+ title: this._conv.title,
+ account: account.normalizedName,
+ protocol: account.protocol.normalizedName,
+ isChat: this._conv.isChat,
+ normalizedName: this._conv.normalizedName,
+ };
+ if (aContinuedSession) {
+ header.continuedSession = true;
+ }
+ header = JSON.stringify(header) + "\n";
+
+ this._initialized = appendToFile(this.currentPath, header, true);
+ // Catch the error separately so that _initialized will stay rejected if
+ // writing the header failed.
+ this._initialized.catch(aError =>
+ console.error("Failed to initialize log file:\n" + aError)
+ );
+ },
+ // We start a new log file in the following cases:
+ // - If it has been 30 minutes since the last message.
+ kInactivityLimit: 30 * 60 * 1000,
+ // - If at midnight, it's been longer than 3 hours since we started the file.
+ kDayOverlapLimit: 3 * 60 * 60 * 1000,
+ // - After every 1000 messages.
+ kMessageCountLimit: 1000,
+ async logMessage(aMessage) {
+ // aMessage.time is in seconds, we need it in milliseconds.
+ let messageTime = aMessage.time * 1000;
+ let messageMidnight = new Date(messageTime).setHours(0, 0, 0, 0);
+
+ let inactivityLimitExceeded =
+ !aMessage.delayed &&
+ messageTime - this._lastMessageTime > this.kInactivityLimit;
+ let dayOverlapLimitExceeded =
+ !aMessage.delayed &&
+ messageMidnight - this._startTime > this.kDayOverlapLimit;
+
+ if (
+ inactivityLimitExceeded ||
+ dayOverlapLimitExceeded ||
+ this._messageCount == this.kMessageCountLimit
+ ) {
+ // We start a new session if the inactivity limit was exceeded.
+ this.startNewFile(messageTime, !inactivityLimitExceeded);
+ }
+ ++this._messageCount;
+
+ if (!aMessage.delayed) {
+ this._lastMessageTime = messageTime;
+ }
+
+ let msg = {
+ date: new Date(messageTime),
+ who: aMessage.who,
+ text: aMessage.displayMessage,
+ flags: [
+ "outgoing",
+ "incoming",
+ "system",
+ "autoResponse",
+ "containsNick",
+ "error",
+ "delayed",
+ "noFormat",
+ "containsImages",
+ "notification",
+ "noLinkification",
+ "isEncrypted",
+ "action",
+ "deleted",
+ ].filter(f => aMessage[f]),
+ remoteId: aMessage.remoteId,
+ };
+ let alias = aMessage.alias;
+ if (alias && alias != msg.who) {
+ msg.alias = alias;
+ }
+ let lineToWrite = JSON.stringify(msg) + "\n";
+
+ await this._initialized;
+ try {
+ await appendToFile(this.currentPath, lineToWrite);
+ } catch (error) {
+ console.error("Failed to log message:\n" + error);
+ }
+ if (aMessage.deleted) {
+ queueLogFileCleanup(this.currentPath);
+ }
+ },
+};
+
+var dummyLogWriter = {
+ paths: null,
+ currentPath: null,
+ logMessage() {},
+};
+
+var gLogWritersById = new Map();
+export function getLogWriter(aConversation) {
+ let id = aConversation.id;
+ if (!gLogWritersById.has(id)) {
+ let prefName =
+ "purple.logging.log_" + (aConversation.isChat ? "chats" : "ims");
+ if (Services.prefs.getBoolPref(prefName)) {
+ gLogWritersById.set(id, new LogWriter(aConversation));
+ } else {
+ gLogWritersById.set(id, dummyLogWriter);
+ }
+ }
+ return gLogWritersById.get(id);
+}
+
+export function closeLogWriter(aConversation) {
+ gLogWritersById.delete(aConversation.id);
+}
+
+/**
+ * Takes a properly formatted log file name and extracts the date information
+ * and filetype, returning the results as an Array.
+ *
+ * Filenames are expected to be formatted as:
+ *
+ * YYYY-MM-DD.HHmmSS+ZZzz.format
+ *
+ * @param aFilename the name of the file
+ * @returns an Array, where the first element is a Date object for the date
+ * that the log file represents, and the file type as a string.
+ */
+function getDateFromFilename(aFilename) {
+ const kRegExp =
+ /([\d]{4})-([\d]{2})-([\d]{2}).([\d]{2})([\d]{2})([\d]{2})([+-])([\d]{2})([\d]{2}).*\.([A-Za-z]+)$/;
+
+ let r = aFilename.match(kRegExp);
+ if (!r) {
+ console.error(
+ "Found log file with name not matching YYYY-MM-DD.HHmmSS+ZZzz.format: " +
+ aFilename
+ );
+ return [];
+ }
+
+ // We ignore the timezone offset for now (FIXME)
+ return [new Date(r[1], r[2] - 1, r[3], r[4], r[5], r[6]), r[10]];
+}
+
+function LogMessage(aData, aConversation) {
+ this._init(aData.who, aData.text, {}, aConversation);
+ // Not overriding time using the init options, since init also sets the
+ // property.
+ this.time = Math.round(new Date(aData.date) / 1000);
+ if ("alias" in aData) {
+ this._alias = aData.alias;
+ }
+ this.remoteId = aData.remoteId;
+ if (aData.flags) {
+ for (let flag of aData.flags) {
+ this[flag] = true;
+ }
+ }
+}
+
+LogMessage.prototype = {
+ __proto__: GenericMessagePrototype,
+ _interfaces: [Ci.imIMessage, Ci.prplIMessage],
+ get displayMessage() {
+ return this.originalMessage;
+ },
+};
+
+function LogConversation(aMessages, aProperties) {
+ this._messages = aMessages;
+ for (let property in aProperties) {
+ this[property] = aProperties[property];
+ }
+}
+LogConversation.prototype = {
+ __proto__: ClassInfo("imILogConversation", "Log conversation object"),
+ get isChat() {
+ return this._isChat;
+ },
+ get buddy() {
+ return null;
+ },
+ get account() {
+ return {
+ alias: "",
+ name: this._accountName,
+ normalizedName: this._accountName,
+ protocol: { name: this._protocolName },
+ statusInfo: IMServices.core.globalUserStatus,
+ };
+ },
+ getMessages() {
+ // Start with the newest message to filter out older versions of the same
+ // message. Also filter out deleted messages.
+ return this._messages.map(m => new LogMessage(m, this));
+ },
+};
+
+/**
+ * A Log object represents one or more log files. The constructor expects one
+ * argument, which is either a single path to a json log file or an array of
+ * objects each having two properties:
+ * path: The full path of the (json only) log file it represents.
+ * time: The Date object extracted from the filename of the logfile.
+ *
+ * The returned Log object's time property will be:
+ * For a single file - exact time extracted from the name of the log file.
+ * For a set of files - the time extracted, reduced to the day.
+ */
+function Log(aEntries) {
+ if (typeof aEntries == "string") {
+ // Assume that aEntries is a single path.
+ let path = aEntries;
+ this.path = path;
+ let [date, format] = getDateFromFilename(PathUtils.filename(path));
+ if (!date || !format) {
+ this.time = 0;
+ return;
+ }
+ this.time = date.valueOf() / 1000;
+ // Wrap the path in an array
+ this._entryPaths = [path];
+ return;
+ }
+
+ if (!aEntries.length) {
+ throw new Error(
+ "Log was passed an invalid argument, " +
+ "expected a non-empty array or a string."
+ );
+ }
+
+ // Assume aEntries is an array of objects.
+ // Sort our list of entries for this day in increasing order.
+ aEntries.sort((aLeft, aRight) => aLeft.time - aRight.time);
+
+ this._entryPaths = aEntries.map(entry => entry.path);
+ // Calculate the timestamp for the first entry down to the day.
+ let timestamp = new Date(aEntries[0].time);
+ timestamp.setHours(0);
+ timestamp.setMinutes(0);
+ timestamp.setSeconds(0);
+ this.time = timestamp.valueOf() / 1000;
+ // Path is used to uniquely identify a Log, and sometimes used to
+ // quickly determine which directory a log file is from. We'll use
+ // the first file's path.
+ this.path = aEntries[0].path;
+}
+Log.prototype = {
+ __proto__: ClassInfo("imILog", "Log object"),
+ _entryPaths: null,
+ async getConversation() {
+ /*
+ * Read the set of log files asynchronously and return a promise that
+ * resolves to a LogConversation instance. Even if a file contains some
+ * junk (invalid JSON), messages that are valid will be read. If the first
+ * line of metadata is corrupt however, the data isn't useful and the
+ * promise will resolve to null.
+ */
+ let messages = [];
+ let properties = {};
+ let firstFile = true;
+ let decoder = new TextDecoder();
+ let lastRemoteIdIndex = {};
+ for (let path of this._entryPaths) {
+ let lines;
+ try {
+ let contents = await queueFileOperation(path, () => IOUtils.read(path));
+ lines = decoder.decode(contents).split("\n");
+ } catch (aError) {
+ console.error('Error reading log file "' + path + '":\n' + aError);
+ continue;
+ }
+ let nextLine = lines.shift();
+ let filename = PathUtils.filename(path);
+
+ let data;
+ try {
+ // This will fail if either nextLine is undefined, or not valid JSON.
+ data = JSON.parse(nextLine);
+ } catch (aError) {
+ messages.push({
+ who: "sessionstart",
+ date: getDateFromFilename(filename)[0],
+ text: lazy._("badLogfile", filename),
+ flags: ["noLog", "notification", "error", "system"],
+ });
+ continue;
+ }
+
+ if (firstFile || !data.continuedSession) {
+ messages.push({
+ who: "sessionstart",
+ date: getDateFromFilename(filename)[0],
+ text: "",
+ flags: ["noLog", "notification"],
+ });
+ }
+
+ if (firstFile) {
+ properties.startDate = new Date(data.date) * 1000;
+ properties.name = data.name;
+ properties.title = data.title;
+ properties._accountName = data.account;
+ properties._protocolName = data.protocol;
+ properties._isChat = data.isChat;
+ properties.normalizedName = data.normalizedName;
+ firstFile = false;
+ }
+
+ while (lines.length) {
+ nextLine = lines.shift();
+ if (!nextLine) {
+ break;
+ }
+ try {
+ let message = JSON.parse(nextLine);
+
+ // Backwards compatibility for old action messages.
+ if (
+ !message.flags.includes("action") &&
+ message.text?.startsWith("/me ")
+ ) {
+ message.flags.push("action");
+ message.text = message.text.slice(4);
+ }
+
+ if (message.remoteId) {
+ lastRemoteIdIndex[message.remoteId] = messages.length;
+ }
+ messages.push(message);
+ } catch (e) {
+ // If a message line contains junk, just ignore the error and
+ // continue reading the conversation.
+ }
+ }
+ }
+
+ if (firstFile) {
+ // All selected log files are invalid.
+ return null;
+ }
+
+ // Ignore older versions of edited messages and deleted messages.
+ messages = messages.filter((message, index) => {
+ if (
+ message.remoteId &&
+ lastRemoteIdIndex.hasOwnProperty(message.remoteId) &&
+ index < lastRemoteIdIndex[message.remoteId]
+ ) {
+ return false;
+ }
+ return !message.flags.includes("deleted");
+ });
+
+ return new LogConversation(messages, properties);
+ },
+};
+
+/**
+ * logsGroupedByDay() organizes log entries by date.
+ *
+ * @param {string[]} aEntries - paths of log files to be parsed.
+ * @returns {imILog[]} Logs, ordered by day.
+ */
+function logsGroupedByDay(aEntries) {
+ if (!Array.isArray(aEntries)) {
+ return [];
+ }
+
+ let entries = {};
+ for (let path of aEntries) {
+ let [logDate, logFormat] = getDateFromFilename(PathUtils.filename(path));
+ if (!logDate) {
+ // We'll skip this one, since it's got a busted filename.
+ continue;
+ }
+
+ let dateForID = new Date(logDate);
+ let dayID;
+ // If the file isn't a JSON file, ignore it.
+ if (logFormat != "json") {
+ continue;
+ }
+ // We want to cluster all of the logs that occur on the same day
+ // into the same Arrays. We clone the date for the log, reset it to
+ // the 0th hour/minute/second, and use that to construct an ID for the
+ // Array we'll put the log in.
+ dateForID.setHours(0);
+ dateForID.setMinutes(0);
+ dateForID.setSeconds(0);
+ dayID = dateForID.toISOString();
+
+ if (!(dayID in entries)) {
+ entries[dayID] = [];
+ }
+
+ entries[dayID].push({
+ path,
+ time: logDate,
+ });
+ }
+
+ let days = Object.keys(entries);
+ days.sort();
+ return days.map(dayID => new Log(entries[dayID]));
+}
+
+export function Logger() {
+ IOUtils.profileBeforeChange.addBlocker(
+ "Chat logger: writing all pending messages",
+ async function () {
+ for (let promise of gFilePromises.values()) {
+ try {
+ await promise;
+ } catch (aError) {
+ // Ignore the error, whatever queued the operation will take care of it.
+ }
+ }
+ }
+ );
+
+ Services.obs.addObserver(this, "new-text");
+ Services.obs.addObserver(this, "conversation-closed");
+ Services.obs.addObserver(this, "conversation-left-chat");
+ initLogCleanup();
+}
+
+Logger.prototype = {
+ // Returned Promise resolves to an array of entries for the
+ // log folder if it exists, otherwise null.
+ async _getLogEntries(aAccount, aNormalizedName) {
+ let path;
+ try {
+ path = PathUtils.join(
+ getLogFolderPathForAccount(aAccount),
+ encodeName(aNormalizedName)
+ );
+ if (await queueFileOperation(path, () => IOUtils.exists(path))) {
+ return await IOUtils.getChildren(path);
+ }
+ } catch (aError) {
+ console.error(
+ 'Error getting directory entries for "' + path + '":\n' + aError
+ );
+ }
+ return [];
+ },
+ async getLogFromFile(aFilePath, aGroupByDay) {
+ if (!aGroupByDay) {
+ return new Log(aFilePath);
+ }
+ let [targetDate] = getDateFromFilename(PathUtils.filename(aFilePath));
+ if (!targetDate) {
+ return null;
+ }
+
+ targetDate = targetDate.toDateString();
+
+ // We'll assume that the files relevant to our interests are
+ // in the same folder as the one provided.
+ let relevantEntries = [];
+ for (const path of await IOUtils.getChildren(PathUtils.parent(aFilePath))) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory") {
+ continue;
+ }
+ let [logTime] = getDateFromFilename(PathUtils.filename(path));
+ // If someone placed a 'foreign' file into the logs directory,
+ // pattern matching fails and getDateFromFilename() returns [].
+ if (logTime && targetDate == logTime.toDateString()) {
+ relevantEntries.push({
+ path,
+ time: logTime,
+ });
+ }
+ }
+ return new Log(relevantEntries);
+ },
+
+ async getLogPathsForConversation(aConversation) {
+ let writer = gLogWritersById.get(aConversation.id);
+ // Resolve to null if we haven't created a LogWriter yet for this conv, or
+ // if logging is disabled (paths will be null).
+ if (!writer || !writer.paths) {
+ return null;
+ }
+ let paths = writer.paths;
+ // Wait for any pending file operations to finish, then resolve to the paths
+ // regardless of whether these operations succeeded.
+ for (let path of paths) {
+ await gFilePromises.get(path);
+ }
+ return paths;
+ },
+ async getLogsForContact(aContact) {
+ let entries = [];
+ for (let buddy of aContact.getBuddies()) {
+ for (let accountBuddy of buddy.getAccountBuddies()) {
+ entries = entries.concat(
+ await this._getLogEntries(
+ accountBuddy.account,
+ accountBuddy.normalizedName
+ )
+ );
+ }
+ }
+ return logsGroupedByDay(entries);
+ },
+ getLogsForConversation(aConversation) {
+ let name = aConversation.normalizedName;
+ if (aConversation.isChat) {
+ name += ".chat";
+ }
+
+ return this._getLogEntries(aConversation.account, name).then(entries =>
+ logsGroupedByDay(entries)
+ );
+ },
+ async getSimilarLogs(log) {
+ let entries;
+ try {
+ entries = await IOUtils.getChildren(PathUtils.parent(log.path));
+ } catch (aError) {
+ console.error(
+ 'Error getting similar logs for "' + log.path + '":\n' + aError
+ );
+ }
+ // If there was an error, this will return an empty array.
+ return logsGroupedByDay(entries);
+ },
+
+ getLogFolderPathForAccount(aAccount) {
+ return getLogFolderPathForAccount(aAccount);
+ },
+
+ deleteLogFolderForAccount(aAccount) {
+ if (!aAccount.disconnecting && !aAccount.disconnected) {
+ throw new Error(
+ "Account must be disconnected first before deleting logs."
+ );
+ }
+
+ if (aAccount.disconnecting) {
+ console.error(
+ "Account is still disconnecting while we attempt to remove logs."
+ );
+ }
+
+ let logPath = this.getLogFolderPathForAccount(aAccount);
+ // Find all operations on files inside the log folder.
+ let pendingPromises = [];
+ function checkLogFiles(promiseOperation, filePath) {
+ if (filePath.startsWith(logPath)) {
+ pendingPromises.push(promiseOperation);
+ }
+ }
+ gFilePromises.forEach(checkLogFiles);
+ // After all operations finish, remove the whole log folder.
+ return Promise.all(pendingPromises)
+ .then(values => {
+ IOUtils.remove(logPath, { recursive: true });
+ })
+ .catch(aError =>
+ console.error("Failed to remove log folders:\n" + aError)
+ );
+ },
+
+ async forEach(aCallback) {
+ let getAllSubdirs = async function (aPaths, aErrorMsg) {
+ let entries = [];
+ for (let path of aPaths) {
+ try {
+ entries = entries.concat(await IOUtils.getChildren(path));
+ } catch (aError) {
+ if (aErrorMsg) {
+ console.error(aErrorMsg + "\n" + aError);
+ }
+ }
+ }
+ let filteredPaths = [];
+ for (let path of entries) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory") {
+ filteredPaths.push(path);
+ }
+ }
+ return filteredPaths;
+ };
+
+ let logsPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs"
+ );
+ let prpls = await getAllSubdirs([logsPath]);
+ let accounts = await getAllSubdirs(
+ prpls,
+ "Error while sweeping prpl folder:"
+ );
+ let logFolders = await getAllSubdirs(
+ accounts,
+ "Error while sweeping account folder:"
+ );
+ for (let folder of logFolders) {
+ try {
+ for (const path of await IOUtils.getChildren(folder)) {
+ const stat = await IOUtils.stat(path);
+ if (stat.type === "directory" || !path.endsWith(".json")) {
+ continue;
+ }
+ await aCallback.processLog(path);
+ }
+ } catch (aError) {
+ // If the callback threw, reject the promise and let the caller handle it.
+ if (!DOMException.isInstance(aError)) {
+ throw aError;
+ }
+ console.error("Error sweeping log folder:\n" + aError);
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "new-text":
+ let excludeBecauseEncrypted = false;
+ if (aSubject.isEncrypted) {
+ excludeBecauseEncrypted = !Services.prefs.getBoolPref(
+ "messenger.account." +
+ aSubject.conversation.account.id +
+ ".options.otrAllowMsgLog",
+ Services.prefs.getBoolPref("chat.otr.default.allowMsgLog")
+ );
+ }
+ if (!aSubject.noLog && !excludeBecauseEncrypted) {
+ let log = getLogWriter(aSubject.conversation);
+ log.logMessage(aSubject);
+ }
+ break;
+ case "conversation-closed":
+ case "conversation-left-chat":
+ closeLogWriter(aSubject);
+ break;
+ default:
+ throw new Error("Unexpected notification " + aTopic);
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver", "imILogger"]),
+ classDescription: "Logger",
+};
diff --git a/comm/chat/components/src/moz.build b/comm/chat/components/src/moz.build
new file mode 100644
index 0000000000..cbab7e998b
--- /dev/null
+++ b/comm/chat/components/src/moz.build
@@ -0,0 +1,19 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "imAccounts.sys.mjs",
+ "imCommands.sys.mjs",
+ "imContacts.sys.mjs",
+ "imConversations.sys.mjs",
+ "imCore.sys.mjs",
+ "logger.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/chat/components/src/test/test_accounts.js b/comm/chat/components/src/test/test_accounts.js
new file mode 100644
index 0000000000..267095455f
--- /dev/null
+++ b/comm/chat/components/src/test/test_accounts.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+function run_test() {
+ do_get_profile();
+
+ // Test the handling of accounts for unknown protocols.
+ const kAccountName = "Unknown";
+ const kPrplId = "prpl-unknown";
+
+ let prefs = Services.prefs;
+ prefs.setCharPref("messenger.account.account1.name", kAccountName);
+ prefs.setCharPref("messenger.account.account1.prpl", kPrplId);
+ prefs.setCharPref("mail.accountmanager.accounts", "account1");
+ prefs.setCharPref("mail.account.account1.server", "server1");
+ prefs.setCharPref("mail.server.server1.imAccount", "account1");
+ prefs.setCharPref("mail.server.server1.type", "im");
+ prefs.setCharPref("mail.server.server1.userName", kAccountName);
+ prefs.setCharPref("mail.server.server1.hostname", kPrplId);
+ try {
+ // Having an implementation of nsIXULAppInfo is required for
+ // IMServices.core.init to work.
+ updateAppInfo();
+ IMServices.core.init();
+
+ let account = IMServices.accounts.getAccountByNumericId(1);
+ Assert.ok(account instanceof Ci.imIAccount);
+ Assert.equal(account.name, kAccountName);
+ Assert.equal(account.normalizedName, kAccountName);
+ Assert.equal(account.protocol.id, kPrplId);
+ Assert.equal(
+ account.connectionErrorReason,
+ Ci.imIAccount.ERROR_UNKNOWN_PRPL
+ );
+ } finally {
+ IMServices.core.quit();
+
+ prefs.deleteBranch("messenger");
+ }
+}
diff --git a/comm/chat/components/src/test/test_commands.js b/comm/chat/components/src/test/test_commands.js
new file mode 100644
index 0000000000..de0fd0e665
--- /dev/null
+++ b/comm/chat/components/src/test/test_commands.js
@@ -0,0 +1,271 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+// We don't load the command service via Services as we want to access
+// _findCommands in order to avoid having to intercept command execution.
+var { CommandsService } = ChromeUtils.importESModule(
+ "resource:///modules/imCommands.sys.mjs"
+);
+
+var kPrplId = "green";
+var kPrplId2 = "red";
+
+var fakeAccount = {
+ connected: true,
+ protocol: { id: kPrplId },
+};
+var fakeDisconnectedAccount = {
+ connected: false,
+ protocol: { id: kPrplId },
+};
+var fakeAccount2 = {
+ connected: true,
+ protocol: { id: kPrplId2 },
+};
+
+var fakeConversation = {
+ account: fakeAccount,
+ isChat: true,
+};
+
+function fakeCommand(aName, aUsageContext) {
+ this.name = aName;
+ if (aUsageContext) {
+ this.usageContext = aUsageContext;
+ }
+}
+fakeCommand.prototype = {
+ get helpString() {
+ return "";
+ },
+ usageContext: Ci.imICommand.CMD_CONTEXT_ALL,
+ priority: Ci.imICommand.CMD_PRIORITY_PRPL,
+ run: (aMsg, aConv) => true,
+};
+
+function run_test() {
+ let cmdserv = new CommandsService();
+ cmdserv.initCommands();
+
+ // Some commands providing multiple possible completions.
+ cmdserv.registerCommand(new fakeCommand("banana"), kPrplId2);
+ cmdserv.registerCommand(new fakeCommand("baloney"), kPrplId2);
+
+ // MUC-only command.
+ cmdserv.registerCommand(
+ new fakeCommand("balderdash", Ci.imICommand.CMD_CONTEXT_CHAT),
+ kPrplId
+ );
+
+ // Name clashes with global command.
+ cmdserv.registerCommand(new fakeCommand("offline"), kPrplId);
+
+ // Name starts with another command name.
+ cmdserv.registerCommand(new fakeCommand("helpme"), kPrplId);
+
+ // Command name contains numbers.
+ cmdserv.registerCommand(new fakeCommand("r9kbeta"), kPrplId);
+
+ // Array of (possibly partial) command names as entered by the user.
+ let testCmds = [
+ "x",
+ "b",
+ "ba",
+ "bal",
+ "back",
+ "hel",
+ "help",
+ "off",
+ "offline",
+ ];
+
+ // We test an array of different possible conversations.
+ // cmdlist lists all the available commands for the given conversation.
+ // results is an array which for each testCmd provides an array containing
+ // data with which the return value of _findCommands can be checked. In
+ // particular, the name of the command and whether the first (i.e. preferred)
+ // entry in the returned array of commands is a prpl command. (If the latter
+ // boolean is not given, false is assumed, if the name is not given, that
+ // corresponds to no commands being returned.)
+ let testData = [
+ {
+ desc: "No conversation argument.",
+ cmdlist: "away, back, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Disconnected conversation with fakeAccount.",
+ conv: {
+ account: fakeDisconnectedAccount,
+ },
+ cmdlist:
+ "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Conversation with fakeAccount.",
+ conv: {
+ account: fakeAccount,
+ },
+ cmdlist:
+ "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ ["back"],
+ [],
+ ["back"],
+ [],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "MUC with fakeAccount.",
+ conv: {
+ account: fakeAccount,
+ isChat: true,
+ },
+ cmdlist:
+ "away, back, balderdash, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["balderdash", true],
+ ["back"],
+ [],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "Conversation with fakeAccount2.",
+ conv: {
+ account: fakeAccount2,
+ },
+ cmdlist:
+ "away, back, baloney, banana, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["baloney", true],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ {
+ desc: "MUC with fakeAccount2.",
+ conv: {
+ account: fakeAccount2,
+ isChat: true,
+ },
+ cmdlist:
+ "away, back, baloney, banana, busy, dnd, help, offline, raw, say",
+ results: [
+ [],
+ [],
+ [],
+ ["baloney", true],
+ ["back"],
+ ["help"],
+ ["help"],
+ ["offline"],
+ ["offline"],
+ ],
+ },
+ ];
+
+ for (let test of testData) {
+ info("The following tests are with: " + test.desc);
+
+ // Check which commands are available in which context.
+ let cmdlist = cmdserv
+ .listCommandsForConversation(test.conv)
+ .map(aCmd => aCmd.name)
+ .sort()
+ .join(", ");
+ Assert.equal(cmdlist, test.cmdlist);
+
+ for (let testCmd of testCmds) {
+ info("Testing command found for '" + testCmd + "'");
+ let expectedResult = test.results.shift();
+ let cmdArray = cmdserv._findCommands(test.conv, testCmd);
+ // Check whether commands are only returned when appropriate.
+ Assert.equal(cmdArray.length > 0, expectedResult.length > 0);
+ if (cmdArray.length) {
+ // Check if the right command was returned.
+ Assert.equal(cmdArray[0].name, expectedResult[0]);
+ Assert.equal(
+ cmdArray[0].priority == Ci.imICommand.CMD_PRIORITY_PRPL,
+ !!expectedResult[1]
+ );
+ }
+ }
+ }
+
+ // Array of messages to test command execution of.
+ let testMessages = [
+ {
+ message: "/r9kbeta",
+ result: true,
+ },
+ {
+ message: "/helpme 2 arguments",
+ result: true,
+ },
+ {
+ message: "nocommand",
+ result: false,
+ },
+ {
+ message: "/-a",
+ result: false,
+ },
+ {
+ message: "/notregistered",
+ result: false,
+ },
+ ];
+
+ // Test command execution.
+ for (let executionTest of testMessages) {
+ info("Testing command execution for '" + executionTest.message + "'");
+ Assert.equal(
+ cmdserv.executeCommand(executionTest.message, fakeConversation),
+ executionTest.result
+ );
+ }
+
+ cmdserv.unInitCommands();
+}
diff --git a/comm/chat/components/src/test/test_conversations.js b/comm/chat/components/src/test/test_conversations.js
new file mode 100644
index 0000000000..c1ede89734
--- /dev/null
+++ b/comm/chat/components/src/test/test_conversations.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+var { GenericConvIMPrototype, Message } = ChromeUtils.importESModule(
+ "resource:///modules/jsProtoHelper.sys.mjs"
+);
+var { imMessage, UIConversation } = ChromeUtils.importESModule(
+ "resource:///modules/imConversations.sys.mjs"
+);
+
+// Fake prplConversation
+var _id = 0;
+function Conversation(aName) {
+ this._name = aName;
+ this._observers = [];
+ this._date = Date.now() * 1000;
+ this.id = ++_id;
+}
+Conversation.prototype = {
+ __proto__: GenericConvIMPrototype,
+ _account: {
+ imAccount: {
+ protocol: { name: "Fake Protocol" },
+ alias: "",
+ name: "Fake Account",
+ },
+ ERROR(e) {
+ throw e;
+ },
+ DEBUG() {},
+ },
+ addObserver(aObserver) {
+ if (!(aObserver instanceof Ci.nsIObserver)) {
+ aObserver = { observe: aObserver };
+ }
+ GenericConvIMPrototype.addObserver.call(this, aObserver);
+ },
+};
+
+// Ensure that when iMsg.message is set to a message (including the empty
+// string), it returns that message. If not, it should return the original
+// message. This prevents regressions due to JS coercions.
+var test_null_message = function () {
+ let originalMessage = "Hi!";
+ let pMsg = new Message(
+ "buddy",
+ originalMessage,
+ {
+ outgoing: true,
+ _alias: "buddy",
+ time: Date.now(),
+ },
+ null
+ );
+ let iMsg = new imMessage(pMsg);
+ equal(iMsg.message, originalMessage, "Expected the original message.");
+ // Setting the message should prevent a fallback to the original.
+ iMsg.message = "";
+ equal(
+ iMsg.message,
+ "",
+ "Expected an empty string; not the original message."
+ );
+ equal(
+ iMsg.originalMessage,
+ originalMessage,
+ "Expected the original message."
+ );
+};
+
+// ROT13, used as an example transformation.
+function rot13(aString) {
+ return aString.replace(/[a-zA-Z]/g, function (c) {
+ return String.fromCharCode(
+ c.charCodeAt(0) + (c.toLowerCase() < "n" ? 1 : -1) * 13
+ );
+ });
+}
+
+// A test that exercises the message transformation pipeline.
+//
+// From the sending users perspective, this looks like:
+// -> protocol sendMsg
+// -> protocol notifyObservers `preparing-message`
+// -> protocol prepareForSending
+// -> protocol notifyObservers `sending-message`
+// -> protocol dispatchMessage (jsProtoHelper specific)
+// -> protocol writeMessage
+// -> protocol notifyObservers `new-text`
+// -> UIConv notifyObservers `received-message`
+// -> protocol prepareForDisplaying
+// -> UIConv notifyObservers `new-text`
+//
+// From the receiving users perspective, they get:
+// -> protocol writeMessage
+// -> protocol notifyObservers `new-text`
+// -> UIConv notifyObservers `received-message`
+// -> protocol prepareForDisplaying
+// -> UIConv notifyObservers `new-text`
+//
+// The test walks the sending path, which covers both.
+add_task(function test_message_transformation() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ this.writeMessage("user", aMsg, { outgoing: true });
+ };
+
+ let message = "Hello!";
+ let receivedMsg = false,
+ newTxt = false;
+
+ let uiConv = new UIConversation(conv);
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "sending-message":
+ ok(!newTxt, "sending-message should fire before new-text.");
+ ok(
+ !receivedMsg,
+ "sending-message should fire before received-message."
+ );
+ ok(
+ aObject.QueryInterface(Ci.imIOutgoingMessage),
+ "Wrong message type."
+ );
+ aObject.message = rot13(aObject.message);
+ break;
+ case "received-message":
+ ok(!newTxt, "received-message should fire before new-text.");
+ ok(
+ !receivedMsg,
+ "Sanity check that receive-message hasn't fired yet."
+ );
+ ok(aObject.outgoing, "Expected an outgoing message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ rot13(message),
+ "Expected to have been rotated while sending-message."
+ );
+ aObject.displayMessage = rot13(aObject.displayMessage);
+ receivedMsg = true;
+ break;
+ case "new-text":
+ ok(!newTxt, "Sanity check that new-text hasn't fired yet.");
+ ok(receivedMsg, "Expected received-message to have fired.");
+ ok(aObject.outgoing, "Expected an outgoing message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ message,
+ "Expected to have been rotated back to msg in received-message."
+ );
+ newTxt = true;
+ break;
+ }
+ },
+ });
+
+ uiConv.sendMsg(message);
+ ok(newTxt, "Expected new-text to have fired.");
+});
+
+// A test that cancels a message before it gets displayed.
+add_task(function test_cancel_display_message() {
+ let conv = new Conversation();
+ conv.dispatchMessage = function (aMsg) {
+ this.writeMessage("user", aMsg, { outgoing: true });
+ };
+
+ let received = false;
+ let uiConv = new UIConversation(conv);
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "received-message":
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ aObject.cancelled = true;
+ received = true;
+ break;
+ case "new-text":
+ ok(false, "Should not fire for a cancelled message.");
+ break;
+ }
+ },
+ });
+
+ uiConv.sendMsg("Hi!");
+ ok(received, "The received-message notification was never fired.");
+});
+
+var test_update_message = function () {
+ let conv = new Conversation();
+
+ let uiConv = new UIConversation(conv);
+ let message = "Hello!";
+ let receivedMsg = false;
+ let updateText = false;
+
+ uiConv.addObserver({
+ observe(aObject, aTopic, aMsg) {
+ switch (aTopic) {
+ case "received-message":
+ ok(!updateText, "received-message should fire before update-text.");
+ ok(
+ !receivedMsg,
+ "Sanity check that receive-message hasn't fired yet."
+ );
+ ok(aObject.incoming, "Expected an incoming message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(aObject.displayMessage, message, "Wrong message contents");
+ aObject.displayMessage = rot13(aObject.displayMessage);
+ receivedMsg = true;
+ break;
+ case "update-text":
+ ok(!updateText, "Sanity check that update-text hasn't fired yet.");
+ ok(receivedMsg, "Expected received-message to have fired.");
+ ok(aObject.incoming, "Expected an incoming message.");
+ ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type.");
+ equal(
+ aObject.displayMessage,
+ rot13(message),
+ "Expected to have been rotated in received-message."
+ );
+ updateText = true;
+ break;
+ }
+ },
+ });
+
+ conv.updateMessage("user", message, { incoming: true, remoteId: "foo" });
+ ok(updateText, "Expected update-text to have fired.");
+};
+
+add_task(test_null_message);
+add_task(test_update_message);
diff --git a/comm/chat/components/src/test/test_init.js b/comm/chat/components/src/test/test_init.js
new file mode 100644
index 0000000000..48f064027f
--- /dev/null
+++ b/comm/chat/components/src/test/test_init.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+// Modules that should only be loaded once a chat account exists.
+var ACCOUNT_MODULES = new Set([
+ "resource:///modules/matrixAccount.sys.mjs",
+ "resource:///modules/matrix-sdk.sys.mjs",
+ "resource:///modules/ircAccount.sys.mjs",
+ "resource:///modules/ircHandlers.sys.mjs",
+ "resource:///modules/xmpp-base.sys.mjs",
+ "resource:///modules/xmpp-session.sys.mjs",
+]);
+
+add_task(function test_coreInitLoadedModules() {
+ do_get_profile();
+ // Make sure protocols are all loaded.
+ IMServices.core.init();
+ IMServices.core.getProtocols();
+
+ for (const module of ACCOUNT_MODULES) {
+ ok(!Cu.isESModuleLoaded(module), `${module} should be loaded later`);
+ }
+});
diff --git a/comm/chat/components/src/test/test_logger.js b/comm/chat/components/src/test/test_logger.js
new file mode 100644
index 0000000000..be93d8b300
--- /dev/null
+++ b/comm/chat/components/src/test/test_logger.js
@@ -0,0 +1,860 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+do_get_profile();
+
+var { IMServices } = ChromeUtils.importESModule(
+ "resource:///modules/IMServices.sys.mjs"
+);
+
+const {
+ Logger,
+ gFilePromises,
+ gPendingCleanup,
+ queueFileOperation,
+ getLogFolderPathForAccount,
+ encodeName,
+ getLogFilePathForConversation,
+ getNewLogFileName,
+ appendToFile,
+ getLogWriter,
+ closeLogWriter,
+} = ChromeUtils.importESModule("resource:///modules/logger.sys.mjs");
+
+var logDirPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "logs"
+);
+
+var dummyAccount = {
+ name: "dummy-account",
+ normalizedName: "dummyaccount",
+ protocol: {
+ normalizedName: "dummy",
+ id: "prpl-dummy",
+ },
+};
+
+var dummyConv = {
+ account: dummyAccount,
+ id: 0,
+ title: "dummy conv",
+ normalizedName: "dummyconv",
+ get name() {
+ return this.normalizedName;
+ },
+ get startDate() {
+ return new Date(2011, 5, 28).valueOf() * 1000;
+ },
+ isChat: false,
+};
+
+// A day after the first one.
+var dummyConv2 = {
+ account: dummyAccount,
+ id: 0,
+ title: "dummy conv",
+ normalizedName: "dummyconv",
+ get name() {
+ return this.normalizedName;
+ },
+ get startDate() {
+ return new Date(2011, 5, 29).valueOf() * 1000;
+ },
+ isChat: false,
+};
+
+var dummyMUC = {
+ account: dummyAccount,
+ id: 1,
+ title: "Dummy MUC",
+ normalizedName: "dummymuc",
+ get name() {
+ return this.normalizedName;
+ },
+ startDate: new Date(2011, 5, 28).valueOf() * 1000,
+ isChat: true,
+};
+
+var encodeName_input = [
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ "COM3",
+ "LPT5",
+ "file",
+ "file.",
+ "file ",
+ "file_",
+ "file<",
+ "file>",
+ "file:",
+ 'file"',
+ "file/",
+ "file\\",
+ "file|",
+ "file?",
+ "file*",
+ "file&",
+ "file%",
+ "fi<le",
+ "fi>le",
+ "fi:le",
+ 'fi"le',
+ "fi/le",
+ "fi\\le",
+ "fi|le",
+ "fi?le",
+ "fi*le",
+ "fi&le",
+ "fi%le",
+ "<file",
+ ">file",
+ ":file",
+ '"file',
+ "/file",
+ "\\file",
+ "|file",
+ "?file",
+ "*file",
+ "&file",
+ "%file",
+ "\\fi?*&%le<>",
+];
+
+var encodeName_output = [
+ "%CON",
+ "%PRN",
+ "%AUX",
+ "%NUL",
+ "%COM3",
+ "%LPT5",
+ "file",
+ "file._",
+ "file _",
+ "file__",
+ "file%3c",
+ "file%3e",
+ "file%3a",
+ "file%22",
+ "file%2f",
+ "file%5c",
+ "file%7c",
+ "file%3f",
+ "file%2a",
+ "file%26",
+ "file%25",
+ "fi%3cle",
+ "fi%3ele",
+ "fi%3ale",
+ "fi%22le",
+ "fi%2fle",
+ "fi%5cle",
+ "fi%7cle",
+ "fi%3fle",
+ "fi%2ale",
+ "fi%26le",
+ "fi%25le",
+ "%3cfile",
+ "%3efile",
+ "%3afile",
+ "%22file",
+ "%2ffile",
+ "%5cfile",
+ "%7cfile",
+ "%3ffile",
+ "%2afile",
+ "%26file",
+ "%25file",
+ "%5c" + "fi" + "%3f%2a%26%25" + "le" + "%3c%3e", // eslint-disable-line no-useless-concat
+];
+
+var test_queueFileOperation = async function () {
+ let dummyRejectedOperation = () => Promise.reject("Rejected!");
+ let dummyResolvedOperation = () => Promise.resolve("Resolved!");
+
+ // Immediately after calling qFO, "path1" should be mapped to p1.
+ // After yielding, the reference should be cleared from the map.
+ let p1 = queueFileOperation("path1", dummyResolvedOperation);
+ equal(gFilePromises.get("path1"), p1);
+ await p1;
+ ok(!gFilePromises.has("path1"));
+
+ // Repeat above test for a rejected promise.
+ let p2 = queueFileOperation("path2", dummyRejectedOperation);
+ equal(gFilePromises.get("path2"), p2);
+ // This should throw since p2 rejected. Drop the error.
+ await p2.then(
+ () => do_throw(),
+ () => {}
+ );
+ ok(!gFilePromises.has("path2"));
+
+ let onPromiseComplete = (aPromise, aHandler) => {
+ return aPromise.then(aHandler, aHandler);
+ };
+ let test_queueOrder = aOperation => {
+ let promise = queueFileOperation("queueOrderPath", aOperation);
+ let firstOperationComplete = false;
+ onPromiseComplete(promise, () => (firstOperationComplete = true));
+ return queueFileOperation("queueOrderPath", () => {
+ ok(firstOperationComplete);
+ });
+ };
+ // Test the queue order for rejected and resolved promises.
+ await test_queueOrder(dummyResolvedOperation);
+ await test_queueOrder(dummyRejectedOperation);
+};
+
+var test_getLogFolderPathForAccount = async function () {
+ let path = getLogFolderPathForAccount(dummyAccount);
+ equal(
+ PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ ),
+ path
+ );
+};
+
+// Tests the global function getLogFilePathForConversation in logger.js.
+var test_getLogFilePathForConversation = async function () {
+ let path = getLogFilePathForConversation(dummyConv);
+ let expectedPath = PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ encodeName(dummyConv.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ getNewLogFileName(dummyConv.startDate / 1000)
+ );
+ equal(path, expectedPath);
+};
+
+var test_getLogFilePathForMUC = async function () {
+ let path = getLogFilePathForConversation(dummyMUC);
+ let expectedPath = PathUtils.join(
+ logDirPath,
+ dummyAccount.protocol.normalizedName,
+ encodeName(dummyAccount.normalizedName)
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ encodeName(dummyMUC.normalizedName + ".chat")
+ );
+ expectedPath = PathUtils.join(
+ expectedPath,
+ getNewLogFileName(dummyMUC.startDate / 1000)
+ );
+ equal(path, expectedPath);
+};
+
+var test_appendToFile = async function () {
+ const kStringToWrite = "Hello, world!";
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "testFile.txt"
+ );
+ await IOUtils.write(path, new Uint8Array());
+ appendToFile(path, kStringToWrite);
+ appendToFile(path, kStringToWrite);
+ ok(await queueFileOperation(path, () => IOUtils.exists(path)));
+ let text = await queueFileOperation(path, () => IOUtils.readUTF8(path));
+ // The read text should be equal to kStringToWrite repeated twice.
+ equal(text, kStringToWrite + kStringToWrite);
+ await IOUtils.remove(path);
+};
+
+add_task(async function test_appendToFileHeader() {
+ const kStringToWrite = "Lorem ipsum";
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "headerTestFile.txt"
+ );
+ await appendToFile(path, kStringToWrite, true);
+ await appendToFile(path, kStringToWrite, true);
+ let text = await queueFileOperation(path, () => IOUtils.readUTF8(path));
+ // The read text should be equal to kStringToWrite once, since the second
+ // create should just noop.
+ equal(text, kStringToWrite);
+ await IOUtils.remove(path);
+});
+
+// Tests the getLogPathsForConversation API defined in the imILogger interface.
+var test_getLogPathsForConversation = async function () {
+ let logger = new Logger();
+ let paths = await logger.getLogPathsForConversation(dummyConv);
+ // The path should be null since a LogWriter hasn't been created yet.
+ equal(paths, null);
+ let logWriter = getLogWriter(dummyConv);
+ paths = await logger.getLogPathsForConversation(dummyConv);
+ equal(paths.length, 1);
+ equal(paths[0], logWriter.currentPath);
+ ok(await IOUtils.exists(paths[0]));
+ // Ensure this doesn't interfere with future tests.
+ await IOUtils.remove(paths[0]);
+ closeLogWriter(dummyConv);
+};
+
+var test_logging = async function () {
+ let logger = new Logger();
+ let oneSec = 1000000; // Microseconds.
+
+ // Creates a set of dummy messages for a conv (sets appropriate times).
+ let getMsgsForConv = function (aConv) {
+ // Convert to seconds because that's what logMessage expects.
+ let startTime = Math.round(aConv.startDate / oneSec);
+ return [
+ {
+ time: startTime + 1,
+ who: "personA",
+ displayMessage: "Hi!",
+ outgoing: true,
+ },
+ {
+ time: startTime + 2,
+ who: "personB",
+ displayMessage: "Hello!",
+ incoming: true,
+ },
+ {
+ time: startTime + 3,
+ who: "personA",
+ displayMessage: "What's up?",
+ outgoing: true,
+ },
+ {
+ time: startTime + 4,
+ who: "personB",
+ displayMessage: "Nothing much!",
+ incoming: true,
+ },
+ {
+ time: startTime + 5,
+ who: "personB",
+ displayMessage: "Encrypted msg",
+ remoteId: "identifier",
+ incoming: true,
+ isEncrypted: true,
+ },
+ {
+ time: startTime + 6,
+ who: "personA",
+ displayMessage: "Deleted",
+ remoteId: "otherID",
+ outgoing: true,
+ isEncrypted: true,
+ deleted: true,
+ },
+ ];
+ };
+ let firstDayMsgs = getMsgsForConv(dummyConv);
+ let secondDayMsgs = getMsgsForConv(dummyConv2);
+
+ let logMessagesForConv = async function (aConv, aMessages) {
+ let logWriter = getLogWriter(aConv);
+ for (let message of aMessages) {
+ logWriter.logMessage(message);
+ }
+ // If we don't wait for the messages to get written, we have no guarantee
+ // later in the test that the log files were created, and getConversation
+ // will return an EmptyEnumerator. Logging the messages is queued on the
+ // _initialized promise, so we need to await on that first.
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ // Ensure two different files for the different dates.
+ closeLogWriter(aConv);
+ };
+ await logMessagesForConv(dummyConv, firstDayMsgs);
+ await logMessagesForConv(dummyConv2, secondDayMsgs);
+
+ // Write a zero-length file and a file with incorrect JSON for each day
+ // to ensure they are handled correctly.
+ let logDir = PathUtils.parent(getLogFilePathForConversation(dummyConv));
+ let createBadFiles = async function (aConv) {
+ let blankFile = PathUtils.join(
+ logDir,
+ getNewLogFileName((aConv.startDate + oneSec) / 1000)
+ );
+ let invalidJSONFile = PathUtils.join(
+ logDir,
+ getNewLogFileName((aConv.startDate + 2 * oneSec) / 1000)
+ );
+ await IOUtils.write(blankFile, new Uint8Array());
+ await IOUtils.writeUTF8(invalidJSONFile, "This isn't JSON!");
+ };
+ await createBadFiles(dummyConv);
+ await createBadFiles(dummyConv2);
+
+ let testMsgs = function (aMsgs, aExpectedMsgs, aExpectedSessions) {
+ // Ensure the number of session messages is correct.
+ let sessions = aMsgs.filter(aMsg => aMsg.who == "sessionstart").length;
+ equal(sessions, aExpectedSessions);
+
+ // Discard session messages, etc.
+ aMsgs = aMsgs.filter(aMsg => !aMsg.noLog);
+
+ equal(aMsgs.length, aExpectedMsgs.length);
+
+ for (let i = 0; i < aMsgs.length; ++i) {
+ let message = aMsgs[i],
+ expectedMessage = aExpectedMsgs[i];
+ for (let prop in expectedMessage) {
+ ok(prop in message);
+ equal(expectedMessage[prop], message[prop]);
+ }
+ }
+ };
+
+ // Accepts time in seconds, reduces it to a date, and returns the value in millis.
+ let reduceTimeToDate = function (aTime) {
+ let date = new Date(aTime * 1000);
+ date.setHours(0);
+ date.setMinutes(0);
+ date.setSeconds(0);
+ return date.valueOf();
+ };
+
+ // Group expected messages by day.
+ let messagesByDay = new Map();
+ messagesByDay.set(
+ reduceTimeToDate(firstDayMsgs[0].time),
+ firstDayMsgs.filter(msg => !msg.deleted)
+ );
+ messagesByDay.set(
+ reduceTimeToDate(secondDayMsgs[0].time),
+ secondDayMsgs.filter(msg => !msg.deleted)
+ );
+
+ let logs = await logger.getLogsForConversation(dummyConv);
+ for (let log of logs) {
+ let conv = await log.getConversation();
+ let date = reduceTimeToDate(log.time);
+ // 3 session messages - for daily logs, bad files are included.
+ testMsgs(conv.getMessages(), messagesByDay.get(date), 3);
+ }
+
+ // Remove the created log files, testing forEach in the process.
+ await logger.forEach({
+ async processLog(aLog) {
+ let info = await IOUtils.stat(aLog);
+ notEqual(info.type, "directory");
+ ok(aLog.endsWith(".json"));
+ await IOUtils.remove(aLog);
+ },
+ });
+ let logFolder = PathUtils.parent(getLogFilePathForConversation(dummyConv));
+ // The folder should now be empty - this will throw if it isn't.
+ await IOUtils.remove(logFolder, { ignoreAbsent: false });
+};
+
+var test_logFileSplitting = async function () {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logWriter = getLogWriter(dummyConv);
+ let startTime = logWriter._startTime / 1000; // Message times are in seconds.
+ let oldPath = logWriter.currentPath;
+ let message = {
+ time: startTime,
+ who: "John Doe",
+ originalMessage: "Hello, world!",
+ outgoing: true,
+ };
+
+ let logMessage = async function (aMessage) {
+ logWriter.logMessage(aMessage);
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ };
+
+ await logMessage(message);
+ message.time += logWriter.kInactivityLimit / 1000 + 1;
+ // This should go in a new log file.
+ await logMessage(message);
+ notEqual(logWriter.currentPath, oldPath);
+ // The log writer's new start time should be the time of the message.
+ equal(message.time * 1000, logWriter._startTime);
+
+ let getCurrentHeader = async function () {
+ return JSON.parse(
+ (await IOUtils.readUTF8(logWriter.currentPath)).split("\n")[0]
+ );
+ };
+
+ // The header of the new log file should not have the continuedSession flag set.
+ ok(!(await getCurrentHeader()).continuedSession);
+
+ // Set the start time sufficiently before midnight, and the last message time
+ // to just before midnight. A new log file should be created at midnight.
+ logWriter._startTime = new Date(logWriter._startTime).setHours(
+ 24,
+ 0,
+ 0,
+ -(logWriter.kDayOverlapLimit + 1)
+ );
+ let nearlyMidnight = new Date(logWriter._startTime).setHours(24, 0, 0, -1);
+ oldPath = logWriter.currentPath;
+ logWriter._lastMessageTime = nearlyMidnight;
+ message.time = new Date(nearlyMidnight).setHours(24, 0, 0, 1) / 1000;
+ await logMessage(message);
+ // The message should have gone in a new file.
+ notEqual(oldPath, logWriter.currentPath);
+ // The header should have the continuedSession flag set this time.
+ ok((await getCurrentHeader()).continuedSession);
+
+ // Ensure a new file is created every kMessageCountLimit messages.
+ oldPath = logWriter.currentPath;
+ let messageCountLimit = logWriter.kMessageCountLimit;
+ for (let i = 0; i < messageCountLimit; ++i) {
+ logMessage(message);
+ }
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ // The header should have the continuedSession flag set this time too.
+ ok((await getCurrentHeader()).continuedSession);
+ // Again, to make sure it still works correctly after splitting it once already.
+ oldPath = logWriter.currentPath;
+ // We already logged one message to ensure it went into a new file, so i = 1.
+ for (let i = 1; i < messageCountLimit; ++i) {
+ logMessage(message);
+ }
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok((await getCurrentHeader()).continuedSession);
+
+ // The new start time is the time of the message. If we log sufficiently more
+ // messages with the same time property, ensure that the start time of the next
+ // log file is greater than the previous one, and that a new path is being used.
+ let oldStartTime = logWriter._startTime;
+ oldPath = logWriter.currentPath;
+ logWriter._messageCount = messageCountLimit;
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok(logWriter._startTime > oldStartTime);
+
+ // Do it again with the same message.
+ oldStartTime = logWriter._startTime;
+ oldPath = logWriter.currentPath;
+ logWriter._messageCount = messageCountLimit;
+ await logMessage(message);
+ notEqual(oldPath, logWriter.currentPath);
+ ok(logWriter._startTime > oldStartTime);
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ closeLogWriter(dummyConv);
+};
+
+add_task(async function test_logWithEdits() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:48.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:51.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:53.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "delayed", "isEncrypted"],
+ remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Lorem ipsum dolor sit amet",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:53.000Z",
+ who: "@other:example.com",
+ text: "consectetur adipiscing elit",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:51.000Z",
+ who: "@other:example.com",
+ text: "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ",
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:48.000Z",
+ who: "@other:example.com",
+ text: "Ut enim ad minim veniam",
+ flags: ["incoming", "isEncrypted"],
+ remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk",
+ alias: "other",
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ const conv = await logs[0].getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 5);
+ for (const msg of messages) {
+ if (msg.who !== "sessionstart") {
+ notEqual(msg.displayMessage, "Decrypting...");
+ }
+ }
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+// Ensure that any message with a remoteId that has a deleted flag in the
+// latest version is not visible in logs.
+add_task(async function test_logWithDeletedMessages() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ const remoteId = "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM";
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Decrypting...",
+ flags: ["incoming", "isEncrypted"],
+ remoteId,
+ alias: "other",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "Message was redacted.",
+ flags: ["incoming", "isEncrypted", "deleted"],
+ remoteId,
+ alias: "other",
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ const conv = await logs[0].getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 1);
+ equal(messages[0].who, "sessionstart");
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+add_task(async function test_logDeletedMessageCleanup() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logWriter = getLogWriter(dummyConv);
+ let remoteId = "testId";
+
+ let logMessage = async function (aMessage) {
+ logWriter.logMessage(aMessage);
+ await logWriter._initialized;
+ await gFilePromises.get(logWriter.currentPath);
+ };
+
+ await logMessage({
+ time: Math.floor(dummyConv.startDate / 1000000) + 10,
+ who: "test",
+ displayMessage: "delete me",
+ remoteId,
+ incoming: true,
+ });
+
+ await logMessage({
+ time: Math.floor(dummyConv.startDate / 1000000) + 20,
+ who: "test",
+ displayMessage: "Message is deleted",
+ remoteId,
+ deleted: true,
+ incoming: true,
+ });
+ ok(gPendingCleanup.has(logWriter.currentPath));
+ equal(
+ Services.prefs.getStringPref("chat.logging.cleanup.pending"),
+ JSON.stringify([logWriter.currentPath])
+ );
+
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ await (gFilePromises.get(logWriter.currentPath) || Promise.resolve());
+
+ ok(!gPendingCleanup.has(logWriter.currentPath));
+ equal(Services.prefs.getStringPref("chat.logging.cleanup.pending"), "[]");
+
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1, "Only a single log file for this conversation");
+ let conv = await logs[0].getConversation();
+ let messages = conv.getMessages();
+ equal(messages.length, 1, "Only the log header is left");
+ equal(messages[0].who, "sessionstart");
+
+ // Check that the message contents were removed from the file on disk. The
+ // log parser above removes it either way.
+ let logOnDisk = await IOUtils.readUTF8(logWriter.currentPath);
+ let rawMessages = logOnDisk
+ .split("\n")
+ .filter(Boolean)
+ .map(line => JSON.parse(line));
+ equal(rawMessages.length, 3);
+ equal(rawMessages[1].text, "", "Deleted message content was removed");
+ equal(
+ rawMessages[2].text,
+ "Message is deleted",
+ "Deletion content is unaffected"
+ );
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+
+ closeLogWriter(dummyConv);
+});
+
+add_task(async function test_displayOldActionLog() {
+ // Start clean, remove the log directory.
+ await IOUtils.remove(logDirPath, { recursive: true });
+ let logger = new Logger();
+ let logFilePath = getLogFilePathForConversation(dummyConv);
+ await IOUtils.writeUTF8(
+ logFilePath,
+ [
+ {
+ date: "2022-03-04T12:00:03.508Z",
+ name: "test",
+ title: "test",
+ account: "@test:example.com",
+ protocol: "matrix",
+ isChat: false,
+ normalizedName: "!foobar:example.com",
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "/me an old action",
+ flags: ["incoming"],
+ },
+ {
+ date: "2022-03-04T11:59:56.000Z",
+ who: "@other:example.com",
+ text: "a new action",
+ flags: ["incoming", "action"],
+ },
+ ]
+ .map(message => JSON.stringify(message))
+ .join("\n"),
+ {
+ mode: "create",
+ }
+ );
+ let logs = await logger.getLogsForConversation(dummyConv);
+ equal(logs.length, 1);
+ for (let log of logs) {
+ const conv = await log.getConversation();
+ const messages = conv.getMessages();
+ equal(messages.length, 3);
+ for (let message of messages) {
+ if (message.who !== "sessionstart") {
+ ok(message.action, "Message is marked as action");
+ ok(
+ !message.displayMessage.startsWith("/me"),
+ "Message has no leading /me"
+ );
+ }
+ }
+ }
+
+ // Clean up.
+ await IOUtils.remove(logDirPath, { recursive: true });
+});
+
+add_task(function test_encodeName() {
+ // Test encodeName().
+ for (let i = 0; i < encodeName_input.length; ++i) {
+ equal(encodeName(encodeName_input[i]), encodeName_output[i]);
+ }
+});
+
+add_task(test_getLogFolderPathForAccount);
+
+add_task(test_getLogFilePathForConversation);
+
+add_task(test_getLogFilePathForMUC);
+
+add_task(test_queueFileOperation);
+
+add_task(test_appendToFile);
+
+add_task(test_getLogPathsForConversation);
+
+add_task(test_logging);
+
+add_task(test_logFileSplitting);
diff --git a/comm/chat/components/src/test/xpcshell.ini b/comm/chat/components/src/test/xpcshell.ini
new file mode 100644
index 0000000000..63cce6e7e1
--- /dev/null
+++ b/comm/chat/components/src/test/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head =
+tail =
+
+[test_accounts.js]
+[test_commands.js]
+[test_conversations.js]
+[test_init.js]
+[test_logger.js]