diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat/components | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/chat/components')
31 files changed, 9108 insertions, 0 deletions
diff --git a/comm/chat/components/public/imIAccount.idl b/comm/chat/components/public/imIAccount.idl new file mode 100644 index 0000000000..0fcf210d1c --- /dev/null +++ b/comm/chat/components/public/imIAccount.idl @@ -0,0 +1,331 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "prplIConversation.idl" +#include "imIUserStatusInfo.idl" + +interface imITag; +interface imIBuddy; +interface prplIAccountBuddy; +interface imIAccount; +interface prplIAccount; +interface prplIProtocol; +interface nsIScriptError; +interface nsITransportSecurityInfo; + +/* + * Used to join chat rooms. + */ + +[scriptable, uuid(7e91accd-f04c-4787-9954-c7db4fb235fb)] +interface prplIChatRoomFieldValues: nsISupports { + AUTF8String getValue(in AUTF8String aIdentifier); + void setValue(in AUTF8String aIdentifier, in AUTF8String aValue); +}; + +[scriptable, uuid(19dff981-b125-4a70-bc1a-efc783d07137)] +interface prplIChatRoomField: nsISupports { + readonly attribute AUTF8String label; + readonly attribute AUTF8String identifier; + readonly attribute boolean required; + + const short TYPE_TEXT = 0; + const short TYPE_PASSWORD = 1; + const short TYPE_INT = 2; + + readonly attribute short type; + readonly attribute long min; + readonly attribute long max; +}; + +/* + * Information about a chat room and the fields required to join it. + */ +[scriptable, uuid(017d5951-fdd0-4f26-b697-fcc138cd2861)] +interface prplIRoomInfo: nsISupports { + readonly attribute AUTF8String name; + readonly attribute AUTF8String topic; + + const long NO_PARTICIPANT_COUNT = -1; + + readonly attribute long participantCount; + readonly attribute prplIChatRoomFieldValues chatRoomFieldValues; +}; + +/* + * Callback passed to an account's requestRoomInfo function. + */ +[scriptable, function, uuid(43102a36-883a-421d-a6ac-126aafee5a28)] +interface prplIRoomInfoCallback: nsISupports { + /* aRooms is an array of chatroom names. This will be called + * multiple times as batches of chat rooms are received. The number of rooms + * in each batch is left for the prplIAccount implementation to decide. + * aCompleted will be true when aRooms is the last batch. + */ + void onRoomInfoAvailable(in Array<AString> aRooms, in boolean aCompleted); +}; + +/** + * Encryption session of the prplIAccount. Usually every logged in device that + * can encrypt will have its own session. + */ +[scriptable, uuid(0254d011-44b3-40a1-8589-d2fd4a18a421)] +interface prplISession: nsISupports { + /** ID of this session as displayed to the user. */ + readonly attribute AUTF8String id; + /** Whether this session is trusted. */ + readonly attribute boolean trusted; + /** Indicates that this is the session we're currently using */ + readonly attribute boolean currentSession; + /** + * Verify the identity of this session. + * + * @returns {Promise<imISessionVerification>} + */ + Promise verify(); +}; + +/* + * This interface should be implemented by the protocol plugin. + */ +[scriptable, uuid(3ce02a3c-f38b-4a1e-9050-a19bea1cb6c1)] +interface prplIAccount: nsISupports { + readonly attribute imIAccount imAccount; + + // observe should only be called by the imIAccount + // implementation to report user status changes that affect this account. + void observe(in nsISupports aObj, in string aEvent, + [optional] in wstring aData); + + // This should only be called by the imIAccountsService + // implementation, never directly. It will call + // imIContactsService.accountBuddyRemoved on each buddy of the + // account and close all prplIConversation instances of the account. + void remove(); + + /* Uninitialize the prplIAccount instance. This is typically done + automatically at shutdown (by the core service) or as part of + the 'remove' method. */ + void unInit(); + + void connect(); + void disconnect(); + + prplIConversation createConversation(in AUTF8String aName); + + // Used when the user wants to add a buddy to the buddy list + void addBuddy(in imITag aTag, in AUTF8String aName); + + // Used while loading the buddy list at startup. + prplIAccountBuddy loadBuddy(in imIBuddy aBuddy, in imITag aTag); + + /* Request more info on a buddy (typically a chat buddy). + * The result (if any) will be provided by user-info-received + * notifications dispatched through the observer service: + * - aSubject will be an nsISimpleEnumerator of prplITooltipInfo. + * - aData will be aBuddyName. + * If multiple user-info-received are sent, subsequent notifications + * will update any previous data. + */ + void requestBuddyInfo(in AUTF8String aBuddyName); + + readonly attribute boolean canJoinChat; + Array<prplIChatRoomField> getChatRoomFields(); + prplIChatRoomFieldValues getChatRoomDefaultFieldValues([optional] in AUTF8String aDefaultChatName); + + /* Request information on available chat rooms, whose names are returned + * via the callback. + */ + void requestRoomInfo(in prplIRoomInfoCallback aCallback); + prplIRoomInfo getRoomInfo(in AUTF8String aRoomName); + readonly attribute boolean isRoomInfoStale; + + /* + * Create a new chat conversation if it doesn't already exist. + */ + void joinChat(in prplIChatRoomFieldValues aComponents); + + // A name that can be used to check for duplicates and is the basis + // for the directory name for log storage. + readonly attribute AUTF8String normalizedName; + // Request that the account normalizes a name. Use this only when an object + // providing a normalizedName doesn't exist yet or isn't accessible. + AUTF8String normalize(in AUTF8String aName); + + // protocol specific options: those functions set the protocol + // specific options for the prplIAccount + void setBool(in string aName, in boolean aVal); + void setInt(in string aName, in long aVal); + void setString(in string aName, in AUTF8String aVal); + + /* When a connection error occurred, this value indicates the type of error */ + readonly attribute short connectionErrorReason; + + /** + * When a certificate error occurs, the host/port that caused a + * SSL/certificate error when connecting to it. This is only valid when + * connectionErrorReason is one of ERROR_CERT_* + */ + readonly attribute AUTF8String connectionTarget; + /** + * When a certificate error occurs, the nsITransportSecurityInfo error of + * the socket. This should only be set when connectionTarget is set. + */ + readonly attribute nsITransportSecurityInfo securityInfo; + + /* Possible connection error reasons: + ERROR_NETWORK_ERROR and ERROR_ENCRYPTION_ERROR are not fatal and + should enable the automatic reconnection feature. */ + const short NO_ERROR = -1; + const short ERROR_NETWORK_ERROR = 0; + const short ERROR_INVALID_USERNAME = 1; + const short ERROR_AUTHENTICATION_FAILED = 2; + const short ERROR_AUTHENTICATION_IMPOSSIBLE = 3; + const short ERROR_NO_SSL_SUPPORT = 4; + const short ERROR_ENCRYPTION_ERROR = 5; + const short ERROR_NAME_IN_USE = 6; + const short ERROR_INVALID_SETTINGS = 7; + const short ERROR_CERT_NOT_PROVIDED = 8; + const short ERROR_CERT_UNTRUSTED = 9; + const short ERROR_CERT_EXPIRED = 10; + const short ERROR_CERT_NOT_ACTIVATED = 11; + const short ERROR_CERT_HOSTNAME_MISMATCH = 12; + const short ERROR_CERT_FINGERPRINT_MISMATCH = 13; + const short ERROR_CERT_SELF_SIGNED = 14; + const short ERROR_CERT_OTHER_ERROR = 15; + const short ERROR_OTHER_ERROR = 16; + + /** + * Get a list of active encryption sessions for the account. + * The protocol sends a "account-sessions-changed" notification when + * the trust state of a session changes, or entries are added or removed. + */ + Array<prplISession> getSessions(); + + /** + * Information as to the state of encryption capabilities of this account. For + * example Matrix surfaces the secret storage, key backup and cross-signing + * status info here. + * The protocol sends a "account-encryption-status-changed" notification when + * this chanes. + */ + readonly attribute Array<AUTF8String> encryptionStatus; +}; + + +[scriptable, uuid(488959b4-992e-4626-ae96-beaf6adc4a77)] +interface imIDebugMessage: nsISupports { + const short LEVEL_DEBUG = 1; + const short LEVEL_LOG = 2; + const short LEVEL_WARNING = 3; + const short LEVEL_ERROR = 4; + readonly attribute short logLevel; // One of the above constants. + readonly attribute nsIScriptError message; +}; + +/* This interface should be implemented by the im core. It inherits +from prplIAccount and in most cases will forward the calls for the +inherited members to a prplIAccount account instance implemented by +the protocol plugin. */ +[scriptable, uuid(20a85b44-e220-4f23-85bf-f8523d1a2b08)] +interface imIAccount: prplIAccount { + /* Check if autologin is enabled for this account, connect it now. */ + void checkAutoLogin(); + + /* Cancel the timer that automatically reconnects the account if it was + disconnected because of a non fatal error. */ + void cancelReconnection(); + + readonly attribute AUTF8String name; + readonly attribute AUTF8String id; + readonly attribute unsigned long numericId; + readonly attribute prplIProtocol protocol; + readonly attribute prplIAccount prplAccount; + + // Save account specific preferences to disk. + void save(); + + attribute boolean autoLogin; + + /* This is the value when the preference firstConnectionState is not set. + It indicates that the account has already been successfully connected at + least once with the current parameters. */ + const short FIRST_CONNECTION_OK = 0; + /* Set when the account has never had a successful connection + with the current parameters */ + const short FIRST_CONNECTION_UNKNOWN = 1; + /* Set when the account is trying to connect for the first time + with the current parameters (removed after a successsful connection) */ + const short FIRST_CONNECTION_PENDING = 2; + /* Set at startup when the previous state was pending */ + const short FIRST_CONNECTION_CRASHED = 4; + + attribute short firstConnectionState; + + /* Passwords are stored in the toolkit Password Manager. + * Warning: Don't attempt to access passwords during startup before + * Services.login.initializationPromise has resolved. + */ + attribute AUTF8String password; + + attribute AUTF8String alias; + + /* While an account is connecting, this attribute contains a message + indicating the current step of the connection */ + readonly attribute AUTF8String connectionStateMsg; + + /* Number of the reconnection attempt + * 0 means that no automatic reconnection currently pending + * n means the nth reconnection attempt is pending + */ + readonly attribute unsigned short reconnectAttempt; + + /* Time stamp of the next reconnection attempt */ + readonly attribute long long timeOfNextReconnect; + + /* Time stamp of the last connection (value not reliable if not connected) */ + readonly attribute long long timeOfLastConnect; + + /* Additional possible connection error reasons: + * (Use a big enough number that it can't conflict with error + * codes used in prplIAccount). + */ + const short ERROR_UNKNOWN_PRPL = 42; + const short ERROR_CRASHED = 43; + const short ERROR_MISSING_PASSWORD = 44; + + /* A message describing the connection error */ + readonly attribute AUTF8String connectionErrorMessage; + + /* Info about the connection state and flags */ + const short STATE_DISCONNECTED = 0; + const short STATE_CONNECTED = 1; + const short STATE_CONNECTING = 2; + const short STATE_DISCONNECTING = 3; + + readonly attribute short connectionState; + + /* The following 4 properties use the above connectionState value. */ + readonly attribute boolean disconnected; + readonly attribute boolean connected; + readonly attribute boolean connecting; + readonly attribute boolean disconnecting; + + void logDebugMessage(in nsIScriptError aMessage, in short aLevel); + + /* Get an array of the 50 most recent debug messages. */ + Array<imIDebugMessage> getDebugMessages(); + + /* The imIUserStatusInfo instance this account should observe for + status changes. When this is null (the default value), the + account will observe the global status. */ + attribute imIUserStatusInfo observedStatusInfo; + // Same as above, but never null (it fallbacks to the global status info). + attribute imIUserStatusInfo statusInfo; + + // imIAccount also implements an observe method but this + // observe should only be called by the prplIAccount + // implementations to report connection status changes. +}; diff --git a/comm/chat/components/public/imIAccountsService.idl b/comm/chat/components/public/imIAccountsService.idl new file mode 100644 index 0000000000..38a2d52a12 --- /dev/null +++ b/comm/chat/components/public/imIAccountsService.idl @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "imIAccount.idl" + +[scriptable, uuid(b3b6459a-5c26-47b8-8e9c-ba838b6f632a)] +interface imIAccountsService: nsISupports { + void initAccounts(); + void unInitAccounts(); + + /* This attribute is set to AUTOLOGIN_ENABLED by default. It can be set to + any other value before the initialization of this service to prevent + accounts with autoLogin enabled from being connected when libpurple is + initialized. + Any value other than the ones listed below will disable autoLogin and + display a generic message in the Account Manager. */ + attribute short autoLoginStatus; + + const short AUTOLOGIN_ENABLED = 0; + const short AUTOLOGIN_USER_DISABLED = 1; + const short AUTOLOGIN_SAFE_MODE = 2; + const short AUTOLOGIN_CRASH = 3; + const short AUTOLOGIN_START_OFFLINE = 4; + + /* The method should be used to connect all accounts with autoLogin enabled. + Some use cases: + - if the autologin was disabled at startup + - after a loss of internet connectivity that disconnected all accounts. + */ + void processAutoLogin(); + + imIAccount getAccountById(in AUTF8String aAccountId); + + /* will throw NS_ERROR_FAILURE if not found */ + imIAccount getAccountByNumericId(in unsigned long aAccountId); + + Array<imIAccount> getAccounts(); + + /* will fire the event account-added */ + imIAccount createAccount(in AUTF8String aName, in AUTF8String aPrpl); + + /* will fire the event account-removed */ + void deleteAccount(in AUTF8String aAccountId); +}; + +/* + account related notifications sent to nsIObserverService: + - account-added: a new account has been created + - account-removed: the account has been deleted + - account-connecting: the account is being connected + - account-connected: the account is now connected + - account-connect-error: the account is disconnect with an error. + (before account-disconnecting) + - account-disconnecting: the account is being disconnected + - account-disconnected: the account is now disconnected + - account-updated: when some settings have changed + - account-list-updated: when the list of account is reordered. + These events can be watched using an nsIObserver. + The associated imIAccount will be given as a parameter + (except for account-list-updated). +*/ diff --git a/comm/chat/components/public/imICommandsService.idl b/comm/chat/components/public/imICommandsService.idl new file mode 100644 index 0000000000..9011a673b0 --- /dev/null +++ b/comm/chat/components/public/imICommandsService.idl @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +interface prplIConversation; + +[scriptable, uuid(b12b0d89-0e5b-499c-9567-37f2deacc182)] +interface imICommand: nsISupports { + readonly attribute AUTF8String name; + + // Help message displayed when the user types /help <name>. + // Format: <command name> <parameters>: <help message> + // Example: "help <name>: show the help message for the <name> + // command, or the list of possible commands when used without + // parameter." + readonly attribute AUTF8String helpString; + + const short CMD_CONTEXT_IM = 1; + const short CMD_CONTEXT_CHAT = 2; + const short CMD_CONTEXT_ALL = CMD_CONTEXT_IM | CMD_CONTEXT_CHAT; + readonly attribute long usageContext; + + const short CMD_PRIORITY_LOW = -1000; + const short CMD_PRIORITY_DEFAULT = 0; + const short CMD_PRIORITY_PRPL = 1000; + const short CMD_PRIORITY_HIGH = 4000; + // Any integer value is usable as a priority. + // 0 is the default priority. + // < 0 is lower priority. + // > 0 is higher priority. + // Commands registered by protocol plugins will usually use PRIORITY_PRPL. + readonly attribute long priority; + + // Will return true if the command handled the message (it should not be sent). + // The leading slash, the command name and the following space are not included + // in the aMessage parameter. + // If a conversation is returned as a result of executing the command, + // the caller should consider focusing it. + boolean run(in AUTF8String aMessage, + [optional] in prplIConversation aConversation, + [optional] out prplIConversation aReturnedConv); +}; + +[scriptable, uuid(9a1accfd-9bd8-4548-aef7-e8107fc7839f)] +interface imICommandsService: nsISupports { + void initCommands(); + void unInitCommands(); + + // Commands registered without a protocol id will work for all protocols. + // Registering several commands of the same name with the same + // protocol id or no protocol id will replace the former command + // with the latter. + void registerCommand(in imICommand aCommand, + [optional] in AUTF8String aPrplId); + + // aPrplId should be the same as what was used for the command registration. + void unregisterCommand(in AUTF8String aCommandName, + [optional] in AUTF8String aPrplId); + + Array<imICommand> listCommandsForConversation( + [optional] in prplIConversation aConversation); + + Array<imICommand> listCommandsForProtocol(in AUTF8String aPrplId); + + // Will return true if a command handled the message (it should not be sent). + // The aConversation parameters is required to execute protocol specific + // commands. Application global commands will work without it. + // If a conversation is returned as a result of executing the command, + // the caller should consider focusing it. + boolean executeCommand(in AUTF8String aMessage, + [optional] in prplIConversation aConversation, + [optional] out prplIConversation aReturnedConv); +}; + +%{ C++ +#define IM_COMMANDS_SERVICE_CONTRACTID \ + "@mozilla.org/chat/commands-service;1" +%} diff --git a/comm/chat/components/public/imIContactsService.idl b/comm/chat/components/public/imIContactsService.idl new file mode 100644 index 0000000000..d0f42dbac0 --- /dev/null +++ b/comm/chat/components/public/imIContactsService.idl @@ -0,0 +1,290 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "imIStatusInfo.idl" +#include "imITagsService.idl" +#include "nsISupports.idl" +#include "nsIObserver.idl" + +interface imIContact; +interface imIBuddy; +interface prplIAccountBuddy; +interface imIAccount; +interface prplIProtocol; + +[scriptable, uuid(45ce33d9-d335-4cce-b904-44821987e048)] +interface imIContactsService: nsISupports { + void initContacts(); + void unInitContacts(); + + imIContact getContactById(in long aId); + // Get an array of all existing contacts. + Array<imIContact> getContacts(); + imIBuddy getBuddyById(in long aId); + imIBuddy getBuddyByNameAndProtocol(in AUTF8String aNormalizedName, + in prplIProtocol aPrpl); + prplIAccountBuddy getAccountBuddyByNameAndAccount(in AUTF8String aNormalizedName, + in imIAccount aAccount); + + // These 3 functions are called by the protocol plugins when + // synchronizing the buddy list with the server stored list, + // or after user operations have been performed. + void accountBuddyAdded(in prplIAccountBuddy aAccountBuddy); + void accountBuddyRemoved(in prplIAccountBuddy aAccountBuddy); + void accountBuddyMoved(in prplIAccountBuddy aAccountBuddy, + in imITag aOldTag, in imITag aNewTag); + + // These methods are called by the imIAccountsService implementation + // to keep the accounts table in sync with accounts stored in the + // preferences. + + // Called when an account is created or loaded to store the new + // account or ensure it doesn't conflict with an existing account + // (to detect database corruption). + // Will throw if a stored account has the id aId but a different + // username or prplId. + void storeAccount(in unsigned long aId, in AUTF8String aUserName, + in AUTF8String aPrplId); + // Check if an account id already exists in the database. + boolean accountIdExists(in unsigned long aId); + // Called when deleting an account to remove it from blist.sqlite. + void forgetAccount(in unsigned long aId); +}; + +/** + * An imIContact represents a person, e.g. our friend Alice. This person might + * have multiple means of contacting them. + * + * Remember that an imIContact can have multiple buddies (imIBuddy instances), + * each imIBuddy can have multiple account-buddies (prplIAccountBuddy instances) + * referencing it. To be explicit, the difference is that an imIBuddy represents + * a contact's account on a network, while a prplIAccountBuddy represents the + * link between your account and your contact's account. + * + * Each of these implement imIStatusInfo: imIContact and imIBuddy should merge + * the status info based on the information available in their instances of + * imIBuddy and prplIAccountBuddy, respectively. + */ +[scriptable, uuid(f585b0df-f6ad-40d5-9de4-c58b14af13e4)] +interface imIContact: imIStatusInfo { + // The id will be positive if the contact is real (stored in the + // SQLite database) and negative if the instance is a dummy contact + // holding only a single buddy without aliases or additional tags. + readonly attribute long id; + attribute AUTF8String alias; + + Array<imITag> getTags(); + + // Will do nothing if the contact already has aTag. + void addTag(in imITag aTag); + // Will throw if the contact doesn't have aTag or doesn't have any other tag. + void removeTag(in imITag aTag); + + readonly attribute imIBuddy preferredBuddy; + Array<imIBuddy> getBuddies(); + + // Move all the buddies of aContact into the current contact, + // and copy all its tags. + void mergeContact(in imIContact aContact); + + // Change the position of aBuddy in the current contact. + // The new position is the current position of aBeforeBuddy if it is + // specified, or at the end otherwise. + void moveBuddyBefore(in imIBuddy aBuddy, [optional] in imIBuddy aBeforeBuddy); + + // Remove aBuddy from its current contact and append it to the list + // of buddies of the current contact. + // aBuddy should not already be attached to the current contact. + void adoptBuddy(in imIBuddy aBuddy); + + // Returns a new contact that contains only aBuddy, and has the same + // list of tags. + // Will throw if aBuddy is not a buddy of the contact. + imIContact detachBuddy(in imIBuddy aBuddy); + + // remove the contact from the buddy list. Will also remove the + // associated buddies. + void remove(); + + void addObserver(in nsIObserver aObserver); + void removeObserver(in nsIObserver aObserver); + /* Observers will be notified of changes related to the contact. + * aSubject will point to the imIContact object + * (with some exceptions for contact-moved-* notifications). + * + * Fired notifications: + * contact-availability-changed + * when either statusType or availabilityDetails has changed. + * contact-signed-on + * contact-signed-off + * contact-status-changed + * when either statusType or statusText has changed. + * contact-display-name-changed + * when the alias (or serverAlias of the most available buddy if + * no alias is set) has changed. + * The old display name is provided in aData. + * contact-preferred-buddy-changed + * The buddy that would be favored to start a conversation has changed. + * contact-moved, contact-moved-in, contact-moved-out + * contact-moved is notified through the observer service + * contact-moved-in is notified to + * - the contact observers (aSubject is the new tag) + * - the new tag (aSubject is the contact instance) + * contact-moved-out is notified to + * - the contact observers (aSubject is the old tag) + * - the old tag (aSubject is the contact instance) + * contact-no-longer-dummy + * When a real contact is created to replace a dummy contact. + * The old (negative) id will be given in aData. + * See also the comment above the 'id' attribute. + * contact-icon-changed + * + * Observers will also receive all the (forwarded) notifications + * from the linked buddies (imIBuddy instances) and their account + * buddies (prplIAccountBuddy instances). + */ + + // Exposed for add-on authors. All internal calls will come from the + // imIContact implementation itself so it wasn't required to expose this. + // This can be used to dispatch custom notifications to the + // observers of the contact and its tags. + // The notification will also be forwarded to the observer service. + void notifyObservers(in nsISupports aObj, in string aEvent, + [optional] in wstring aData); +}; + +/** + * An imIBuddy represents a person's account on a particular network. Note that + * what a network is depends on the implementation of the prpl, e.g. for AIM + * there is only a single network, but both GTalk and XMPP are the same network. + * + * E.g. Our contact Alice has two accounts on the Foo network: @lic4 and + * alice88; and she has a single account on the Bar network: _alice_. This would + * result in an imIBuddy instance for each of these: @lic4, alice88, and _alice_ + * that would all exist as part of the same imIContact. + */ +[scriptable, uuid(c56520ba-d923-4b95-8416-ca6733c4a38e)] +interface imIBuddy: imIStatusInfo { + readonly attribute long id; + readonly attribute prplIProtocol protocol; + readonly attribute AUTF8String userName; // may be formatted + // A name that can be used to check for duplicates and is the basis + // for the directory name for log storage. + readonly attribute AUTF8String normalizedName; + // The optional server alias is in displayName (inherited from imIStatusInfo) + // displayName = serverAlias || userName. + + readonly attribute imIContact contact; + readonly attribute prplIAccountBuddy preferredAccountBuddy; + Array<prplIAccountBuddy> getAccountBuddies(); + + // remove the buddy from the buddy list. If the contact becomes empty, it will be removed too. + void remove(); + + void addObserver(in nsIObserver aObserver); + void removeObserver(in nsIObserver aObserver); + /* Observers will be notified of changes related to the buddy. + * aSubject will point to the imIBuddy object. + * Fired notifications: + * buddy-availability-changed + * when either statusType or availabilityDetails has changed. + * buddy-signed-on + * buddy-signed-off + * buddy-status-changed + * when either statusType or statusText has changed. + * buddy-display-name-changed + * when the serverAlias has changed. + * The old display name is provided in aData. + * buddy-preferred-account-changed + * The account that would be favored to start a conversation has changed. + * buddy-icon-changed + * + * Observers will also receive all the (forwarded) notifications + * from the linked account buddies (prplIAccountBuddy instances). + */ + + // Exposed for add-on authors. All internal calls will come from the + // imIBuddy implementation itself so it wasn't required to expose this. + // This can be used to dispatch custom notifications to the + // observers of the buddy, its contact and its tags. + // The contact will forward the notifications to the observer service. + void notifyObservers(in nsISupports aObj, in string aEvent, + [optional] in wstring aData); + + // observe should only be called by the prplIAccountBuddy + // implementations to report changes. + void observe(in nsISupports aObj, in string aEvent, + [optional] in wstring aData); +}; + +/** + * A prplIAccountBuddy represents the connection on a network between one of the + * current user's accounts and a persons's account. E.g. if we're logged into + * the Foo network as BobbyBoy91 and want to talk to Alice, there may be two + * prplIAccountBuddy instances: @lic4 as seen by BobbyBoy91 or alice88 as seen + * by BobbyBoy91. Additionally, if we also login as 8ob, there could be @lic4 as + * seen by 8ob and alice88 as seen by 8ob; but these (now four) + * prplIAccountBuddy instances would link to only TWO imIBuddy instances (one + * each for @lic4 and alice88). Note that the above uses "may be" and "could" + * because it depends on whether the contacts are on the contact list (and + * therefore have imIContact / imIBuddy instances). + * + * prplIAccountBuddy implementations send notifications to their buddy: + * + * For all of them, aSubject points to the prplIAccountBuddy object. + * + * Supported notifications: + * account-buddy-availability-changed + * when either statusType or availabilityDetails has changed. + * account-buddy-signed-on + * account-buddy-signed-off + * account-buddy-status-changed + * when either statusType or statusText has changed. + * account-buddy-display-name-changed + * when the serverAlias has changed. + * The old display name is provided in aData. + * account-buddy-icon-changed + * + * All notifications (even unsupported ones) will be forwarded to the contact, + * its tags and nsObserverService. + */ +[scriptable, uuid(0c5021ac-7acd-4118-bf4f-c0dd9cb3ddef)] +interface prplIAccountBuddy: imIStatusInfo { + // The setter is for internal use only. buddy will be set by the + // Contacts service when accountBuddyAdded is called on this + // instance of prplIAccountBuddy. + attribute imIBuddy buddy; + readonly attribute imIAccount account; + // Setting the tag will move the buddy to a different group on the + // server-stored buddy list. + attribute imITag tag; + readonly attribute AUTF8String userName; + // A name that can be used to check for duplicates and is the basis + // for the directory name for log storage. + readonly attribute AUTF8String normalizedName; + attribute AUTF8String serverAlias; + + /** Whether we can verify the identity of this buddy. */ + readonly attribute boolean canVerifyIdentity; + + /** + * True if we trust the encryption with this buddy in E2EE conversations. Can + * only be true if |canVerifyIdentity| is true. + */ + readonly attribute boolean identityVerified; + + /** + * Initialize identity verification with this buddy. + * @returns {Promise<imISessionVerification>} + */ + Promise verifyIdentity(); + + // remove the buddy from the buddy list of this account. + void remove(); + + // Called by the contacts service during its uninitialization to + // notify that all references kept to imIBuddy or imIAccount + // instances should be released now. + void unInit(); +}; diff --git a/comm/chat/components/public/imIConversationsService.idl b/comm/chat/components/public/imIConversationsService.idl new file mode 100644 index 0000000000..67affbdfcb --- /dev/null +++ b/comm/chat/components/public/imIConversationsService.idl @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "prplIConversation.idl" +#include "prplIMessage.idl" +#include "imIContactsService.idl" + +interface imIMessage; + +[scriptable, uuid(81b8d9a9-4715-4109-b522-84b9d31493a3)] +interface imIConversation: prplIConversation { + // Will be null for MUCs and IMs from people not in the contacts list. + readonly attribute imIContact contact; + + // Write a system message into the conversation. + // Note: this will not be logged. + void systemMessage(in AUTF8String aMessage, + [optional] in boolean aIsError, + [optional] in boolean aNoCollapse); + + // Write a system message into the conversation and trigger the update of the + // notification counter during an off-the-record authentication request. + // Note: this will not be logged. + void notifyVerifyOTR(in AUTF8String aMessage); + + attribute prplIConversation target; + + // Number of unread messages (all messages, including system + // messages are counted). + readonly attribute unsigned long unreadMessageCount; + // Number of unread incoming messages targeted at the user (= IMs or + // message containing the user's nick in MUCs). + readonly attribute unsigned long unreadTargetedMessageCount; + // Number of unread incoming messages (both targeted and untargeted + // messages are counted). + readonly attribute unsigned long unreadIncomingMessageCount; + // Number of unread off-the-record authentication requests. + readonly attribute unsigned long unreadOTRNotificationCount; + // Reset all unread message counts. + void markAsRead(); + + // Can be used instead of the topic when no topic is set. + readonly attribute AUTF8String noTopicString; + + // Call this to give the core an opportunity to close an inactive + // conversation. If the conversation is a left MUC or an IM + // conversation without unread message, the implementation will call + // close(). + // The returned value indicates if the conversation was closed. + boolean checkClose(); + + // Get an array of all messages of the conversation. + Array<imIMessage> getMessages(); +}; + +[scriptable, uuid(984e182c-d395-4fba-ba6e-cc80c71f57bf)] +interface imIConversationsService: nsISupports { + void initConversations(); + void unInitConversations(); + + // Register a conversation. This will create a unique id for the + // conversation and set it. + void addConversation(in prplIConversation aConversation); + void removeConversation(in prplIConversation aConversation); + + Array<imIConversation> getUIConversations(); + imIConversation getUIConversation(in prplIConversation aConversation); + imIConversation getUIConversationByContactId(in long aId); + + Array<prplIConversation> getConversations(); + prplIConversation getConversationById(in unsigned long aId); + prplIConversation getConversationByNameAndAccount(in AUTF8String aName, + in imIAccount aAccount, + in boolean aIsChat); +}; + +// Because of limitations in libpurple (write_conv is called without context), +// there's an implicit contract that whatever message string the conversation +// service passes to a protocol, it'll get back as the originalMessage when +// "new-text" is notified. This is required for the OTR extensions to work. + +// A cancellable outgoing message. Before handing a message off to a protocol, +// the conversation service notifies observers of `preparing-message` and +// `sending-message` (typically add-ons) of an outgoing message, which can be +// transformed or cancelled. +[scriptable, uuid(f88535b1-0b99-433b-a6de-c1a4bf8b43ea)] +interface imIOutgoingMessage: nsISupports { + attribute AUTF8String message; + attribute boolean cancelled; + /** Outgoing message is an action command. */ + readonly attribute boolean action; + /** Outgoing message is a notice */ + readonly attribute boolean notification; + readonly attribute prplIConversation conversation; +}; + +// A cancellable message to be displayed. When the conversation service is +// notified of a `new-text` (ie. an incoming or outgoing message to be +// displayed), it in turn notifies observers of `received-message` +// (again, typically add-ons), which have the opportunity to swap or cancel +// the message. +[scriptable, uuid(3f88cc5c-6940-4eb5-a576-c65770f49ce9)] +interface imIMessage: prplIMessage { + attribute boolean cancelled; + // Holds the sender color for Chats. + // Empty string by default, it is set by the conversation binding. + attribute AUTF8String color; + + // What eventually gets shown to the user. + attribute AUTF8String displayMessage; + + // The related incoming or outgoing message is transmitted + // with encryption through OTR. + attribute boolean otrEncrypted; +}; diff --git a/comm/chat/components/public/imICoreService.idl b/comm/chat/components/public/imICoreService.idl new file mode 100644 index 0000000000..08ae1d2fbe --- /dev/null +++ b/comm/chat/components/public/imICoreService.idl @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "imIUserStatusInfo.idl" +#include "prplIProtocol.idl" + +[scriptable, uuid(205d4b2b-1ccf-4879-9ef1-f08942566151)] +interface imICoreService: nsISupports { + readonly attribute boolean initialized; + + // This will emit a prpl-init notification. After this point the 'initialized' + // attribute will be 'true' and it's safe to access the services for accounts, + // contacts, conversations and commands. + void init(); + + // This will emit a prpl-quit notification. This is the last opportunity to + // use the aforementioned services before they are uninitialized. + void quit(); + + // Returns the available protocols. + Array<prplIProtocol> getProtocols(); + + prplIProtocol getProtocolById(in AUTF8String aProtocolId); + + readonly attribute imIUserStatusInfo globalUserStatus; +}; diff --git a/comm/chat/components/public/imILogger.idl b/comm/chat/components/public/imILogger.idl new file mode 100644 index 0000000000..fd8e632d5d --- /dev/null +++ b/comm/chat/components/public/imILogger.idl @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsIFile.idl" + +interface imIAccount; +interface prplIAccountBuddy; +interface imIBuddy; +interface imIContact; +interface imIMessage; +interface prplIConversation; + +[scriptable, uuid(7771402c-ff55-41f5-86b4-59b93f9b0693)] +interface imILogConversation: nsISupports { + readonly attribute AUTF8String title; + readonly attribute AUTF8String name; + // Value in microseconds. + readonly attribute PRTime startDate; + + // Simplified account implementation: + // - alias will always be empty + // - name (always the normalizedName) + // - statusInfo will return IMServices.core.globalUserStatus + // - protocol will only contain a "name" attribute, with the prpl's normalized name. + // Other methods/attributes aren't implemented. + readonly attribute imIAccount account; + + readonly attribute boolean isChat; // always false (compatibility with prplIConversation). + readonly attribute prplIAccountBuddy buddy; // always null (compatibility with prplIConvIM). + + Array<imIMessage> getMessages(); +}; + +[scriptable, uuid(27712ece-ad2c-4504-87d5-9e2c16d40fef)] +interface imILog: nsISupports { + readonly attribute AUTF8String path; + // Value in seconds. + readonly attribute PRTime time; + readonly attribute AUTF8String format; + // Returns a promise that resolves to an imILogConversation instance, or null + // if the log format isn't JSON. + jsval getConversation(); +}; + +[scriptable, function, uuid(2ab5f8ac-4b89-4954-9a4a-7c167f1e3b0d)] +interface imIProcessLogsCallback: nsISupports { + // The callback can return a promise. If it does, then it will not be called + // on the next log until this promise resolves. If it throws (or rejects), + // iteration will stop. + jsval processLog(in AUTF8String aLogPath); +}; + +[scriptable, uuid(7e2476dc-8199-4454-9661-b78ee73fa49e)] +interface imILogger: nsISupports { + // Returns a promise that resolves to an imILog instance. + jsval getLogFromFile(in AUTF8String aFilePath, [optional] in boolean aGroupByDay); + // Returns a promise that resolves to the log file paths if a log writer + // exists for the conversation, or null otherwise. The promise resolves + // after any pending I/O operations on the files complete. + jsval getLogPathsForConversation(in prplIConversation aConversation); + + // Below methods return promises that resolve to {imILog[]}. + + // Get logs for a contact. + jsval getLogsForContact(in imIContact aContact); + // Get logs for a conversation. + jsval getLogsForConversation(in prplIConversation aConversation); + // Get logs that are from the same conversation. + jsval getSimilarLogs(in imILog aLog); + + // Asynchronously iterates through log folders for all prpls and accounts and + // invokes the callback on every log file. Returns a promise that resolves when + // iteration is complete. If the callback returns a promise, iteration pauses + // until the promise resolves. If the callback throws (or rejects), iteration + // will stop and the returned promise will reject with the same error. + jsval forEach(in imIProcessLogsCallback aCallback); + + // Returns the folder storing all logs for aAccount. + AUTF8String getLogFolderPathForAccount(in imIAccount aAccount); + + // Removes the folder storing all logs for aAccount. + // Be sure the account is disconnected before using this. + jsval deleteLogFolderForAccount(in imIAccount aAccount); +}; diff --git a/comm/chat/components/public/imIStatusInfo.idl b/comm/chat/components/public/imIStatusInfo.idl new file mode 100644 index 0000000000..0338886923 --- /dev/null +++ b/comm/chat/components/public/imIStatusInfo.idl @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "prplIConversation.idl" + +interface prplITooltipInfo; + +[scriptable, uuid(f13dc4fc-5334-45cb-aa58-a92851955e55)] +interface imIStatusInfo: nsISupports { + // Name suitable for display in the UI. Can either be the username, + // the server side alias, or the user set local alias of the contact. + readonly attribute AUTF8String displayName; + readonly attribute AUTF8String buddyIconFilename; + + const short STATUS_UNKNOWN = 0; + const short STATUS_OFFLINE = 1; + const short STATUS_INVISIBLE = 2; + const short STATUS_MOBILE = 3; + const short STATUS_IDLE = 4; + const short STATUS_AWAY = 5; + const short STATUS_UNAVAILABLE = 6; + const short STATUS_AVAILABLE = 7; + + // numerical value used to compare the availability of two buddies + // based on their current status. + // Use it only for immediate comparisons, do not store the value, + // it can change between versions for a same status of the buddy. + readonly attribute long statusType; + + readonly attribute boolean online; // (statusType > STATUS_OFFLINE) + readonly attribute boolean available; // (statusType == STATUS_AVAILABLE) + readonly attribute boolean idle; // (statusType == STATUS_IDLE) + readonly attribute boolean mobile; // (statusType == STATUS_MOBILE) + + readonly attribute AUTF8String statusText; + + // Gives more detail to compare the availability of two buddies with the same + // status type. + // Example: 2 buddies may have been idle for various amounts of times. + readonly attribute long availabilityDetails; + + // True if the buddy is online or if the account supports sending + // offline messages to the buddy. + readonly attribute boolean canSendMessage; + + // Array of prplITooltipInfo components. + Array<prplITooltipInfo> getTooltipInfo(); + + // Will select the buddy automatically based on availability, and + // the account (if needed) based on the account order in the account + // manager. + prplIConversation createConversation(); +}; diff --git a/comm/chat/components/public/imITagsService.idl b/comm/chat/components/public/imITagsService.idl new file mode 100644 index 0000000000..e438c971c1 --- /dev/null +++ b/comm/chat/components/public/imITagsService.idl @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsIObserver.idl" + +interface imIContact; + +[scriptable, uuid(c211e5e2-f0a4-4a86-9e4c-3f6b905628a5)] +interface imITag: nsISupports { + readonly attribute long id; + attribute AUTF8String name; + + /** + * Get an array of all the contacts associated with this tag. + * + * Contacts can either "have the tag" (added by user action) or + * have inherited the tag because it was the server side group for + * one of the AccountBuddy of the contact. + */ + Array<imIContact> getContacts(); + + void addObserver(in nsIObserver aObserver); + void removeObserver(in nsIObserver aObserver); + /* Observers will be notified of changes related to the contacts + * that have the tag: contact-*, buddy-*, account-buddy-* + * notifications forwarded respectively from the imIContact, + * imIBuddy and prplIAccountBuddy instances. + */ + + // Exposed for add-on authors. All internal calls will come from the + // imITag implementation itself so it wasn't required to expose this. + // This can be used to dispatch custom notifications to the + // observers of the tag. + void notifyObservers(in nsISupports aObj, in string aEvent, + [optional] in wstring aData); +}; + +[scriptable, uuid(993aa8c7-8193-4354-8ee1-d2fd9fca692d)] +interface imITagsService: nsISupports { + // Get the default tag (ie. "Contacts" for en-US). + readonly attribute imITag defaultTag; + + /** + * Creates a new tag or gets an existing tag if one already exists. + * + * @param aName the name of the new tag. + * @returns imITag + */ + imITag createTag(in AUTF8String aName); + + /** + * Get an existing tag by ID. + * + * @param aId the numeric tag ID. + * @returns the tag or null if the tag doesn't exist. + */ + imITag getTagById(in long aId); + + /** + * Get an existing tag by name (note that this will do an SQL query). + * + * @param aName the tag name. + * @returns the tag or null if the tag doesn't exist. + */ + imITag getTagByName(in AUTF8String aName); + + /** + * Get an array of all existing tags. + * + * @returns imITag[] + */ + Array<imITag> getTags(); + + boolean isTagHidden(in imITag aTag); + void hideTag(in imITag aTag); + void showTag(in imITag aTag); + + readonly attribute imITag otherContactsTag; +}; diff --git a/comm/chat/components/public/imIUserStatusInfo.idl b/comm/chat/components/public/imIUserStatusInfo.idl new file mode 100644 index 0000000000..dba07c3190 --- /dev/null +++ b/comm/chat/components/public/imIUserStatusInfo.idl @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsIObserver.idl" + +//forward declarations +interface nsIFile; +interface nsIFileURL; + +[scriptable, uuid(817918fa-1f4b-4254-9cdb-f906da91c45d)] +interface imIUserStatusInfo: nsISupports { + + readonly attribute AUTF8String statusText; + + // See imIStatusInfo for the values. + readonly attribute short statusType; + + /** + * Set the user's current status (e.g. available or away). + * + * When called with the status type STATUS_UNSET, only the status + * message will be changed. + * + * @param aStatus the new status to use. Only works with STATUS_OFFLINE, + * STATUS_UNAVAILABLE, STATUS_AWAY, STATUS_AVAILABLE and STATUS_INVISIBLE. + * @param aMessage the new status message. Ignored when aStatus is STATUS_OFFLINE. + */ + void setStatus(in short aStatus, in AUTF8String aMessage); + + /** + * Sets the user icon, or removes it if null is passed as a parameter. + * + * Calling this will fire a user-icon-changed notification. + */ + void setUserIcon(in nsIFile aIconFile); + + /** + * Returns the location of the current user icon, or null if no icon is set. + */ + nsIFileURL getUserIcon(); + + /* The setter will fire a user-display-name-changed notification. */ + attribute AUTF8String displayName; + + void addObserver(in nsIObserver aObserver); + void removeObserver(in nsIObserver aObserver); + /* Observers will receive the following notifications: + * status-changed (when either the status type or text has changed) + * user-icon-changed + * user-display-name-changed + * idle-time-changed + */ +}; diff --git a/comm/chat/components/public/moz.build b/comm/chat/components/public/moz.build new file mode 100644 index 0000000000..71758d842d --- /dev/null +++ b/comm/chat/components/public/moz.build @@ -0,0 +1,25 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPIDL_SOURCES += [ + "imIAccount.idl", + "imIAccountsService.idl", + "imICommandsService.idl", + "imIContactsService.idl", + "imIConversationsService.idl", + "imICoreService.idl", + "imILogger.idl", + "imIStatusInfo.idl", + "imITagsService.idl", + "imIUserStatusInfo.idl", + "prplIConversation.idl", + "prplIMessage.idl", + "prplIPref.idl", + "prplIProtocol.idl", + "prplIRequest.idl", + "prplITooltipInfo.idl", +] + +XPIDL_MODULE = "chat" diff --git a/comm/chat/components/public/prplIConversation.idl b/comm/chat/components/public/prplIConversation.idl new file mode 100644 index 0000000000..dd947337bb --- /dev/null +++ b/comm/chat/components/public/prplIConversation.idl @@ -0,0 +1,274 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +#include "nsISupports.idl" +#include "nsIObserver.idl" + +interface prplIAccountBuddy; +interface imIAccount; +interface imIOutgoingMessage; +interface imIMessage; +interface nsIURI; +interface prplIChatRoomFieldValues; + +/** + * This interface represents a conversation as implemented by a protocol. It + * contains the properties and methods shared between direct (IM) and multi + * user (chat) conversations. + */ +[scriptable, uuid(f71c58d6-2c47-4468-934b-b1c61462c01a)] +interface prplIConversation: nsISupports { + + /** + * Indicate if this conversation implements prplIConvIM or prplIConvChat. If + * this ever changes at runtime, the conversation should emit a + * "chat-update-type" notification. */ + readonly attribute boolean isChat; + + /* The account used for this conversation */ + readonly attribute imIAccount account; + + /* The name of the conversation, typically in English */ + readonly attribute AUTF8String name; + + /* A name that can be used to check for duplicates and is the basis + for the directory name for log storage. */ + readonly attribute AUTF8String normalizedName; + + /* The title of the conversation, typically localized */ + readonly attribute AUTF8String title; + + /* The time and date of the conversation's creation, in microseconds */ + readonly attribute PRTime startDate; + /* Unique identifier of the conversation */ + /* Setable only once by purpleCoreService while calling addConversation. */ + attribute unsigned long id; + + /** URI of the icon for the conversation */ + readonly attribute AUTF8String convIconFilename; + + /** + * The user can not enable encryption for this room (another participant may + * be able to enable encryption however) + */ + const short ENCRYPTION_NOT_SUPPORTED = 0; + /** + * Encryption can be initialized in this conversation. + */ + const short ENCRYPTION_AVAILABLE = 1; + /** + * New messages in this conversation are end-to-end encrypted. + */ + const short ENCRYPTION_ENABLED = 2; + /** + * Indicates that the encryption with the other side should be trusted, for + * example because the user has verified their public keys. Implies + * ENCRYPTION_ENABLED. + */ + const short ENCRYPTION_TRUSTED = 3; + + /** + * State of encryption for this conversation, as available via the protocol. + * update-conv-encryption is observed when this changes. + */ + readonly attribute short encryptionState; + + /** + * When encryptionState is ENCRYPTION_AVAILABLE this tries to initialize + * encryption for all new messages in the conversation. + */ + void initializeEncryption(); + + /** + * Send a message in the conversation. Protocols should consider resetting + * the typing state with this call, similar to |sendTyping("")|. + */ + void sendMsg(in AUTF8String aMsg, in boolean aAction, in boolean aNotice); + + /** + * Preprocess messages before they are sent (eg. split long messages). + * + * @returns the potentially modified message(s). + */ + Array<AString> prepareForSending(in imIOutgoingMessage aMsg); + + /** + * Postprocess messages before they are displayed (eg. escaping). The + * implementation can set aMsg.displayMessage, otherwise the originalMessage + * is used. + */ + void prepareForDisplaying(in imIMessage aMsg); + + /** + * Send information about the current typing state to the server. + * + * @param aString should contain the content currently in the text field. + * @returns the number of characters that can still be typed. + */ + long sendTyping(in AUTF8String aString); + const long NO_TYPING_LIMIT = 2147483647; // max int = 2 ^ 31 - 1 + + /** + * Un-initialize the conversation. + * + * This will be called by purpleCoreService::RemoveConversation + * when the conversation is closed or by purpleCoreService::Quit + * while exiting. + */ + void unInit(); + + /** + * Called when the conversation is closed from the UI. + */ + void close(); + + /** + * Method to add or remove an observer. + */ + void addObserver(in nsIObserver aObserver); + void removeObserver(in nsIObserver aObserver); + + /** + * Observers will all receive new-text and update-text notifications. + * aSubject will contain the message (prplIMessage). For update-text the + * update applies to any message with the same |remoteId| in the same + * conversation. + * The remove-text notification has no subject, but a remote ID as data. + * It indicates that the message should be removed from the conversation. + * Neither update-text nor remove-text affect unread counts. + */ +}; + +[scriptable, uuid(c0b5b647-b0ec-4dc6-9e53-31a762a30a6e)] +interface prplIConvIM: prplIConversation { + + /* The buddy at the remote end of the conversation */ + readonly attribute prplIAccountBuddy buddy; + + /* The remote buddy is not currently typing */ + const short NOT_TYPING = 0; + + /* The remote buddy is currently typing */ + const short TYPING = 1; + + /* The remote buddy started typing, but has stopped typing */ + const short TYPED = 2; + + /* The typing state of the remote buddy. + The value is NOT_TYPING, TYPING or TYPED. */ + readonly attribute short typingState; +}; + +/** This represents a participant in a chat room */ +[scriptable, uuid(b0e9177b-40f6-420b-9918-04bbbb9ce44f)] +interface prplIConvChatBuddy: nsISupports { + + /* The name of the buddy */ + readonly attribute AUTF8String name; + + /* The alias (FIXME: can this be non-null if buddy is null?) */ + readonly attribute AUTF8String alias; + + /* Indicates if this chat buddy corresponds to a buddy in our buddy list */ + readonly attribute boolean buddy; + + /** URI of the user icon for the buddy */ + readonly attribute AUTF8String buddyIconFilename; + + /* The role of the participant in the room. */ + + /* Voiced users can send messages to the room. */ + readonly attribute boolean voiced; + /* Moderators can manage other participants. */ + readonly attribute boolean moderator; + /* Admins have additional powers. */ + readonly attribute boolean admin; + /* Founders have complete control of a room. */ + readonly attribute boolean founder; + + /* Whether the participant is currently typing. */ + readonly attribute boolean typing; + + /** Whether we can verify the identity of this participant. */ + readonly attribute boolean canVerifyIdentity; + + /** + * True if we trust the encryption with this participant in E2EE chats. Can + * only be true if |canVerifyIdentity| is true. + */ + readonly attribute boolean identityVerified; + + /** + * Initialize identity verification with this participant. + * @returns {Promise<imISessionVerification>} + */ + Promise verifyIdentity(); +}; + +[scriptable, uuid(72c17398-639f-4141-a19c-78cbdeb39fba)] +interface prplIConvChat: prplIConversation { + + /** + * Get the prplIConvChatBuddy of a participant. + * + * @param aName the participant's nick in the conversation exists + * @returns prplIConvChatBuddy if the participant exists, otherwise null + */ + prplIConvChatBuddy getParticipant(in AUTF8String aName); + + /** + * Get the list of people participating in this chat. + * + * @returns an array of prplIConvChatBuddy objects. + */ + Array<prplIConvChatBuddy> getParticipants(); + + /** + * Normalize the name of a chat buddy. This will be suitable for calling + * createConversation to start a private conversation or calling + * requestBuddyInfo. + * + * @returns the normalized chat buddy name. + */ + AUTF8String getNormalizedChatBuddyName(in AUTF8String aChatBuddyName); + + /* The topic of this chat room */ + attribute AUTF8String topic; + + /* The name/nick of the person who set the topic */ + readonly attribute AUTF8String topicSetter; + + /* Whether the protocol plugin can set a topic. Doesn't check that + the user has the necessary rights in the current conversation. */ + readonly attribute boolean topicSettable; + + /* The nick seen by other people in the room */ + readonly attribute AUTF8String nick; + + /* This is true when we left the chat but kept the conversation open */ + readonly attribute boolean left; + + /* This is true if we are in the process of joining the channel */ + readonly attribute boolean joining; + + /* This stores the data required to join the chat with joinChat(). + If null, the chat will not be rejoined automatically when the + account reconnects after a disconnect. + Should be set to null by the prpl if the user parts the chat. */ + readonly attribute prplIChatRoomFieldValues chatRoomFields; + + /* Observers will receive chat-buddy-add, chat-buddy-update, + chat-buddy-remove and chat-update-topic notifications. + + aSubject will be of type: + nsISimpleEnumerator of prplIConvChatBuddy for chat-buddy-add, + nsISimpleEnumerator of nsISupportsString for chat-buddy-remove, + prplIConvChatBuddy for chat-buddy-update, + null for chat-update-topic. + + aData will contain the old nick for chat-buddy-update if the name + has changed. + */ +}; diff --git a/comm/chat/components/public/prplIMessage.idl b/comm/chat/components/public/prplIMessage.idl new file mode 100644 index 0000000000..610c6a477d --- /dev/null +++ b/comm/chat/components/public/prplIMessage.idl @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsIRunnable.idl" +#include "prplIConversation.idl" + +/** + * An action that the user may perform in relation to a particular message. + */ +[scriptable, uuid(7e470f0e-d948-4d9a-b8dc-4beecf6554b9)] +interface prplIMessageAction: nsIRunnable +{ + /** + * The protocol plugins need to provide a localized label suitable + * for being shown in the user interface (for example as a context + * menu item). + */ + readonly attribute AUTF8String label; +}; + +[scriptable, uuid(d6accb66-cdd2-4a91-8854-1156e65d5a43)] +interface prplIMessage: nsISupports { + /** + * The uniqueness of the message id is only guaranteed across + * messages of a conversation, not across all messages created + * during the execution of the application. + */ + readonly attribute unsigned long id; + /** + * An ID for this message provided by the protocol. Used for finding the + * message in the conversation for actions like editing. This is expected to + * be absolute per conversation, meaning if two prplIMessages in the same + * conversation have identical |remoteId|s they refer to the same message in + * the conversation as far as the protocol is concerned. + */ + readonly attribute AUTF8String remoteId; + /** The name of the message sender. */ + readonly attribute AUTF8String who; + /** The alias of the message sender (frequently the same as who). */ + readonly attribute AUTF8String alias; + /** The original message, if it was modified, e.g. via OTR. */ + readonly attribute AUTF8String originalMessage; + /** The message that will be sent over the wire. */ + attribute AUTF8String message; + /** An icon to associate with the message sender. */ + readonly attribute AUTF8String iconURL; + /** The time the message was sent, in seconds. */ + readonly attribute PRTime time; + /** The conversation the message was sent to. */ + readonly attribute prplIConversation conversation; + + /** Outgoing message. */ + readonly attribute boolean outgoing; + /** Incoming message. */ + readonly attribute boolean incoming; + /** System message, i.e. a message from the server or client (not from another user). */ + readonly attribute boolean system; + /** Auto response. */ + readonly attribute boolean autoResponse; + /** Contains your nick, e.g. if you were pinged. */ + readonly attribute boolean containsNick; + /** This message should not be logged. */ + readonly attribute boolean noLog; + /** Error message. */ + readonly attribute boolean error; + /** Delayed message, e.g. it was received from a queue of historical messages on the server. */ + readonly attribute boolean delayed; + /** "Raw" message - don't apply formatting. */ + readonly attribute boolean noFormat; + /** Message contains images. */ + readonly attribute boolean containsImages; + /** Message is a notification. */ + readonly attribute boolean notification; + /** Message should not be auto-linkified. */ + readonly attribute boolean noLinkification; + /** Do not collapse the message. */ + readonly attribute boolean noCollapse; + /** Message is encrypted. */ + readonly attribute boolean isEncrypted; + /** The message should be displayed as an action/emote. */ + readonly attribute boolean action; + /** Message was deleted, this is a placeholder for it */ + readonly attribute boolean deleted; + + /** + * Get an array of actions the user may perform on this message. + * + * @returns prplIMessageAction[] + */ + Array<prplIMessageAction> getActions(); + + /** + * Called when the message is first displayed to the user. Only invoked for + * the latest message in a conversation. + */ + void whenDisplayed(); + + /** + * Called when the message has been read by the user, as defined by it being + * above the unread marker in the conversation. Only called for the message + * immediately above the marker. + */ + void whenRead(); +}; diff --git a/comm/chat/components/public/prplIPref.idl b/comm/chat/components/public/prplIPref.idl new file mode 100644 index 0000000000..7f3f827952 --- /dev/null +++ b/comm/chat/components/public/prplIPref.idl @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "nsISimpleEnumerator.idl" + +[scriptable, uuid(8fc16882-ba8e-432a-999f-0d4dc104234b)] +interface prplIKeyValuePair: nsISupports { + readonly attribute AUTF8String name; + readonly attribute AUTF8String value; +}; + +/* + * This is a proxy for libpurple PurpleAccountOption + */ + +[scriptable, uuid(e781563f-9088-4a96-93e3-4fb6f5ce6a77)] +interface prplIPref: nsISupports { + const short typeBool = 1; + const short typeInt = 2; + const short typeString = 3; + const short typeList = 4; + + readonly attribute AUTF8String name; + readonly attribute AUTF8String label; + readonly attribute short type; + readonly attribute boolean masked; + + boolean getBool(); + long getInt(); + AUTF8String getString(); + /** + * @returns array of prplIKeyValuePair + */ + Array<prplIKeyValuePair> getList(); + AUTF8String getListDefault(); +}; diff --git a/comm/chat/components/public/prplIProtocol.idl b/comm/chat/components/public/prplIProtocol.idl new file mode 100644 index 0000000000..f6d30826f4 --- /dev/null +++ b/comm/chat/components/public/prplIProtocol.idl @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" +#include "imIAccount.idl" + +interface prplIPref; +interface prplIUsernameSplit; + +/** + * This must be implemented for every protocol. + * + * See jsProtoHelper.jsm for a base class. + */ +[scriptable, uuid(7d302db0-3813-4c51-8372-c7eb5fc9f3d3)] +interface prplIProtocol: nsISupports { + /** + * This method is used so that classes implementing several protocol + * plugins can know which protocol is desired for this instance. + * + * @param aId The prpl id. + */ + void init(in AUTF8String aId); + + /** + * A human readable (potentially localized) name for the protocol. + */ + readonly attribute AUTF8String name; + /** + * A unique ID for the protocol, should start with the prefix 'prpl-'. + */ + readonly attribute AUTF8String id; + /** + * A unique name for this protocol, it must consist of only lowercase letters + * & numbers. + * + * It can be used to check for duplicates and is the basis for the directory + * name for log storage. + */ + readonly attribute AUTF8String normalizedName; + + /** + * A chrome URI pointing to a folder that contains the icon files: + * icon.png icon32.png and icon48.png + */ + readonly attribute AUTF8String iconBaseURI; + + /** + * @returns an array of prplIPref + */ + Array<prplIPref> getOptions(); + + /** + * String to put in front of the full account username identifier. Usually + * an empty string. + */ + readonly attribute AUTF8String usernamePrefix; + + /** + * @returns an array of prplIUsernameSplit + */ + Array<prplIUsernameSplit> getUsernameSplit(); + + /** + * Split a username into its parts without separators (or prefix). + * Returns an empty array if the username can not be split. + */ + Array<AUTF8String> splitUsername(in AUTF8String aName); + + /** + * Descriptive text used in the account wizard to describe the username. + */ + readonly attribute AUTF8String usernameEmptyText; + + /** + * Use this function to avoid attempting to create duplicate accounts. + */ + boolean accountExists(in AUTF8String aName); + + // The following should all be flags that describe whether a protocol has a + // particular feature. + + /** + * Whether chat rooms have topics. + */ + readonly attribute boolean chatHasTopic; + + /** + * True if passwords are unused for this protocol. + * + * Passwords are unused for some protocols, e.g. Bonjour. + */ + readonly attribute boolean noPassword; + + /** + * True if a password is not required for sign-in. + * + * Passwords in IRC are optional, and are needed for certain functionality. + */ + readonly attribute boolean passwordOptional; + + /** + * Indicates that slash commands are native to this protocol. + * Used as a hint that unknown commands should not be sent as messages. + */ + readonly attribute boolean slashCommandsNative; + + /** + * True if the protocol can provide end-to-end message encryption in + * conversations. + */ + readonly attribute boolean canEncrypt; + + /** + * Get the protocol specific part of an already initialized + * imIAccount instance. + */ + prplIAccount getAccount(in imIAccount aImAccount); +}; + +/** + * The chat account wizards requests the sign-in information as a series of + * fields generated by a list of prplIUsernameSplit. + * + * The result of these is composed into a string and stored as the account name. + * It is the responsibity of the prplIAccount to re-parse this back to usable + * connection data. + * + * TODO Replace this with storing account data as separate fields. + */ +[scriptable, uuid(20c4971a-f7c2-4781-8e85-69fee7b83a3d)] +interface prplIUsernameSplit: nsISupports { + /** + * The field name presented in the account wizard, e.g. server. + */ + readonly attribute AUTF8String label; + /** + * The default value that is presented in the account wizard. + */ + readonly attribute AUTF8String defaultValue; + /** + * The string used to compose the account name. + * + * E.g. an "@" would cause "@" to be appended before this field. + */ + readonly attribute char separator; +}; diff --git a/comm/chat/components/public/prplIRequest.idl b/comm/chat/components/public/prplIRequest.idl new file mode 100644 index 0000000000..2e9b58584f --- /dev/null +++ b/comm/chat/components/public/prplIRequest.idl @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +interface imIAccount; +interface nsIDOMWindow; +interface nsIWebProgress; + +/** + * This interface is for use in the browser-request notification, to + * let protocol plugins open a browser window. This is an unfortunate + * necessity for protocols that require an OAuth authentication. + */ +[scriptable, uuid(b89dbb38-0de4-11e0-b3d0-0002e304243c)] +interface prplIRequestBrowser: nsISupports { + readonly attribute AUTF8String promptText; + readonly attribute AUTF8String url; + void cancelled(); + void loaded(in nsIDOMWindow aWindow, + in nsIWebProgress aWebProgress); +}; + +/** + * This interface is used for buddy authorization requests, when the + * user needs to confirm if a remote contact should be allowed to see + * his presence information. It is implemented by the aSubject + * parameter of the buddy-authorization-request and + * buddy-authorization-request-canceled notifications. + */ +[scriptable, uuid(a55c1e24-17cc-4ddc-8c64-3bc315a3c3b1)] +interface prplIBuddyRequest: nsISupports { + readonly attribute imIAccount account; + readonly attribute AUTF8String userName; + void grant(); + void deny(); +}; + +/** + * This is used with chat room invitation requests, so the user can accept or + * reject an invitation. It is implemented by the aSubject parameter of the + * conv-authorization-request notification. + */ +[scriptable, uuid(44ac9606-711b-40f6-9031-94a9c60c938d)] +interface prplIChatRequest: nsISupports { + readonly attribute imIAccount account; + readonly attribute AUTF8String conversationName; + /** + * Resolves when the request is completed, with a boolean indicating if it + * was granted. Rejected if the request is cancelled. + * + * @type {Promise<boolean>} + */ + readonly attribute Promise completePromise; + readonly attribute boolean canDeny; + void grant(); + void deny(); +}; + +/** + * Verification information for an encryption session (for example prplISession). + * Used to present a verification flow to the user. + */ +[scriptable, uuid(48c1748d-ba51-44c0-aa3c-e979d4d4bdf3)] +interface imISessionVerification: nsISupports { + /** + * Challenge mode where a text string is presented to the user and they have + * to confirm it matches with the other user/device's. + */ + const short CHALLENGE_TEXT = 1; + /** Verification mode */ + readonly attribute short challengeType; + /** Challenge string to present to the user for CHALLENGE_TEXT */ + readonly attribute AUTF8String challenge; + /** + * Optional description of the challenge contents. For example text + * representation of emoji. + */ + readonly attribute AUTF8String challengeDescription; + /** + * User readable name for the entity the verification is about (so the + * user/device on the other side of the flow). + */ + readonly attribute AUTF8String subject; + /** + * resolves with the result from the challenge, rejects if the action was + * cancelled. + * + * @type {Promise<boolean>} + */ + readonly attribute Promise completePromise; + /** + * Submit result of the challenge, completing the verification on this side. + */ + void submitResponse(in boolean challengeMatches); + /** + * Cancel the verification. + */ + void cancel(); +}; + +/** + * Incoming verification request, sent to the UI via buddy-verification-request + * notification. Can be canelled with buddy-verification-request-cancelled. + */ +[scriptable, uuid(c46d426f-6e99-4713-b0aa-0b404db5a40d)] +interface imIIncomingSessionVerification: imISessionVerification { + readonly attribute imIAccount account; + /** + * Method to accept the verification. Resolves once |challenge| is + * populated. + */ + Promise verify(); +}; diff --git a/comm/chat/components/public/prplITooltipInfo.idl b/comm/chat/components/public/prplITooltipInfo.idl new file mode 100644 index 0000000000..baa62b89a7 --- /dev/null +++ b/comm/chat/components/public/prplITooltipInfo.idl @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +/* + * This interface provides access to the content of a + * PurpleNotifyUserInfoEntry structure. + */ + +[scriptable, uuid(e4c1def4-d1fe-4449-b195-51f137d1f215)] +interface prplITooltipInfo: nsISupports { + const short pair = 0; + const short sectionBreak = 1; + const short sectionHeader = 2; + const short status = 3; + const short icon = 4; + + readonly attribute short type; + + /* + * When type == status, the label holds the statusType (a short + * converted to a string), while the value holds the statusText. + * When type == icon, the value holds the user icon URI. + */ + readonly attribute AUTF8String label; + readonly attribute AUTF8String value; +}; diff --git a/comm/chat/components/src/components.conf b/comm/chat/components/src/components.conf new file mode 100644 index 0000000000..cec63d9801 --- /dev/null +++ b/comm/chat/components/src/components.conf @@ -0,0 +1,50 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + "cid": "{a94b5427-cd8d-40cf-b47e-b67671953e70}", + "contract_ids": ["@mozilla.org/chat/accounts-service;1"], + 'esModule': "resource:///modules/imAccounts.sys.mjs", + "constructor": "AccountsService", + }, + { + "cid": "{7cb20c68-ccc8-4a79-b6f1-0b4771ed6c23}", + "contract_ids": ["@mozilla.org/chat/commands-service;1"], + 'esModule': "resource:///modules/imCommands.sys.mjs", + "constructor": "CommandsService", + }, + { + "cid": "{8c3725dd-ee26-489d-8135-736015af8c7f}", + "contract_ids": ["@mozilla.org/chat/contacts-service;1"], + 'esModule': "resource:///modules/imContacts.sys.mjs", + "constructor": "ContactsService", + }, + { + "cid": "{1fa92237-4303-4384-b8ac-4e65b50810a5}", + "contract_ids": ["@mozilla.org/chat/tags-service;1"], + 'esModule': "resource:///modules/imContacts.sys.mjs", + "constructor": "TagsService", + }, + { + "cid": "{b2397cd5-c76d-4618-8410-f344c7c6443a}", + "contract_ids": ["@mozilla.org/chat/conversations-service;1"], + 'esModule': "resource:///modules/imConversations.sys.mjs", + "constructor": "ConversationsService", + }, + { + "cid": "{073f5953-853c-4a38-bd81-255510c31c2e}", + "contract_ids": ["@mozilla.org/chat/core-service;1"], + 'esModule': "resource:///modules/imCore.sys.mjs", + "constructor": "CoreService", + }, + { + "cid": "{fb0dc220-2c7a-4216-9f19-6b8f3480eae9}", + "contract_ids": ["@mozilla.org/chat/logger;1"], + 'esModule': "resource:///modules/logger.sys.mjs", + "constructor": "Logger", + }, +] diff --git a/comm/chat/components/src/imAccounts.sys.mjs b/comm/chat/components/src/imAccounts.sys.mjs new file mode 100644 index 0000000000..f06b503fa6 --- /dev/null +++ b/comm/chat/components/src/imAccounts.sys.mjs @@ -0,0 +1,1237 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + ClassInfo, + executeSoon, + l10nHelper, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { + GenericAccountPrototype, + GenericAccountBuddyPrototype, +} from "resource:///modules/jsProtoHelper.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/accounts.properties") +); +XPCOMUtils.defineLazyGetter(lazy, "_maxDebugMessages", () => + Services.prefs.getIntPref("messenger.accounts.maxDebugMessages") +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "HttpProtocolHandler", + "@mozilla.org/network/protocol;1?name=http", + "nsIHttpProtocolHandler" +); + +var kPrefAutologinPending = "messenger.accounts.autoLoginPending"; +let kPrefAccountOrder = "mail.accountmanager.accounts"; +var kPrefAccountPrefix = "messenger.account."; +var kAccountKeyPrefix = "account"; +var kAccountOptionPrefPrefix = "options."; +var kPrefAccountName = "name"; +var kPrefAccountPrpl = "prpl"; +var kPrefAccountAutoLogin = "autoLogin"; +var kPrefAccountAutoJoin = "autoJoin"; +var kPrefAccountAlias = "alias"; +var kPrefAccountFirstConnectionState = "firstConnectionState"; + +var gUserCanceledPrimaryPasswordPrompt = false; + +var SavePrefTimer = { + saveNow() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + Services.prefs.savePrefFile(null); + }, + _timer: null, + unInitTimer() { + if (this._timer) { + this.saveNow(); + } + }, + initTimer() { + if (!this._timer) { + this._timer = setTimeout(this.saveNow.bind(this), 5000); + } + }, +}; + +var AutoLoginCounter = { + _count: 0, + startAutoLogin() { + ++this._count; + if (this._count != 1) { + return; + } + Services.prefs.setIntPref(kPrefAutologinPending, Date.now() / 1000); + SavePrefTimer.saveNow(); + }, + finishedAutoLogin() { + --this._count; + if (this._count != 0) { + return; + } + Services.prefs.clearUserPref(kPrefAutologinPending); + SavePrefTimer.initTimer(); + }, +}; + +function UnknownProtocol(aPrplId) { + this.id = aPrplId; +} +UnknownProtocol.prototype = { + __proto__: ClassInfo("prplIProtocol", "Unknown protocol"), + get name() { + return ""; + }, + get normalizedName() { + // Use the ID, but remove the 'prpl-' prefix. + return this.id.replace(/^prpl-/, ""); + }, + get iconBaseURI() { + return "chrome://chat/skin/prpl-unknown/"; + }, + getOptions() { + return []; + }, + get usernamePrefix() { + return ""; + }, + getUsernameSplit() { + return []; + }, + get usernameEmptyText() { + return ""; + }, + + getAccount(aKey, aName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + accountExists() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + // false seems an acceptable default for all options + // (they should never be called anyway). + get chatHasTopic() { + return false; + }, + get noPassword() { + return false; + }, + get passwordOptional() { + return true; + }, + get slashCommandsNative() { + return false; + }, + get canEncrypt() { + return false; + }, +}; + +// An unknown prplIAccount. +function UnknownAccount(aAccount) { + this._init(aAccount.protocol, aAccount); +} +UnknownAccount.prototype = GenericAccountPrototype; + +function UnknownAccountBuddy(aAccount, aBuddy, aTag) { + this._init(new UnknownAccount(aAccount), aBuddy, aTag); +} +UnknownAccountBuddy.prototype = GenericAccountBuddyPrototype; + +/** + * @param {string} aKey - Account key for preferences. + * @param {string} [aName] - Name of the account if it is new. Will be stored + * in account preferences. If not provided, the value from the account + * preferences is used instead. + * @param {string} [aPrplId] - Protocol ID for this account if it is new. Will + * be stored in account preferences. If not provided, the value from the + * account preferences is used instead. + */ +function imAccount(aKey, aName, aPrplId) { + if (!aKey.startsWith(kAccountKeyPrefix)) { + throw Components.Exception(`Invalid key: ${aKey}`, Cr.NS_ERROR_INVALID_ARG); + } + + this.id = aKey; + this.numericId = parseInt(aKey.substr(kAccountKeyPrefix.length)); + gAccountsService._keepAccount(this); + this.prefBranch = Services.prefs.getBranch(kPrefAccountPrefix + aKey + "."); + + if (aName) { + this.name = aName; + this.prefBranch.setStringPref(kPrefAccountName, aName); + + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN; + } else { + this.name = this.prefBranch.getStringPref(kPrefAccountName); + } + + let prplId = aPrplId; + if (prplId) { + this.prefBranch.setCharPref(kPrefAccountPrpl, prplId); + } else { + prplId = this.prefBranch.getCharPref(kPrefAccountPrpl); + } + + // Get the protocol plugin, or fallback to an UnknownProtocol instance. + this.protocol = IMServices.core.getProtocolById(prplId); + if (!this.protocol) { + this.protocol = new UnknownProtocol(prplId); + this._connectionErrorReason = Ci.imIAccount.ERROR_UNKNOWN_PRPL; + return; + } + + // Ensure the account is correctly stored in blist.sqlite. + IMServices.contacts.storeAccount(this.numericId, this.name, prplId); + + // Get the prplIAccount from the protocol plugin. + this.prplAccount = this.protocol.getAccount(this); + + // Send status change notifications to the account. + this.observedStatusInfo = null; // (To execute the setter). + + // If we have never finished the first connection attempt for this account, + // mark the account as having caused a crash. + if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) { + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_CRASHED; + } + + Services.logins.initializationPromise.then(() => { + // If protocol is falsy remove() was called on this instance while waiting + // for the promise to resolve. Since the instance was disposed there is + // nothing to do. + if (!this.protocol) { + return; + } + + // Check for errors that should prevent connection attempts. + if (this._passwordRequired && !this.password) { + this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD; + } else if ( + this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_CRASHED + ) { + this._connectionErrorReason = Ci.imIAccount.ERROR_CRASHED; + } + }); +} + +imAccount.prototype = { + __proto__: ClassInfo(["imIAccount", "prplIAccount"], "im account object"), + + name: "", + id: "", + numericId: 0, + protocol: null, + prplAccount: null, + connectionState: Ci.imIAccount.STATE_DISCONNECTED, + connectionStateMsg: "", + connectionErrorMessage: "", + _connectionErrorReason: Ci.prplIAccount.NO_ERROR, + get connectionErrorReason() { + if ( + this._connectionErrorReason != Ci.prplIAccount.NO_ERROR && + (this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD || + !this._password) + ) { + return this._connectionErrorReason; + } + return this.prplAccount.connectionErrorReason; + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "account-connect-progress") { + this.connectionStateMsg = aData; + } else if (aTopic == "account-connecting") { + if (this.prplAccount.connectionErrorReason != Ci.prplIAccount.NO_ERROR) { + delete this.connectionErrorMessage; + if (this.timeOfNextReconnect - Date.now() > 1000) { + // This is a manual reconnection, reset the auto-reconnect stuff + this.timeOfLastConnect = 0; + this._cancelReconnection(); + } + } + if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) { + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_PENDING; + } + this.connectionState = Ci.imIAccount.STATE_CONNECTING; + } else if (aTopic == "account-connected") { + this.connectionState = Ci.imIAccount.STATE_CONNECTED; + this._finishedAutoLogin(); + this.timeOfLastConnect = Date.now(); + if (this.firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK) { + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_OK; + } + delete this.connectionStateMsg; + + if ( + this.canJoinChat && + this.prefBranch.prefHasUserValue(kPrefAccountAutoJoin) + ) { + let autojoin = this.prefBranch.getStringPref(kPrefAccountAutoJoin); + if (autojoin) { + for (let room of autojoin.trim().split(/,\s*/)) { + if (room) { + this.joinChat(this.getChatRoomDefaultFieldValues(room)); + } + } + } + } + } else if (aTopic == "account-disconnecting") { + this.connectionState = Ci.imIAccount.STATE_DISCONNECTING; + this.connectionErrorMessage = aData; + delete this.connectionStateMsg; + this._finishedAutoLogin(); + + let firstConnectionState = this.firstConnectionState; + if ( + firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_OK && + firstConnectionState != Ci.imIAccount.FIRST_CONNECTION_CRASHED + ) { + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN; + } + + let connectionErrorReason = this.prplAccount.connectionErrorReason; + if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) { + if ( + connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR || + connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR + ) { + this._startReconnectTimer(); + } + this._sendNotification("account-connect-error"); + } + } else if (aTopic == "account-disconnected") { + this.connectionState = Ci.imIAccount.STATE_DISCONNECTED; + let connectionErrorReason = this.prplAccount.connectionErrorReason; + if (connectionErrorReason != Ci.prplIAccount.NO_ERROR) { + // If the account was disconnected with an error, save the debug messages. + this._omittedDebugMessagesBeforeError += this._omittedDebugMessages; + if (this._debugMessagesBeforeError) { + this._omittedDebugMessagesBeforeError += + this._debugMessagesBeforeError.length; + } + this._debugMessagesBeforeError = this._debugMessages; + } else { + // After a clean disconnection, drop the debug messages that + // could have been left by a previous error. + delete this._omittedDebugMessagesBeforeError; + delete this._debugMessagesBeforeError; + } + delete this._omittedDebugMessages; + delete this._debugMessages; + if ( + this._statusObserver && + connectionErrorReason == Ci.prplIAccount.NO_ERROR && + this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE + ) { + // If the status changed back to online while an account was still + // disconnecting, it was not reconnected automatically at that point, + // so we must do it now. (This happens for protocols like IRC where + // disconnection is not immediate.) + this._sendNotification(aTopic, aData); + this.connect(); + return; + } + } else { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + this._sendNotification(aTopic, aData); + }, + + _debugMessages: null, + _omittedDebugMessages: 0, + _debugMessagesBeforeError: null, + _omittedDebugMessagesBeforeError: 0, + logDebugMessage(aMessage, aLevel) { + if (!this._debugMessages) { + this._debugMessages = []; + } + if ( + lazy._maxDebugMessages && + this._debugMessages.length >= lazy._maxDebugMessages + ) { + this._debugMessages.shift(); + ++this._omittedDebugMessages; + } + this._debugMessages.push({ logLevel: aLevel, message: aMessage }); + }, + _createDebugMessage(aMessage) { + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + aMessage, + "", + "", + 0, + null, + Ci.nsIScriptError.warningFlag, + "component javascript" + ); + return { logLevel: 0, message: scriptError }; + }, + getDebugMessages() { + let messages = []; + if (this._omittedDebugMessagesBeforeError) { + let text = this._omittedDebugMessagesBeforeError + " messages omitted"; + messages.push(this._createDebugMessage(text)); + } + if (this._debugMessagesBeforeError) { + messages = messages.concat(this._debugMessagesBeforeError); + } + if (this._omittedDebugMessages) { + let text = this._omittedDebugMessages + " messages omitted"; + messages.push(this._createDebugMessage(text)); + } + if (this._debugMessages) { + messages = messages.concat(this._debugMessages); + } + if (messages.length) { + let appInfo = Services.appinfo; + let header = + `${appInfo.name} ${appInfo.version} (${appInfo.appBuildID}), ` + + `Gecko ${appInfo.platformVersion} (${appInfo.platformBuildID}) ` + + `on ${lazy.HttpProtocolHandler.oscpu}`; + messages.unshift(this._createDebugMessage(header)); + } + + return messages; + }, + + _observedStatusInfo: null, + get observedStatusInfo() { + return this._observedStatusInfo; + }, + _statusObserver: null, + set observedStatusInfo(aUserStatusInfo) { + if (!this.prplAccount) { + return; + } + if (this._statusObserver) { + this.statusInfo.removeObserver(this._statusObserver); + } + this._observedStatusInfo = aUserStatusInfo; + if (this._statusObserver) { + this.statusInfo.addObserver(this._statusObserver); + } + }, + _removeStatusObserver() { + if (this._statusObserver) { + this.statusInfo.removeObserver(this._statusObserver); + delete this._statusObserver; + } + }, + get statusInfo() { + return this._observedStatusInfo || IMServices.core.globalUserStatus; + }, + + reconnectAttempt: 0, + timeOfLastConnect: 0, + timeOfNextReconnect: 0, + _reconnectTimer: null, + _startReconnectTimer() { + if (Services.io.offline) { + console.error("_startReconnectTimer called while offline"); + return; + } + + /* If the last successful connection is older than 10 seconds, reset the + number of reconnection attempts. */ + const kTimeBeforeSuccessfulConnection = 10; + if ( + this.timeOfLastConnect && + this.timeOfLastConnect + kTimeBeforeSuccessfulConnection * 1000 < + Date.now() + ) { + delete this.reconnectAttempt; + delete this.timeOfLastConnect; + } + + let timers = Services.prefs + .getCharPref("messenger.accounts.reconnectTimer") + .split(","); + let delay = timers[Math.min(this.reconnectAttempt, timers.length - 1)]; + let msDelay = parseInt(delay) * 1000; + ++this.reconnectAttempt; + this.timeOfNextReconnect = Date.now() + msDelay; + this._reconnectTimer = setTimeout(this.connect.bind(this), msDelay); + }, + + _sendNotification(aTopic, aData) { + Services.obs.notifyObservers(this, aTopic, aData); + }, + + get firstConnectionState() { + try { + return this.prefBranch.getIntPref(kPrefAccountFirstConnectionState); + } catch (e) { + return Ci.imIAccount.FIRST_CONNECTION_OK; + } + }, + set firstConnectionState(aState) { + if (aState == Ci.imIAccount.FIRST_CONNECTION_OK) { + this.prefBranch.clearUserPref(kPrefAccountFirstConnectionState); + } else { + this.prefBranch.setIntPref(kPrefAccountFirstConnectionState, aState); + // We want to save this pref immediately when trying to connect. + if (aState == Ci.imIAccount.FIRST_CONNECTION_PENDING) { + SavePrefTimer.saveNow(); + } else { + SavePrefTimer.initTimer(); + } + } + }, + + _pendingReconnectForConnectionInfoChange: false, + _connectionInfoChanged() { + // The next connection will be the first connection with these parameters. + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN; + + // We want to attempt to reconnect with the new settings only if a + // previous attempt failed or a connection attempt is currently + // pending (so we can return early if the account is currently + // connected or disconnected without error). + // The code doing the reconnection attempt is wrapped within an + // executeSoon call so that when multiple settings are changed at + // once we don't attempt to reconnect until they are all saved. + // If a reconnect attempt is already scheduled, we can also return early. + if ( + this._pendingReconnectForConnectionInfoChange || + this.connected || + (this.disconnected && + this.connectionErrorReason == Ci.prplIAccount.NO_ERROR) + ) { + return; + } + + this._pendingReconnectForConnectionInfoChange = true; + executeSoon( + function () { + delete this._pendingReconnectForConnectionInfoChange; + // If the connection parameters have changed while we were + // trying to connect, cancel the ongoing connection attempt and + // try again with the new parameters. + if (this.connecting) { + this.disconnect(); + this.connect(); + return; + } + // If the account was disconnected because of a non-fatal + // connection error, retry now that we have new parameters. + let errorReason = this.connectionErrorReason; + if ( + this.disconnected && + errorReason != Ci.prplIAccount.NO_ERROR && + errorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD && + errorReason != Ci.imIAccount.ERROR_CRASHED && + errorReason != Ci.imIAccount.ERROR_UNKNOWN_PRPL + ) { + this.connect(); + } + }.bind(this) + ); + }, + + // If the protocol plugin is missing, we can't access the normalizedName, + // but in lots of cases this.name is equivalent. + get normalizedName() { + return this.prplAccount ? this.prplAccount.normalizedName : this.name; + }, + normalize(aName) { + return this.prplAccount ? this.prplAccount.normalize(aName) : aName; + }, + + _sendUpdateNotification() { + this._sendNotification("account-updated"); + }, + + set alias(val) { + if (val) { + this.prefBranch.setStringPref(kPrefAccountAlias, val); + } else { + this.prefBranch.clearUserPref(kPrefAccountAlias); + } + this._sendUpdateNotification(); + }, + get alias() { + try { + return this.prefBranch.getStringPref(kPrefAccountAlias); + } catch (e) { + return ""; + } + }, + + _password: "", + get password() { + if (this._password) { + return this._password; + } + + // Avoid prompting the user for the primary password more than once at startup. + if (gUserCanceledPrimaryPasswordPrompt) { + return ""; + } + + let passwordURI = "im://" + this.protocol.id; + let logins; + try { + logins = Services.logins.findLogins(passwordURI, null, passwordURI); + } catch (e) { + this._handlePrimaryPasswordException(e); + return ""; + } + let normalizedName = this.normalizedName; + for (let login of logins) { + if (login.username == normalizedName) { + this._password = login.password; + if ( + this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD + ) { + // We have found a password for an account marked as missing password, + // re-check all others accounts missing a password. But first, + // remove the error on our own account to avoid re-checking it. + delete this._connectionErrorReason; + gAccountsService._checkIfPasswordStillMissing(); + } + return this._password; + } + } + return ""; + }, + _checkIfPasswordStillMissing() { + if ( + this._connectionErrorReason != Ci.imIAccount.ERROR_MISSING_PASSWORD || + !this.password + ) { + return; + } + + delete this._connectionErrorReason; + this._sendUpdateNotification(); + }, + get _passwordRequired() { + return !this.protocol.noPassword && !this.protocol.passwordOptional; + }, + set password(aPassword) { + this._password = aPassword; + if (gUserCanceledPrimaryPasswordPrompt) { + return; + } + let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + let passwordURI = "im://" + this.protocol.id; + newLogin.init( + passwordURI, + null, + passwordURI, + this.normalizedName, + aPassword, + "", + "" + ); + try { + let logins = Services.logins.findLogins(passwordURI, null, passwordURI); + let saved = false; + for (let login of logins) { + if (newLogin.matches(login, true)) { + if (aPassword) { + Services.logins.modifyLogin(login, newLogin); + } else { + Services.logins.removeLogin(login); + } + saved = true; + break; + } + } + if (!saved && aPassword) { + Services.logins.addLogin(newLogin); + } + } catch (e) { + this._handlePrimaryPasswordException(e); + } + + this._connectionInfoChanged(); + if ( + aPassword && + this._connectionErrorReason == Ci.imIAccount.ERROR_MISSING_PASSWORD + ) { + this._connectionErrorReason = Ci.imIAccount.NO_ERROR; + } else if (!aPassword && this._passwordRequired) { + this._connectionErrorReason = Ci.imIAccount.ERROR_MISSING_PASSWORD; + } + this._sendUpdateNotification(); + }, + _handlePrimaryPasswordException(aException) { + if (aException.result != Cr.NS_ERROR_ABORT) { + throw aException; + } + + gUserCanceledPrimaryPasswordPrompt = true; + executeSoon(function () { + gUserCanceledPrimaryPasswordPrompt = false; + }); + }, + + get autoLogin() { + return this.prefBranch.getBoolPref(kPrefAccountAutoLogin, true); + }, + set autoLogin(val) { + this.prefBranch.setBoolPref(kPrefAccountAutoLogin, val); + SavePrefTimer.initTimer(); + this._sendUpdateNotification(); + }, + _autoLoginPending: false, + checkAutoLogin() { + // No auto-login if: the account has an error at the imIAccount level + // (unknown protocol, missing password, first connection crashed), + // the account is already connected or connecting, or autoLogin is off. + if ( + this._connectionErrorReason != Ci.prplIAccount.NO_ERROR || + this.connecting || + this.connected || + !this.autoLogin + ) { + return; + } + + this._autoLoginPending = true; + AutoLoginCounter.startAutoLogin(); + try { + this.connect(); + } catch (e) { + console.error(e); + this._finishedAutoLogin(); + } + }, + _finishedAutoLogin() { + if (!this.hasOwnProperty("_autoLoginPending")) { + return; + } + delete this._autoLoginPending; + AutoLoginCounter.finishedAutoLogin(); + }, + + // Delete the account (from the preferences, mozStorage, and call unInit). + remove() { + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + let passwordURI = "im://" + this.protocol.id; + // Note: the normalizedName may not be exactly right if the + // protocol plugin is missing. + login.init(passwordURI, null, passwordURI, this.normalizedName, "", "", ""); + let logins = Services.logins.findLogins(passwordURI, null, passwordURI); + for (let l of logins) { + if (login.matches(l, true)) { + Services.logins.removeLogin(l); + break; + } + } + if (this.connected || this.connecting) { + this.disconnect(); + } + if (this.prplAccount) { + this.prplAccount.remove(); + } + this.unInit(); + IMServices.contacts.forgetAccount(this.numericId); + for (let prefName of this.prefBranch.getChildList("")) { + this.prefBranch.clearUserPref(prefName); + } + }, + unInit() { + // remove any pending reconnection timer. + this._cancelReconnection(); + + // Keeping a status observer could cause an immediate reconnection. + this._removeStatusObserver(); + + // remove any pending autologin preference used for crash detection. + this._finishedAutoLogin(); + + // If the first connection was pending on quit, we set it back to unknown. + if (this.firstConnectionState == Ci.imIAccount.FIRST_CONNECTION_PENDING) { + this.firstConnectionState = Ci.imIAccount.FIRST_CONNECTION_UNKNOWN; + } + + // and make sure we cleanup the save pref timer. + SavePrefTimer.unInitTimer(); + + if (this.prplAccount) { + this.prplAccount.unInit(); + } + + delete this.protocol; + delete this.prplAccount; + }, + + get _ensurePrplAccount() { + if (this.prplAccount) { + return this.prplAccount; + } + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + connect() { + if (!this.prplAccount) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + if (this._passwordRequired) { + // If the previous connection attempt failed because we have a wrong password, + // clear the passwor cache so that if there's no password in the password + // manager the user gets prompted again. + if ( + this.connectionErrorReason == + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED + ) { + delete this._password; + } + + let password = this.password; + if (!password) { + let prompts = Services.prompt; + let shouldSave = { value: false }; + password = { value: "" }; + if ( + !prompts.promptPassword( + null, + lazy._("passwordPromptTitle", this.name), + lazy._("passwordPromptText", this.name), + password, + lazy._("passwordPromptSaveCheckbox"), + shouldSave + ) + ) { + return; + } + + if (shouldSave.value) { + this.password = password.value; + } else { + this._password = password.value; + } + } + } + + if (!this._statusObserver) { + this._statusObserver = { + observe: function (aSubject, aTopic, aData) { + // Disconnect or reconnect the account automatically, otherwise notify + // the prplAccount instance. + let statusType = aSubject.statusType; + let connectionErrorReason = this.connectionErrorReason; + if (statusType == Ci.imIStatusInfo.STATUS_OFFLINE) { + if (this.connected || this.connecting) { + this.prplAccount.disconnect(); + } + this._cancelReconnection(); + } else if ( + statusType > Ci.imIStatusInfo.STATUS_OFFLINE && + this.disconnected && + (connectionErrorReason == Ci.prplIAccount.NO_ERROR || + connectionErrorReason == Ci.prplIAccount.ERROR_NETWORK_ERROR || + connectionErrorReason == Ci.prplIAccount.ERROR_ENCRYPTION_ERROR) + ) { + this.prplAccount.connect(); + } else if (this.connected) { + this.prplAccount.observe(aSubject, aTopic, aData); + } + }.bind(this), + }; + + this.statusInfo.addObserver(this._statusObserver); + } + + if ( + !Services.io.offline && + this.statusInfo.statusType > Ci.imIStatusInfo.STATUS_OFFLINE && + this.disconnected + ) { + this.prplAccount.connect(); + } + }, + disconnect() { + this._removeStatusObserver(); + if (!this.disconnected) { + this._ensurePrplAccount.disconnect(); + } + }, + + get disconnected() { + return this.connectionState == Ci.imIAccount.STATE_DISCONNECTED; + }, + get connected() { + return this.connectionState == Ci.imIAccount.STATE_CONNECTED; + }, + get connecting() { + return this.connectionState == Ci.imIAccount.STATE_CONNECTING; + }, + get disconnecting() { + return this.connectionState == Ci.imIAccount.STATE_DISCONNECTING; + }, + + _cancelReconnection() { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + delete this._reconnectTimer; + } + delete this.reconnectAttempt; + delete this.timeOfNextReconnect; + }, + cancelReconnection() { + if (!this.disconnected) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // Ensure we don't keep a status observer that could re-enable the + // auto-reconnect timers. + this.disconnect(); + + this._cancelReconnection(); + }, + createConversation(aName) { + return this._ensurePrplAccount.createConversation(aName); + }, + addBuddy(aTag, aName) { + this._ensurePrplAccount.addBuddy(aTag, aName); + }, + loadBuddy(aBuddy, aTag) { + if (this.prplAccount) { + return this.prplAccount.loadBuddy(aBuddy, aTag); + } + // Generate dummy account buddies for unknown protocols. + return new UnknownAccountBuddy(this, aBuddy, aTag); + }, + requestBuddyInfo(aBuddyName) { + this._ensurePrplAccount.requestBuddyInfo(aBuddyName); + }, + getChatRoomFields() { + return this._ensurePrplAccount.getChatRoomFields(); + }, + getChatRoomDefaultFieldValues(aDefaultChatName) { + return this._ensurePrplAccount.getChatRoomDefaultFieldValues( + aDefaultChatName + ); + }, + get canJoinChat() { + return this.prplAccount ? this.prplAccount.canJoinChat : false; + }, + joinChat(aComponents) { + this._ensurePrplAccount.joinChat(aComponents); + }, + setBool(aName, aVal) { + this.prefBranch.setBoolPref(kAccountOptionPrefPrefix + aName, aVal); + this._connectionInfoChanged(); + if (this.prplAccount) { + this.prplAccount.setBool(aName, aVal); + } + SavePrefTimer.initTimer(); + }, + setInt(aName, aVal) { + this.prefBranch.setIntPref(kAccountOptionPrefPrefix + aName, aVal); + this._connectionInfoChanged(); + if (this.prplAccount) { + this.prplAccount.setInt(aName, aVal); + } + SavePrefTimer.initTimer(); + }, + setString(aName, aVal) { + this.prefBranch.setStringPref(kAccountOptionPrefPrefix + aName, aVal); + this._connectionInfoChanged(); + if (this.prplAccount) { + this.prplAccount.setString(aName, aVal); + } + SavePrefTimer.initTimer(); + }, + save() { + SavePrefTimer.saveNow(); + }, + + getSessions() { + return this._ensurePrplAccount.getSessions(); + }, + get encryptionStatus() { + return this._ensurePrplAccount.encryptionStatus; + }, +}; + +var gAccountsService = null; + +export function AccountsService() {} +AccountsService.prototype = { + initAccounts() { + this._initAutoLoginStatus(); + this._accounts = []; + this._accountsById = {}; + gAccountsService = this; + let accountIdArray = MailServices.accounts.accounts + .map(account => account.incomingServer.getCharValue("imAccount")) + .filter(accountKey => accountKey?.startsWith(kAccountKeyPrefix)); + for (let account of accountIdArray) { + new imAccount(account); + } + + this._prefObserver = this.observe.bind(this); + Services.prefs.addObserver(kPrefAccountOrder, this._prefObserver); + }, + + _prefObserver: null, + observe(aSubject, aTopic, aData) { + if (aTopic != "nsPref:changed" || aData != kPrefAccountOrder) { + return; + } + + const imAccounts = MailServices.accounts.accounts + .map(account => account.incomingServer.getCharValue("imAccount")) + .filter(k => k?.startsWith(kAccountKeyPrefix)) + .map(k => + this.getAccountByNumericId(parseInt(k.substr(kAccountKeyPrefix.length))) + ) + .filter(a => a); + + // Only update _accounts if it's a reorder operation + if (imAccounts.length == this._accounts.length) { + this._accounts = imAccounts; + Services.obs.notifyObservers(this, "account-list-updated"); + } + }, + + unInitAccounts() { + for (let account of this._accounts) { + account.unInit(); + } + gAccountsService = null; + delete this._accounts; + delete this._accountsById; + Services.prefs.removeObserver(kPrefAccountOrder, this._prefObserver); + delete this._prefObserver; + }, + + autoLoginStatus: Ci.imIAccountsService.AUTOLOGIN_ENABLED, + _initAutoLoginStatus() { + /* If auto-login is already disabled, do nothing */ + if (this.autoLoginStatus != Ci.imIAccountsService.AUTOLOGIN_ENABLED) { + return; + } + + let prefs = Services.prefs; + if (!prefs.getIntPref("messenger.startup.action")) { + // the value 0 means that we start without connecting the accounts + this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_USER_DISABLED; + return; + } + + /* Disable auto-login if we are running in safe mode */ + if (Services.appinfo.inSafeMode) { + this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_SAFE_MODE; + return; + } + + /* Check if we crashed at the last startup during autologin */ + let autoLoginPending; + if ( + prefs.getPrefType(kPrefAutologinPending) == prefs.PREF_INVALID || + !(autoLoginPending = prefs.getIntPref(kPrefAutologinPending)) + ) { + // if the pref isn't set, then we haven't crashed: keep autologin enabled + return; + } + + // Last autologin hasn't finished properly. + // For now, assume it's because of a crash. + this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_CRASH; + prefs.deleteBranch(kPrefAutologinPending); + + // If the crash reporter isn't built, we can't know anything more. + if (!("nsICrashReporter" in Ci)) { + return; + } + + try { + // Try to get more info with breakpad + let lastCrashTime = 0; + + /* Locate the LastCrash file */ + let lastCrash = Services.dirsvc.get("UAppData", Ci.nsIFile); + lastCrash.append("Crash Reports"); + lastCrash.append("LastCrash"); + if (lastCrash.exists()) { + /* Ok, the file exists, now let's try to read it */ + let is = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + is.init(lastCrash, -1, 0, 0); + sis.init(sis); + + lastCrashTime = parseInt(sis.read(lastCrash.fileSize)); + + sis.close(); + } + // The file not existing is totally acceptable, it just means that + // either we never crashed or breakpad is not enabled. + // In this case, lastCrashTime will keep its 0 initialization value. + + /* dump("autoLoginPending = " + autoLoginPending + + ", lastCrash = " + lastCrashTime + + ", difference = " + lastCrashTime - autoLoginPending + "\n");*/ + + if (lastCrashTime < autoLoginPending) { + // the last crash caught by breakpad is older than our last autologin + // attempt. + // If breakpad is currently enabled, we can be confident that + // autologin was interrupted for an exterior reason + // (application killed by the user, power outage, ...) + try { + Services.appinfo + .QueryInterface(Ci.nsICrashReporter) + .annotateCrashReport("=", ""); + } catch (e) { + // This should fail with NS_ERROR_INVALID_ARG if breakpad is enabled, + // and NS_ERROR_NOT_INITIALIZED if it is not. + if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) { + this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED; + } + } + } + } catch (e) { + // if we failed to get the last crash time, then keep the + // AUTOLOGIN_CRASH value in mAutoLoginStatus and return. + } + }, + + processAutoLogin() { + if (!this._accounts) { + // if we're already shutting down + return; + } + + for (let account of this._accounts) { + account.checkAutoLogin(); + } + + // Make sure autologin is now enabled, so that we don't display a + // message stating that it is disabled and asking the user if it + // should be processed now. + this.autoLoginStatus = Ci.imIAccountsService.AUTOLOGIN_ENABLED; + + // Notify observers so that any message stating that autologin is + // disabled can be removed + Services.obs.notifyObservers(this, "autologin-processed"); + }, + + _checkingIfPasswordStillMissing: false, + _checkIfPasswordStillMissing() { + // Avoid recursion. + if (this._checkingIfPasswordStillMissing) { + return; + } + + this._checkingIfPasswordStillMissing = true; + for (let account of this._accounts) { + account._checkIfPasswordStillMissing(); + } + delete this._checkingIfPasswordStillMissing; + }, + + getAccountById(aAccountId) { + if (!aAccountId.startsWith(kAccountKeyPrefix)) { + throw Components.Exception( + `Invalid id: ${aAccountId}`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + let id = parseInt(aAccountId.substr(kAccountKeyPrefix.length)); + return this.getAccountByNumericId(id); + }, + + _keepAccount(aAccount) { + this._accounts.push(aAccount); + this._accountsById[aAccount.numericId] = aAccount; + }, + getAccountByNumericId(aAccountId) { + return this._accountsById[aAccountId]; + }, + getAccounts() { + return this._accounts; + }, + + createAccount(aName, aPrpl) { + // Ensure an account with the same name and protocol doesn't already exist. + let prpl = IMServices.core.getProtocolById(aPrpl); + if (!prpl) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + if (prpl.accountExists(aName)) { + console.error("Attempted to create a duplicate account!"); + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + + /* First get a unique id for the new account. */ + let id; + for (id = 1; ; ++id) { + if (this._accountsById.hasOwnProperty(id)) { + continue; + } + + /* id isn't used by a known account, double check it isn't + already used in the sqlite database. This should never + happen, except if we have a corrupted profile. */ + if (!IMServices.contacts.accountIdExists(id)) { + break; + } + Services.console.logStringMessage( + "No account " + + id + + " but there is some data in the buddy list for an account with this number. Your profile may be corrupted." + ); + } + + /* Actually create the new account. */ + let key = kAccountKeyPrefix + id; + let account = new imAccount(key, aName, aPrpl); + + Services.obs.notifyObservers(account, "account-added"); + return account; + }, + + deleteAccount(aAccountId) { + let account = this.getAccountById(aAccountId); + if (!account) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let index = this._accounts.indexOf(account); + if (index == -1) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + let id = account.numericId; + account.remove(); + this._accounts.splice(index, 1); + delete this._accountsById[id]; + Services.obs.notifyObservers(account, "account-removed"); + }, + + QueryInterface: ChromeUtils.generateQI(["imIAccountsService"]), + classDescription: "Accounts", +}; diff --git a/comm/chat/components/src/imCommands.sys.mjs b/comm/chat/components/src/imCommands.sys.mjs new file mode 100644 index 0000000000..d28bd9a592 --- /dev/null +++ b/comm/chat/components/src/imCommands.sys.mjs @@ -0,0 +1,289 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/commands.properties") +); + +export function CommandsService() {} +CommandsService.prototype = { + initCommands() { + this._commands = {}; + // The say command is directly implemented in the UI layer, but has a + // dummy command registered here so it shows up as a command (e.g. when + // using the /help command). + this.registerCommand({ + name: "say", + get helpString() { + return lazy._("sayHelpString"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_ALL, + priority: Ci.imICommand.CMD_PRIORITY_HIGH, + run(aMsg, aConv) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + }); + + this.registerCommand({ + name: "raw", + get helpString() { + return lazy._("rawHelpString"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_ALL, + priority: Ci.imICommand.CMD_PRIORITY_DEFAULT, + run(aMsg, aConv) { + let conv = IMServices.conversations.getUIConversation(aConv); + if (!conv) { + return false; + } + conv.sendMsg(aMsg); + return true; + }, + }); + + this.registerCommand({ + // Reference the command service so we can use the internal properties + // directly. + cmdSrv: this, + + name: "help", + get helpString() { + return lazy._("helpHelpString"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_ALL, + priority: Ci.imICommand.CMD_PRIORITY_DEFAULT, + run(aMsg, aConv) { + aMsg = aMsg.trim(); + let conv = IMServices.conversations.getUIConversation(aConv); + if (!conv) { + return false; + } + + // Handle when no command is given, list all possible commands that are + // available for this conversation (alphabetically). + if (!aMsg) { + let commands = this.cmdSrv.listCommandsForConversation(aConv); + if (!commands.length) { + return false; + } + + // Concatenate the command names (separated by a comma and space). + let cmds = commands + .map(aCmd => aCmd.name) + .sort() + .join(", "); + let message = lazy._("commands", cmds); + + // Display the message + conv.systemMessage(message); + return true; + } + + // A command name was given, find the commands that match. + let cmdArray = this.cmdSrv._findCommands(aConv, aMsg); + + if (!cmdArray.length) { + // No command that matches. + let message = lazy._("noCommand", aMsg); + conv.systemMessage(message); + return true; + } + + // Only show the help for the one of the highest priority. + let cmd = cmdArray[0]; + + let text = cmd.helpString; + if (!text) { + text = lazy._("noHelp", cmd.name); + } + + // Display the message. + conv.systemMessage(text); + return true; + }, + }); + + // Status commands + let status = { + back: "AVAILABLE", + away: "AWAY", + busy: "UNAVAILABLE", + dnd: "UNAVAILABLE", + offline: "OFFLINE", + }; + for (let cmd in status) { + let statusValue = Ci.imIStatusInfo["STATUS_" + status[cmd]]; + this.registerCommand({ + name: cmd, + get helpString() { + return lazy._("statusCommand", this.name, lazy._(this.name)); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_ALL, + priority: Ci.imICommand.CMD_PRIORITY_HIGH, + run(aMsg) { + IMServices.core.globalUserStatus.setStatus(statusValue, aMsg); + return true; + }, + }); + } + }, + unInitCommands() { + delete this._commands; + }, + + registerCommand(aCommand, aPrplId) { + let name = aCommand.name; + if (!name) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + if (!this._commands.hasOwnProperty(name)) { + this._commands[name] = {}; + } + this._commands[name][aPrplId || ""] = aCommand; + }, + unregisterCommand(aCommandName, aPrplId) { + if (this._commands.hasOwnProperty(aCommandName)) { + let prplId = aPrplId || ""; + let commands = this._commands[aCommandName]; + if (commands.hasOwnProperty(prplId)) { + delete commands[prplId]; + } + if (!Object.keys(commands).length) { + delete this._commands[aCommandName]; + } + } + }, + listCommandsForConversation(aConversation) { + let result = []; + let prplId = aConversation && aConversation.account.protocol.id; + for (let name in this._commands) { + let commands = this._commands[name]; + if (commands.hasOwnProperty("")) { + result.push(commands[""]); + } + if (prplId && commands.hasOwnProperty(prplId)) { + result.push(commands[prplId]); + } + } + if (aConversation) { + result = result.filter(this._usageContextFilter(aConversation)); + } + return result; + }, + // List only the commands for a protocol (excluding the global commands). + listCommandsForProtocol(aPrplId) { + if (!aPrplId) { + throw new Error("You must provide a prpl ID."); + } + + let result = []; + for (let name in this._commands) { + let commands = this._commands[name]; + if (commands.hasOwnProperty(aPrplId)) { + result.push(commands[aPrplId]); + } + } + return result; + }, + _usageContextFilter(aConversation) { + let usageContext = + Ci.imICommand["CMD_CONTEXT_" + (aConversation.isChat ? "CHAT" : "IM")]; + return c => c.usageContext & usageContext; + }, + _findCommands(aConversation, aName) { + let prplId = null; + if (aConversation) { + let account = aConversation.account; + if (account.connected) { + prplId = account.protocol.id; + } + } + + let commandNames; + // If there is an exact match for the given command name, + // don't look at any other commands. + if (this._commands.hasOwnProperty(aName)) { + commandNames = [aName]; + } else { + // Otherwise, check if there is a partial match. + commandNames = Object.keys(this._commands).filter(command => + command.startsWith(aName) + ); + } + + // If a single full command name matches the given (partial) + // command name, return the results for that command name. Otherwise, + // return an empty array (don't assume a certain command). + let cmdArray = []; + for (let commandName of commandNames) { + let matches = []; + + // Get the 2 possible commands (the global and the proto specific). + let commands = this._commands[commandName]; + if (commands.hasOwnProperty("")) { + matches.push(commands[""]); + } + if (prplId && commands.hasOwnProperty(prplId)) { + matches.push(commands[prplId]); + } + + // Remove the commands that can't apply in this context. + if (aConversation) { + matches = matches.filter(this._usageContextFilter(aConversation)); + } + + if (!matches.length) { + continue; + } + + // If we have found a second matching command name, return the empty array. + if (cmdArray.length) { + return []; + } + + cmdArray = matches; + } + + // Sort the matching commands by priority before returning the array. + return cmdArray.sort((a, b) => b.priority - a.priority); + }, + executeCommand(aMessage, aConversation, aReturnedConv) { + if (!aMessage) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let matchResult; + if ( + aMessage[0] != "/" || + !(matchResult = /^\/([a-z0-9]+)(?: |$)([\s\S]*)/.exec(aMessage)) + ) { + return false; + } + + let [, name, args] = matchResult; + + let cmdArray = this._findCommands(aConversation, name); + if (!cmdArray.length) { + return false; + } + + // cmdArray contains commands sorted by priority, attempt to apply + // them in order until one succeeds. + if (!cmdArray.some(aCmd => aCmd.run(args, aConversation, aReturnedConv))) { + // If they all failed, print help message. + this.executeCommand("/help " + name, aConversation); + } + return true; + }, + + QueryInterface: ChromeUtils.generateQI(["imICommandsService"]), + classDescription: "Commands", +}; diff --git a/comm/chat/components/src/imContacts.sys.mjs b/comm/chat/components/src/imContacts.sys.mjs new file mode 100644 index 0000000000..c902cf4623 --- /dev/null +++ b/comm/chat/components/src/imContacts.sys.mjs @@ -0,0 +1,1809 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + executeSoon, + ClassInfo, + l10nHelper, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/contacts.properties") +); + +var gDBConnection = null; + +function executeAsyncThenFinalize(statement) { + statement.executeAsync(); + statement.finalize(); +} + +function getDBConnection() { + const NS_APP_USER_PROFILE_50_DIR = "ProfD"; + let dbFile = Services.dirsvc.get(NS_APP_USER_PROFILE_50_DIR, Ci.nsIFile); + dbFile.append("blist.sqlite"); + + let conn = Services.storage.openDatabase(dbFile); + if (!conn.connectionReady) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // Grow blist db in 512KB increments. + try { + conn.setGrowthIncrement(512 * 1024, ""); + } catch (e) { + if (e.result == Cr.NS_ERROR_FILE_TOO_BIG) { + Services.console.logStringMessage( + "Not setting growth increment on " + + "blist.sqlite because the available " + + "disk space is limited" + ); + } else { + throw e; + } + } + + // Create tables and indexes. + [ + "CREATE TABLE IF NOT EXISTS accounts (" + + "id INTEGER PRIMARY KEY, " + + "name VARCHAR, " + + "prpl VARCHAR)", + + "CREATE TABLE IF NOT EXISTS contacts (" + + "id INTEGER PRIMARY KEY, " + + "firstname VARCHAR, " + + "lastname VARCHAR, " + + "alias VARCHAR)", + + "CREATE TABLE IF NOT EXISTS buddies (" + + "id INTEGER PRIMARY KEY, " + + "key VARCHAR NOT NULL, " + + "name VARCHAR NOT NULL, " + + "srv_alias VARCHAR, " + + "position INTEGER, " + + "icon BLOB, " + + "contact_id INTEGER)", + "CREATE INDEX IF NOT EXISTS buddies_contactindex " + + "ON buddies (contact_id)", + + "CREATE TABLE IF NOT EXISTS tags (" + + "id INTEGER PRIMARY KEY, " + + "name VARCHAR UNIQUE NOT NULL, " + + "position INTEGER)", + + "CREATE TABLE IF NOT EXISTS contact_tag (" + + "contact_id INTEGER NOT NULL, " + + "tag_id INTEGER NOT NULL)", + "CREATE INDEX IF NOT EXISTS contact_tag_contactindex " + + "ON contact_tag (contact_id)", + "CREATE INDEX IF NOT EXISTS contact_tag_tagindex " + + "ON contact_tag (tag_id)", + + "CREATE TABLE IF NOT EXISTS account_buddy (" + + "account_id INTEGER NOT NULL, " + + "buddy_id INTEGER NOT NULL, " + + "status VARCHAR, " + + "tag_id INTEGER)", + "CREATE INDEX IF NOT EXISTS account_buddy_accountindex " + + "ON account_buddy (account_id)", + "CREATE INDEX IF NOT EXISTS account_buddy_buddyindex " + + "ON account_buddy (buddy_id)", + ].forEach(conn.executeSimpleSQL); + + return conn; +} + +// Wrap all the usage of DBConn inside a transaction that will be +// committed automatically at the end of the event loop spin so that +// we flush buddy list data to disk only once per event loop spin. +var gDBConnWithPendingTransaction = null; +Object.defineProperty(lazy, "DBConn", { + configurable: true, + enumerable: true, + + get() { + if (gDBConnWithPendingTransaction) { + return gDBConnWithPendingTransaction; + } + + if (!gDBConnection) { + gDBConnection = getDBConnection(); + Services.obs.addObserver(function dbClose(aSubject, aTopic, aData) { + Services.obs.removeObserver(dbClose, aTopic); + if (gDBConnection) { + gDBConnection.asyncClose(); + gDBConnection = null; + } + }, "profile-before-change"); + } + gDBConnWithPendingTransaction = gDBConnection; + gDBConnection.beginTransaction(); + executeSoon(function () { + gDBConnWithPendingTransaction.commitTransaction(); + gDBConnWithPendingTransaction = null; + }); + return gDBConnection; + }, +}); + +export function TagsService() {} +TagsService.prototype = { + get wrappedJSObject() { + return this; + }, + get defaultTag() { + return this.createTag(lazy._("defaultGroup")); + }, + createTag(aName) { + // If the tag already exists, we don't want to create a duplicate. + let tag = this.getTagByName(aName); + if (tag) { + return tag; + } + + let statement = lazy.DBConn.createStatement( + "INSERT INTO tags (name, position) VALUES(:name, 0)" + ); + try { + statement.params.name = aName; + statement.executeStep(); + } finally { + statement.finalize(); + } + + tag = new Tag(lazy.DBConn.lastInsertRowID, aName); + Tags.push(tag); + return tag; + }, + // Get an existing tag by (numeric) id. Returns null if not found. + getTagById: aId => TagsById[aId], + // Get an existing tag by name (will do an SQL query). Returns null + // if not found. + getTagByName(aName) { + let statement = lazy.DBConn.createStatement( + "SELECT id FROM tags where name = :name" + ); + statement.params.name = aName; + try { + if (!statement.executeStep()) { + return null; + } + return this.getTagById(statement.row.id); + } finally { + statement.finalize(); + } + }, + // Get an array of all existing tags. + getTags() { + if (Tags.length) { + Tags.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + } else { + this.defaultTag; + } + + return Tags; + }, + + isTagHidden: aTag => aTag.id in otherContactsTag._hiddenTags, + hideTag(aTag) { + otherContactsTag.hideTag(aTag); + }, + showTag(aTag) { + otherContactsTag.showTag(aTag); + }, + get otherContactsTag() { + otherContactsTag._initContacts(); + return otherContactsTag; + }, + + QueryInterface: ChromeUtils.generateQI(["imITagsService"]), + classDescription: "Tags", +}; + +// TODO move into the tagsService +var Tags = []; +var TagsById = {}; + +function Tag(aId, aName) { + this._id = aId; + this._name = aName; + this._contacts = []; + this._observers = []; + + TagsById[this.id] = this; +} +Tag.prototype = { + __proto__: ClassInfo("imITag", "Tag"), + get id() { + return this._id; + }, + get name() { + return this._name; + }, + set name(aNewName) { + let statement = lazy.DBConn.createStatement( + "UPDATE tags SET name = :name WHERE id = :id" + ); + try { + statement.params.name = aNewName; + statement.params.id = this._id; + statement.execute(); + } finally { + statement.finalize(); + } + + // FIXME move the account buddies if some use this tag as their group + }, + getContacts() { + return this._contacts.filter(c => !c._empty); + }, + _addContact(aContact) { + this._contacts.push(aContact); + }, + _removeContact(aContact) { + let index = this._contacts.indexOf(aContact); + if (index != -1) { + this._contacts.splice(index, 1); + } + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + observer.observe(aSubject, aTopic, aData); + } + }, +}; + +var otherContactsTag = { + __proto__: ClassInfo(["nsIObserver", "imITag"], "Other Contacts Tag"), + hiddenTagsPref: "messenger.buddies.hiddenTags", + _hiddenTags: {}, + _contactsInitialized: false, + _saveHiddenTagsPref() { + Services.prefs.setCharPref( + this.hiddenTagsPref, + Object.keys(this._hiddenTags).join(",") + ); + }, + showTag(aTag) { + let id = aTag.id; + delete this._hiddenTags[id]; + let contacts = Object.keys(this._contacts).map(id => this._contacts[id]); + for (let contact of contacts) { + if (contact.getTags().some(t => t.id == id)) { + this._removeContact(contact); + } + } + + aTag.notifyObservers(aTag, "tag-shown"); + Services.obs.notifyObservers(aTag, "tag-shown"); + this._saveHiddenTagsPref(); + }, + hideTag(aTag) { + if (aTag.id < 0 || aTag.id in otherContactsTag._hiddenTags) { + return; + } + + this._hiddenTags[aTag.id] = aTag; + if (this._contactsInitialized) { + this._hideTag(aTag); + } + + aTag.notifyObservers(aTag, "tag-hidden"); + Services.obs.notifyObservers(aTag, "tag-hidden"); + this._saveHiddenTagsPref(); + }, + _hideTag(aTag) { + for (let contact of aTag.getContacts()) { + if ( + !(contact.id in this._contacts) && + contact.getTags().every(t => t.id in this._hiddenTags) + ) { + this._addContact(contact); + } + } + }, + observe(aSubject, aTopic, aData) { + aSubject.QueryInterface(Ci.imIContact); + if (aTopic == "contact-tag-removed" || aTopic == "contact-added") { + if ( + !(aSubject.id in this._contacts) && + !(parseInt(aData) in this._hiddenTags) && + aSubject.getTags().every(t => t.id in this._hiddenTags) + ) { + this._addContact(aSubject); + } + } else if ( + aSubject.id in this._contacts && + (aTopic == "contact-removed" || + (aTopic == "contact-tag-added" && + !(parseInt(aData) in this._hiddenTags))) + ) { + this._removeContact(aSubject); + } + }, + + _initHiddenTags() { + let pref = Services.prefs.getCharPref(this.hiddenTagsPref); + if (!pref) { + return; + } + for (let tagId of pref.split(",")) { + this._hiddenTags[tagId] = TagsById[tagId]; + } + }, + _initContacts() { + if (this._contactsInitialized) { + return; + } + this._observers = []; + this._observer = { + self: this, + observe(aSubject, aTopic, aData) { + if (aTopic == "contact-moved-in" && !(aSubject instanceof Contact)) { + return; + } + + this.self.notifyObservers(aSubject, aTopic, aData); + }, + }; + this._contacts = {}; + this._contactsInitialized = true; + for (let id in this._hiddenTags) { + let tag = this._hiddenTags[id]; + this._hideTag(tag); + } + Services.obs.addObserver(this, "contact-tag-added"); + Services.obs.addObserver(this, "contact-tag-removed"); + Services.obs.addObserver(this, "contact-added"); + Services.obs.addObserver(this, "contact-removed"); + }, + + // imITag implementation + get id() { + return -1; + }, + get name() { + return "__others__"; + }, + set name(aNewName) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + }, + getContacts() { + return Object.keys(this._contacts).map(id => this._contacts[id]); + }, + _addContact(aContact) { + this._contacts[aContact.id] = aContact; + this.notifyObservers(aContact, "contact-moved-in"); + for (let observer of ContactsById[aContact.id]._observers) { + observer.observe(this, "contact-moved-in", null); + } + aContact.addObserver(this._observer); + }, + _removeContact(aContact) { + delete this._contacts[aContact.id]; + aContact.removeObserver(this._observer); + this.notifyObservers(aContact, "contact-moved-out"); + for (let observer of ContactsById[aContact.id]._observers) { + observer.observe(this, "contact-moved-out", null); + } + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + observer.observe(aSubject, aTopic, aData); + } + }, +}; + +var ContactsById = {}; +var LastDummyContactId = 0; +function Contact(aId, aAlias) { + // Assign a negative id to dummy contacts that have a single buddy + this._id = aId || --LastDummyContactId; + this._alias = aAlias; + this._tags = []; + this._buddies = []; + this._observers = []; + + ContactsById[this._id] = this; +} +Contact.prototype = { + __proto__: ClassInfo("imIContact", "Contact"), + _id: 0, + get id() { + return this._id; + }, + get alias() { + return this._alias; + }, + set alias(aNewAlias) { + this._ensureNotDummy(); + + let statement = lazy.DBConn.createStatement( + "UPDATE contacts SET alias = :alias WHERE id = :id" + ); + statement.params.alias = aNewAlias; + statement.params.id = this._id; + executeAsyncThenFinalize(statement); + + let oldDisplayName = this.displayName; + this._alias = aNewAlias; + this._notifyObservers("display-name-changed", oldDisplayName); + for (let buddy of this._buddies) { + for (let accountBuddy of buddy._accounts) { + accountBuddy.serverAlias = aNewAlias; + } + } + }, + _ensureNotDummy() { + if (this._id >= 0) { + return; + } + + // Create a real contact for this dummy contact + let statement = lazy.DBConn.createStatement( + "INSERT INTO contacts DEFAULT VALUES" + ); + try { + statement.execute(); + } finally { + statement.finalize(); + } + delete ContactsById[this._id]; + let oldId = this._id; + this._id = lazy.DBConn.lastInsertRowID; + ContactsById[this._id] = this; + this._notifyObservers("no-longer-dummy", oldId.toString()); + // Update the contact_id for the single existing buddy of this contact + statement = lazy.DBConn.createStatement( + "UPDATE buddies SET contact_id = :id WHERE id = :buddy_id" + ); + statement.params.id = this._id; + statement.params.buddy_id = this._buddies[0].id; + executeAsyncThenFinalize(statement); + }, + + getTags() { + return this._tags; + }, + addTag(aTag, aInherited) { + if (this.hasTag(aTag)) { + return; + } + + if (!aInherited) { + this._ensureNotDummy(); + let statement = lazy.DBConn.createStatement( + "INSERT INTO contact_tag (contact_id, tag_id) " + + "VALUES(:contactId, :tagId)" + ); + statement.params.contactId = this.id; + statement.params.tagId = aTag.id; + executeAsyncThenFinalize(statement); + } + + aTag = TagsById[aTag.id]; + this._tags.push(aTag); + aTag._addContact(this); + + aTag.notifyObservers(this, "contact-moved-in"); + for (let observer of this._observers) { + observer.observe(aTag, "contact-moved-in", null); + } + Services.obs.notifyObservers(this, "contact-tag-added", aTag.id); + }, + /* Remove a tag from the local tags of the contact. */ + _removeTag(aTag) { + if (!this.hasTag(aTag) || this._isTagInherited(aTag)) { + return; + } + + this._removeContactTagRow(aTag); + + this._tags = this._tags.filter(tag => tag.id != aTag.id); + aTag = TagsById[aTag.id]; + aTag._removeContact(this); + + aTag.notifyObservers(this, "contact-moved-out"); + for (let observer of this._observers) { + observer.observe(aTag, "contact-moved-out", null); + } + Services.obs.notifyObservers(this, "contact-tag-removed", aTag.id); + }, + _removeContactTagRow(aTag) { + let statement = lazy.DBConn.createStatement( + "DELETE FROM contact_tag " + + "WHERE contact_id = :contactId " + + "AND tag_id = :tagId" + ); + statement.params.contactId = this.id; + statement.params.tagId = aTag.id; + executeAsyncThenFinalize(statement); + }, + hasTag(aTag) { + return this._tags.some(t => t.id == aTag.id); + }, + _massMove: false, + removeTag(aTag) { + if (!this.hasTag(aTag)) { + throw new Error( + "Attempting to remove a tag that the contact doesn't have" + ); + } + if (this._tags.length == 1) { + throw new Error("Attempting to remove the last tag of a contact"); + } + + this._massMove = true; + let hasTag = this.hasTag.bind(this); + let newTag = this._tags[this._tags[0].id != aTag.id ? 0 : 1]; + let moved = false; + this._buddies.forEach(function (aBuddy) { + aBuddy._accounts.forEach(function (aAccountBuddy) { + if (aAccountBuddy.tag.id == aTag.id) { + if ( + aBuddy._accounts.some( + ab => + ab.account.numericId == aAccountBuddy.account.numericId && + ab.tag.id != aTag.id && + hasTag(ab.tag) + ) + ) { + // A buddy that already has an accountBuddy of the same + // account with another tag of the contact shouldn't be + // moved to newTag, just remove the accountBuddy + // associated to the tag we are removing. + aAccountBuddy.remove(); + moved = true; + } else { + try { + aAccountBuddy.tag = newTag; + moved = true; + } catch (e) { + // Ignore failures. Some protocol plugins may not implement this. + } + } + } + }); + }); + this._massMove = false; + if (moved) { + this._moved(aTag, newTag); + } else { + // If we are here, the old tag is not inherited from a buddy, so + // just remove the local tag. + this._removeTag(aTag); + } + }, + _isTagInherited(aTag) { + for (let buddy of this._buddies) { + for (let accountBuddy of buddy._accounts) { + if (accountBuddy.tag.id == aTag.id) { + return true; + } + } + } + return false; + }, + _moved(aOldTag, aNewTag) { + if (this._massMove) { + return; + } + + // Avoid xpconnect wrappers. + aNewTag = aNewTag && TagsById[aNewTag.id]; + aOldTag = aOldTag && TagsById[aOldTag.id]; + + // Decide what we need to do. Return early if nothing to do. + let shouldRemove = + aOldTag && this.hasTag(aOldTag) && !this._isTagInherited(aOldTag); + let shouldAdd = + aNewTag && !this.hasTag(aNewTag) && this._isTagInherited(aNewTag); + if (!shouldRemove && !shouldAdd) { + return; + } + + // Apply the changes. + let tags = this._tags; + if (shouldRemove) { + tags = tags.filter(aTag => aTag.id != aOldTag.id); + aOldTag._removeContact(this); + } + if (shouldAdd) { + tags.push(aNewTag); + aNewTag._addContact(this); + } + this._tags = tags; + + // Finally, notify of the changes. + if (shouldRemove) { + aOldTag.notifyObservers(this, "contact-moved-out"); + for (let observer of this._observers) { + observer.observe(aOldTag, "contact-moved-out", null); + } + Services.obs.notifyObservers(this, "contact-tag-removed", aOldTag.id); + } + if (shouldAdd) { + aNewTag.notifyObservers(this, "contact-moved-in"); + for (let observer of this._observers) { + observer.observe(aNewTag, "contact-moved-in", null); + } + Services.obs.notifyObservers(this, "contact-tag-added", aNewTag.id); + } + Services.obs.notifyObservers(this, "contact-moved"); + }, + + getBuddies() { + return this._buddies; + }, + get _empty() { + return this._buddies.length == 0 || this._buddies.every(b => b._empty); + }, + + mergeContact(aContact) { + // Avoid merging the contact with itself or merging into an + // already removed contact. + if (aContact.id == this.id || !(this.id in ContactsById)) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + this._ensureNotDummy(); + let contact = ContactsById[aContact.id]; // remove XPConnect wrapper + + // Copy all the contact-only tags first, otherwise they would be lost. + for (let tag of contact.getTags()) { + if (!contact._isTagInherited(tag)) { + this.addTag(tag); + } + } + + // Adopt each buddy. Removing the last one will delete the contact. + for (let buddy of contact.getBuddies()) { + buddy.contact = this; + } + this._updatePreferredBuddy(); + }, + moveBuddyBefore(aBuddy, aBeforeBuddy) { + let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper + let oldPosition = this._buddies.indexOf(buddy); + if (oldPosition == -1) { + throw new Error("aBuddy isn't attached to this contact"); + } + + let newPosition = -1; + if (aBeforeBuddy) { + newPosition = this._buddies.indexOf(BuddiesById[aBeforeBuddy.id]); + } + if (newPosition == -1) { + newPosition = this._buddies.length - 1; + } + + if (oldPosition == newPosition) { + return; + } + + this._buddies.splice(oldPosition, 1); + this._buddies.splice(newPosition, 0, buddy); + this._updatePositions( + Math.min(oldPosition, newPosition), + Math.max(oldPosition, newPosition) + ); + buddy._notifyObservers("position-changed", String(newPosition)); + this._updatePreferredBuddy(buddy); + }, + adoptBuddy(aBuddy) { + if (aBuddy.contact.id == this.id) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let buddy = BuddiesById[aBuddy.id]; // remove XPConnect wrapper + buddy.contact = this; + this._updatePreferredBuddy(buddy); + }, + _massRemove: false, + _removeBuddy(aBuddy) { + if (this._buddies.length == 1) { + if (this._id > 0) { + let statement = lazy.DBConn.createStatement( + "DELETE FROM contacts WHERE id = :id" + ); + statement.params.id = this._id; + executeAsyncThenFinalize(statement); + } + this._notifyObservers("removed"); + delete ContactsById[this._id]; + + for (let tag of this._tags) { + tag._removeContact(this); + } + let statement = lazy.DBConn.createStatement( + "DELETE FROM contact_tag WHERE contact_id = :id" + ); + statement.params.id = this._id; + executeAsyncThenFinalize(statement); + + delete this._tags; + delete this._buddies; + delete this._observers; + } else { + let index = this._buddies.indexOf(aBuddy); + if (index == -1) { + throw new Error("Removing an unknown buddy from contact " + this._id); + } + + this._buddies = this._buddies.filter(b => b !== aBuddy); + + // If we are actually removing the whole contact, don't bother updating + // the positions or the preferred buddy. + if (this._massRemove) { + return; + } + + // No position to update if the removed buddy is at the last position. + if (index < this._buddies.length) { + this._updatePositions(index); + } + + if (this._preferredBuddy.id == aBuddy.id) { + this._updatePreferredBuddy(); + } + } + }, + _updatePositions(aIndexBegin, aIndexEnd) { + if (aIndexEnd === undefined) { + aIndexEnd = this._buddies.length - 1; + } + if (aIndexBegin > aIndexEnd) { + throw new Error("_updatePositions: Invalid indexes"); + } + + let statement = lazy.DBConn.createStatement( + "UPDATE buddies SET position = :position WHERE id = :buddyId" + ); + for (let i = aIndexBegin; i <= aIndexEnd; ++i) { + statement.params.position = i; + statement.params.buddyId = this._buddies[i].id; + statement.executeAsync(); + } + statement.finalize(); + }, + + detachBuddy(aBuddy) { + // Should return a new contact with the same list of tags. + let buddy = BuddiesById[aBuddy.id]; + if (buddy.contact.id != this.id) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + if (buddy.contact._buddies.length == 1) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // Save the list of tags, it may be destroyed if the buddy was the last one. + let tags = buddy.contact.getTags(); + + // Create a new dummy contact and use it for the detached buddy. + buddy.contact = new Contact(); + buddy.contact._notifyObservers("added"); + + // The first tag was inherited during the contact setter. + // This will copy the remaining tags. + for (let tag of tags) { + buddy.contact.addTag(tag); + } + + return buddy.contact; + }, + remove() { + this._massRemove = true; + for (let buddy of this._buddies) { + buddy.remove(); + } + }, + + // imIStatusInfo implementation + _preferredBuddy: null, + get preferredBuddy() { + if (!this._preferredBuddy) { + this._updatePreferredBuddy(); + } + return this._preferredBuddy; + }, + set preferredBuddy(aBuddy) { + let shouldNotify = this._preferredBuddy != null; + let oldDisplayName = + this._preferredBuddy && this._preferredBuddy.displayName; + this._preferredBuddy = aBuddy; + if (shouldNotify) { + this._notifyObservers("preferred-buddy-changed"); + } + if (oldDisplayName && this._preferredBuddy.displayName != oldDisplayName) { + this._notifyObservers("display-name-changed", oldDisplayName); + } + this._updateStatus(); + }, + // aBuddy indicate which buddy's availability has changed. + _updatePreferredBuddy(aBuddy) { + if (aBuddy) { + aBuddy = BuddiesById[aBuddy.id]; // remove potential XPConnect wrapper + + if (!this._preferredBuddy) { + this.preferredBuddy = aBuddy; + return; + } + + if (aBuddy.id == this._preferredBuddy.id) { + // The suggested buddy is already preferred, check if its + // availability has changed. + if ( + aBuddy.statusType > this._statusType || + (aBuddy.statusType == this._statusType && + aBuddy.availabilityDetails >= this._availabilityDetails) + ) { + // keep the currently preferred buddy, only update the status. + this._updateStatus(); + return; + } + // We aren't sure that the currently preferred buddy should + // still be preferred. Let's go through the list! + } else { + // The suggested buddy is not currently preferred. If it is + // more available or at a better position, prefer it! + if ( + aBuddy.statusType > this._statusType || + (aBuddy.statusType == this._statusType && + (aBuddy.availabilityDetails > this._availabilityDetails || + (aBuddy.availabilityDetails == this._availabilityDetails && + this._buddies.indexOf(aBuddy) < + this._buddies.indexOf(this.preferredBuddy)))) + ) { + this.preferredBuddy = aBuddy; + } + return; + } + } + + let preferred; + // |this._buddies| is ordered by user preference, so in case of + // equal availability, keep the current value of |preferred|. + for (let buddy of this._buddies) { + if ( + !preferred || + preferred.statusType < buddy.statusType || + (preferred.statusType == buddy.statusType && + preferred.availabilityDetails < buddy.availabilityDetails) + ) { + preferred = buddy; + } + } + if ( + preferred && + (!this._preferredBuddy || preferred.id != this._preferredBuddy.id) + ) { + this.preferredBuddy = preferred; + } + }, + _updateStatus() { + let buddy = this._preferredBuddy; // for convenience + + // Decide which notifications should be fired. + let notifications = []; + if ( + this._statusType != buddy.statusType || + this._availabilityDetails != buddy.availabilityDetails + ) { + notifications.push("availability-changed"); + } + if ( + this._statusType != buddy.statusType || + this._statusText != buddy.statusText + ) { + notifications.push("status-changed"); + if (this.online && buddy.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-off"); + } + if (!this.online && buddy.statusType > Ci.imIStatusInfo.STATUS_OFFLINE) { + notifications.push("signed-on"); + } + } + + // Actually change the stored status. + [this._statusType, this._statusText, this._availabilityDetails] = [ + buddy.statusType, + buddy.statusText, + buddy.availabilityDetails, + ]; + + // Fire the notifications. + notifications.forEach(function (aTopic) { + this._notifyObservers(aTopic); + }, this); + }, + get displayName() { + return this._alias || this.preferredBuddy.displayName; + }, + get buddyIconFilename() { + return this.preferredBuddy.buddyIconFilename; + }, + _statusType: 0, + get statusType() { + return this._statusType; + }, + get online() { + return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE; + }, + get available() { + return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE; + }, + get idle() { + return this.statusType == Ci.imIStatusInfo.STATUS_IDLE; + }, + get mobile() { + return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE; + }, + _statusText: "", + get statusText() { + return this._statusText; + }, + _availabilityDetails: 0, + get availabilityDetails() { + return this._availabilityDetails; + }, + get canSendMessage() { + return this.preferredBuddy.canSendMessage; + }, + // XXX should we list the buddies in the tooltip? + getTooltipInfo() { + return this.preferredBuddy.getTooltipInfo(); + }, + createConversation() { + let uiConv = IMServices.conversations.getUIConversationByContactId(this.id); + if (uiConv) { + return uiConv.target; + } + return this.preferredBuddy.createConversation(); + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + if (!this.hasOwnProperty("_observers")) { + return; + } + + this._observers = this._observers.filter(o => o !== aObserver); + }, + // internal calls + calls from add-ons + notifyObservers(aSubject, aTopic, aData) { + for (let observer of this._observers) { + if ("observe" in observer) { + // avoid failing on destructed XBL bindings... + observer.observe(aSubject, aTopic, aData); + } + } + for (let tag of this._tags) { + tag.notifyObservers(aSubject, aTopic, aData); + } + Services.obs.notifyObservers(aSubject, aTopic, aData); + }, + _notifyObservers(aTopic, aData) { + this.notifyObservers(this, "contact-" + aTopic, aData); + }, + + // This is called by the imIBuddy implementations. + _observe(aSubject, aTopic, aData) { + // Forward the notification. + this.notifyObservers(aSubject, aTopic, aData); + + let isPreferredBuddy = + aSubject instanceof Buddy && aSubject.id == this.preferredBuddy.id; + switch (aTopic) { + case "buddy-availability-changed": + this._updatePreferredBuddy(aSubject); + break; + case "buddy-status-changed": + if (isPreferredBuddy) { + this._updateStatus(); + } + break; + case "buddy-display-name-changed": + if (isPreferredBuddy && !this._alias) { + this._notifyObservers("display-name-changed", aData); + } + break; + case "buddy-icon-changed": + if (isPreferredBuddy) { + this._notifyObservers("icon-changed"); + } + break; + case "buddy-added": + // Currently buddies are always added in dummy empty contacts, + // later we may want to check this._buddies.length == 1. + this._notifyObservers("added"); + break; + case "buddy-removed": + this._removeBuddy(aSubject); + } + }, +}; + +var BuddiesById = {}; +function Buddy(aId, aKey, aName, aSrvAlias, aContactId) { + this._id = aId; + this._key = aKey; + this._name = aName; + if (aSrvAlias) { + this._srvAlias = aSrvAlias; + } + this._accounts = []; + this._observers = []; + + if (aContactId) { + this._contact = ContactsById[aContactId]; + } + // Avoid failure if aContactId was invalid. + if (!this._contact) { + this._contact = new Contact(null, null); + } + + this._contact._buddies.push(this); + + BuddiesById[this._id] = this; +} +Buddy.prototype = { + __proto__: ClassInfo("imIBuddy", "Buddy"), + get id() { + return this._id; + }, + destroy() { + for (let ab of this._accounts) { + ab.unInit(); + } + delete this._accounts; + delete this._observers; + delete this._preferredAccount; + }, + get protocol() { + return this._accounts[0].account.protocol; + }, + get userName() { + return this._name; + }, + get normalizedName() { + return this._key; + }, + _srvAlias: "", + _contact: null, + get contact() { + return this._contact; + }, + set contact(aContact) /* not in imIBuddy */ { + if (aContact.id == this._contact.id) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + this._notifyObservers("moved-out-of-contact"); + this._contact._removeBuddy(this); + + this._contact = aContact; + this._contact._buddies.push(this); + + // Ensure all the inherited tags are in the new contact. + for (let accountBuddy of this._accounts) { + this._contact.addTag(TagsById[accountBuddy.tag.id], true); + } + + let statement = lazy.DBConn.createStatement( + "UPDATE buddies SET contact_id = :contactId, " + + "position = :position " + + "WHERE id = :buddyId" + ); + statement.params.contactId = aContact.id > 0 ? aContact.id : 0; + statement.params.position = aContact._buddies.length - 1; + statement.params.buddyId = this.id; + executeAsyncThenFinalize(statement); + + this._notifyObservers("moved-into-contact"); + }, + _hasAccountBuddy(aAccountId, aTagId) { + for (let ab of this._accounts) { + if (ab.account.numericId == aAccountId && ab.tag.id == aTagId) { + return true; + } + } + return false; + }, + getAccountBuddies() { + return this._accounts; + }, + + _addAccount(aAccountBuddy, aTag) { + this._accounts.push(aAccountBuddy); + let contact = this._contact; + if (!this._contact._tags.includes(aTag)) { + this._contact._tags.push(aTag); + aTag._addContact(contact); + } + + if (!this._preferredAccount) { + this._preferredAccount = aAccountBuddy; + } + }, + get _empty() { + return this._accounts.length == 0; + }, + + remove() { + for (let account of this._accounts) { + account.remove(); + } + }, + + // imIStatusInfo implementation + _preferredAccount: null, + get preferredAccountBuddy() { + return this._preferredAccount; + }, + _isPreferredAccount(aAccountBuddy) { + if ( + aAccountBuddy.account.numericId != + this._preferredAccount.account.numericId + ) { + return false; + } + + // In case we have more than one accountBuddy for the same buddy + // and account (possible if the buddy is in several groups on the + // server), the protocol plugin may be broken and not update all + // instances, so ensure we handle the notifications on the instance + // that is currently being notified of a change: + this._preferredAccount = aAccountBuddy; + + return true; + }, + set preferredAccount(aAccount) { + let oldDisplayName = + this._preferredAccount && this._preferredAccount.displayName; + this._preferredAccount = aAccount; + this._notifyObservers("preferred-account-changed"); + if ( + oldDisplayName && + this._preferredAccount.displayName != oldDisplayName + ) { + this._notifyObservers("display-name-changed", oldDisplayName); + } + this._updateStatus(); + }, + // aAccount indicate which account's availability has changed. + _updatePreferredAccount(aAccount) { + if (aAccount) { + if ( + aAccount.account.numericId == this._preferredAccount.account.numericId + ) { + // The suggested account is already preferred, check if its + // availability has changed. + if ( + aAccount.statusType > this._statusType || + (aAccount.statusType == this._statusType && + aAccount.availabilityDetails >= this._availabilityDetails) + ) { + // keep the currently preferred account, only update the status. + this._updateStatus(); + return; + } + // We aren't sure that the currently preferred account should + // still be preferred. Let's go through the list! + } else { + // The suggested account is not currently preferred. If it is + // more available, prefer it! + if ( + aAccount.statusType > this._statusType || + (aAccount.statusType == this._statusType && + aAccount.availabilityDetails > this._availabilityDetails) + ) { + this.preferredAccount = aAccount; + } + return; + } + } + + let preferred; + // TODO take into account the order of the account-manager list. + for (let account of this._accounts) { + if ( + !preferred || + preferred.statusType < account.statusType || + (preferred.statusType == account.statusType && + preferred.availabilityDetails < account.availabilityDetails) + ) { + preferred = account; + } + } + if (!this._preferredAccount) { + if (preferred) { + this.preferredAccount = preferred; + } + return; + } + if ( + preferred.account.numericId != this._preferredAccount.account.numericId + ) { + this.preferredAccount = preferred; + } else { + this._updateStatus(); + } + }, + _updateStatus() { + let account = this._preferredAccount; // for convenience + + // Decide which notifications should be fired. + let notifications = []; + if ( + this._statusType != account.statusType || + this._availabilityDetails != account.availabilityDetails + ) { + notifications.push("availability-changed"); + } + if ( + this._statusType != account.statusType || + this._statusText != account.statusText + ) { + notifications.push("status-changed"); + if ( + this.online && + account.statusType <= Ci.imIStatusInfo.STATUS_OFFLINE + ) { + notifications.push("signed-off"); + } + if ( + !this.online && + account.statusType > Ci.imIStatusInfo.STATUS_OFFLINE + ) { + notifications.push("signed-on"); + } + } + + // Actually change the stored status. + [this._statusType, this._statusText, this._availabilityDetails] = [ + account.statusType, + account.statusText, + account.availabilityDetails, + ]; + + // Fire the notifications. + notifications.forEach(function (aTopic) { + this._notifyObservers(aTopic); + }, this); + }, + get displayName() { + return ( + (this._preferredAccount && this._preferredAccount.displayName) || + this._srvAlias || + this._name + ); + }, + get buddyIconFilename() { + return this._preferredAccount.buddyIconFilename; + }, + _statusType: 0, + get statusType() { + return this._statusType; + }, + get online() { + return this.statusType > Ci.imIStatusInfo.STATUS_OFFLINE; + }, + get available() { + return this.statusType == Ci.imIStatusInfo.STATUS_AVAILABLE; + }, + get idle() { + return this.statusType == Ci.imIStatusInfo.STATUS_IDLE; + }, + get mobile() { + return this.statusType == Ci.imIStatusInfo.STATUS_MOBILE; + }, + _statusText: "", + get statusText() { + return this._statusText; + }, + _availabilityDetails: 0, + get availabilityDetails() { + return this._availabilityDetails; + }, + get canSendMessage() { + return this._preferredAccount.canSendMessage; + }, + // XXX should we list the accounts in the tooltip? + getTooltipInfo() { + return this._preferredAccount.getTooltipInfo(); + }, + createConversation() { + return this._preferredAccount.createConversation(); + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + if (!this._observers) { + return; + } + this._observers = this._observers.filter(o => o !== aObserver); + }, + // internal calls + calls from add-ons + notifyObservers(aSubject, aTopic, aData) { + try { + for (let observer of this._observers) { + observer.observe(aSubject, aTopic, aData); + } + this._contact._observe(aSubject, aTopic, aData); + } catch (e) { + console.error(e); + } + }, + _notifyObservers(aTopic, aData) { + this.notifyObservers(this, "buddy-" + aTopic, aData); + }, + + // This is called by the prplIAccountBuddy implementations. + observe(aSubject, aTopic, aData) { + // Forward the notification. + this.notifyObservers(aSubject, aTopic, aData); + + switch (aTopic) { + case "account-buddy-availability-changed": + this._updatePreferredAccount(aSubject); + break; + case "account-buddy-status-changed": + if (this._isPreferredAccount(aSubject)) { + this._updateStatus(); + } + break; + case "account-buddy-display-name-changed": + if (this._isPreferredAccount(aSubject)) { + this._srvAlias = + this.displayName != this.userName ? this.displayName : ""; + let statement = lazy.DBConn.createStatement( + "UPDATE buddies SET srv_alias = :srvAlias WHERE id = :buddyId" + ); + statement.params.buddyId = this.id; + statement.params.srvAlias = this._srvAlias; + executeAsyncThenFinalize(statement); + this._notifyObservers("display-name-changed", aData); + } + break; + case "account-buddy-icon-changed": + if (this._isPreferredAccount(aSubject)) { + this._notifyObservers("icon-changed"); + } + break; + case "account-buddy-added": + if (this._accounts.length == 0) { + // Add the new account in the empty buddy instance. + // The TagsById hack is to bypass the xpconnect wrapper. + this._addAccount(aSubject, TagsById[aSubject.tag.id]); + this._updateStatus(); + this._notifyObservers("added"); + } else { + this._accounts.push(aSubject); + this.contact._moved(null, aSubject.tag); + this._updatePreferredAccount(aSubject); + } + break; + case "account-buddy-removed": + if (this._accounts.length == 1) { + let statement = lazy.DBConn.createStatement( + "DELETE FROM buddies WHERE id = :id" + ); + try { + statement.params.id = this.id; + statement.execute(); + } finally { + statement.finalize(); + } + this._notifyObservers("removed"); + + delete BuddiesById[this._id]; + this.destroy(); + } else { + this._accounts = this._accounts.filter(function (ab) { + return ( + ab.account.numericId != aSubject.account.numericId || + ab.tag.id != aSubject.tag.id + ); + }); + if ( + this._preferredAccount.account.numericId == + aSubject.account.numericId && + this._preferredAccount.tag.id == aSubject.tag.id + ) { + this._preferredAccount = null; + this._updatePreferredAccount(); + } + this.contact._moved(aSubject.tag); + } + break; + } + }, +}; + +export function ContactsService() {} +ContactsService.prototype = { + initContacts() { + let statement = lazy.DBConn.createStatement("SELECT id, name FROM tags"); + try { + while (statement.executeStep()) { + Tags.push(new Tag(statement.getInt32(0), statement.getUTF8String(1))); + } + } finally { + statement.finalize(); + } + + statement = lazy.DBConn.createStatement("SELECT id, alias FROM contacts"); + try { + while (statement.executeStep()) { + new Contact(statement.getInt32(0), statement.getUTF8String(1)); + } + } finally { + statement.finalize(); + } + + statement = lazy.DBConn.createStatement( + "SELECT contact_id, tag_id FROM contact_tag" + ); + try { + while (statement.executeStep()) { + let contact = ContactsById[statement.getInt32(0)]; + let tag = TagsById[statement.getInt32(1)]; + contact._tags.push(tag); + tag._addContact(contact); + } + } finally { + statement.finalize(); + } + + statement = lazy.DBConn.createStatement( + "SELECT id, key, name, srv_alias, contact_id FROM buddies ORDER BY position" + ); + try { + while (statement.executeStep()) { + new Buddy( + statement.getInt32(0), + statement.getUTF8String(1), + statement.getUTF8String(2), + statement.getUTF8String(3), + statement.getInt32(4) + ); + // FIXME is there a way to enforce that all AccountBuddies of a Buddy have the same protocol? + } + } finally { + statement.finalize(); + } + + statement = lazy.DBConn.createStatement( + "SELECT account_id, buddy_id, tag_id FROM account_buddy" + ); + try { + while (statement.executeStep()) { + let accountId = statement.getInt32(0); + let buddyId = statement.getInt32(1); + let tagId = statement.getInt32(2); + + let account = IMServices.accounts.getAccountByNumericId(accountId); + // If the account was deleted without properly cleaning up the + // account_buddy, skip loading this account buddy. + if (!account) { + continue; + } + + if (!BuddiesById.hasOwnProperty(buddyId)) { + console.error( + "Corrupted database: account_buddy entry for account " + + accountId + + " and tag " + + tagId + + " references unknown buddy with id " + + buddyId + ); + continue; + } + + let buddy = BuddiesById[buddyId]; + if (buddy._hasAccountBuddy(accountId, tagId)) { + console.error( + "Corrupted database: duplicated account_buddy entry: " + + "account_id = " + + accountId + + ", buddy_id = " + + buddyId + + ", tag_id = " + + tagId + ); + continue; + } + + let tag = TagsById[tagId]; + try { + buddy._addAccount(account.loadBuddy(buddy, tag), tag); + } catch (e) { + console.error(e); + dump(e + "\n"); + } + } + } finally { + statement.finalize(); + } + otherContactsTag._initHiddenTags(); + }, + unInitContacts() { + Tags = []; + TagsById = {}; + // Avoid shutdown leaks caused by references to native components + // implementing prplIAccountBuddy. + for (let buddyId in BuddiesById) { + let buddy = BuddiesById[buddyId]; + buddy.destroy(); + } + BuddiesById = {}; + ContactsById = {}; + }, + + getContactById: aId => ContactsById[aId], + // Get an array of all existing contacts. + getContacts() { + return Object.keys(ContactsById) + .filter(id => !ContactsById[id]._empty) + .map(id => ContactsById[id]); + }, + getBuddyById: aId => BuddiesById[aId], + getBuddyByNameAndProtocol(aNormalizedName, aPrpl) { + let statement = lazy.DBConn.createStatement( + "SELECT b.id FROM buddies b " + + "JOIN account_buddy ab ON buddy_id = b.id " + + "JOIN accounts a ON account_id = a.id " + + "WHERE b.key = :buddyName and a.prpl = :prplId" + ); + statement.params.buddyName = aNormalizedName; + statement.params.prplId = aPrpl.id; + try { + if (!statement.executeStep()) { + return null; + } + return BuddiesById[statement.row.id]; + } finally { + statement.finalize(); + } + }, + getAccountBuddyByNameAndAccount(aNormalizedName, aAccount) { + let buddy = this.getBuddyByNameAndProtocol( + aNormalizedName, + aAccount.protocol + ); + if (buddy) { + let id = aAccount.id; + for (let accountBuddy of buddy.getAccountBuddies()) { + if (accountBuddy.account.id == id) { + return accountBuddy; + } + } + } + return null; + }, + + accountBuddyAdded(aAccountBuddy) { + let account = aAccountBuddy.account; + let normalizedName = aAccountBuddy.normalizedName; + let buddy = this.getBuddyByNameAndProtocol( + normalizedName, + account.protocol + ); + if (!buddy) { + let statement = lazy.DBConn.createStatement( + "INSERT INTO buddies " + + "(key, name, srv_alias, position) " + + "VALUES(:key, :name, :srvAlias, 0)" + ); + try { + let name = aAccountBuddy.userName; + let srvAlias = aAccountBuddy.serverAlias; + statement.params.key = normalizedName; + statement.params.name = name; + statement.params.srvAlias = srvAlias; + statement.execute(); + buddy = new Buddy( + lazy.DBConn.lastInsertRowID, + normalizedName, + name, + srvAlias, + 0 + ); + } finally { + statement.finalize(); + } + } + + // Initialize the 'buddy' field of the prplIAccountBuddy instance. + aAccountBuddy.buddy = buddy; + + // Ensure we aren't storing a duplicate entry. + let accountId = account.numericId; + let tagId = aAccountBuddy.tag.id; + if (buddy._hasAccountBuddy(accountId, tagId)) { + console.error( + "Attempting to store a duplicate account buddy " + + normalizedName + + ", account id = " + + accountId + + ", tag id = " + + tagId + ); + return; + } + + // Store the new account buddy. + let statement = lazy.DBConn.createStatement( + "INSERT INTO account_buddy " + + "(account_id, buddy_id, tag_id) " + + "VALUES(:accountId, :buddyId, :tagId)" + ); + try { + statement.params.accountId = accountId; + statement.params.buddyId = buddy.id; + statement.params.tagId = tagId; + statement.execute(); + } finally { + statement.finalize(); + } + + // Fire the notifications. + buddy.observe(aAccountBuddy, "account-buddy-added"); + }, + accountBuddyRemoved(aAccountBuddy) { + let buddy = aAccountBuddy.buddy; + let statement = lazy.DBConn.createStatement( + "DELETE FROM account_buddy " + + "WHERE account_id = :accountId AND " + + "buddy_id = :buddyId AND " + + "tag_id = :tagId" + ); + try { + statement.params.accountId = aAccountBuddy.account.numericId; + statement.params.buddyId = buddy.id; + statement.params.tagId = aAccountBuddy.tag.id; + statement.execute(); + } finally { + statement.finalize(); + } + + buddy.observe(aAccountBuddy, "account-buddy-removed"); + }, + + accountBuddyMoved(aAccountBuddy, aOldTag, aNewTag) { + let buddy = aAccountBuddy.buddy; + let statement = lazy.DBConn.createStatement( + "UPDATE account_buddy " + + "SET tag_id = :newTagId " + + "WHERE account_id = :accountId AND " + + "buddy_id = :buddyId AND " + + "tag_id = :oldTagId" + ); + try { + statement.params.accountId = aAccountBuddy.account.numericId; + statement.params.buddyId = buddy.id; + statement.params.oldTagId = aOldTag.id; + statement.params.newTagId = aNewTag.id; + statement.execute(); + } finally { + statement.finalize(); + } + + let contact = ContactsById[buddy.contact.id]; + + // aNewTag is now inherited by the contact from an account buddy, so avoid + // keeping direct tag <-> contact links in the contact_tag table. + contact._removeContactTagRow(aNewTag); + + buddy.observe(aAccountBuddy, "account-buddy-moved"); + contact._moved(aOldTag, aNewTag); + }, + + storeAccount(aId, aUserName, aPrplId) { + let statement = lazy.DBConn.createStatement( + "SELECT name, prpl FROM accounts WHERE id = :id" + ); + statement.params.id = aId; + try { + if (statement.executeStep()) { + if ( + statement.getUTF8String(0) == aUserName && + statement.getUTF8String(1) == aPrplId + ) { + // The account is already stored correctly. + return; + } + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); // Corrupted database?!? + } + } finally { + statement.finalize(); + } + + // Actually store the account. + statement = lazy.DBConn.createStatement( + "INSERT INTO accounts (id, name, prpl) " + + "VALUES(:id, :userName, :prplId)" + ); + try { + statement.params.id = aId; + statement.params.userName = aUserName; + statement.params.prplId = aPrplId; + statement.execute(); + } finally { + statement.finalize(); + } + }, + accountIdExists(aId) { + let statement = lazy.DBConn.createStatement( + "SELECT id FROM accounts WHERE id = :id" + ); + try { + statement.params.id = aId; + return statement.executeStep(); + } finally { + statement.finalize(); + } + }, + forgetAccount(aId) { + let statement = lazy.DBConn.createStatement( + "DELETE FROM accounts WHERE id = :accountId" + ); + try { + statement.params.accountId = aId; + statement.execute(); + } finally { + statement.finalize(); + } + + // removing the account from the accounts table is not enough, + // we need to remove all the associated account_buddy entries too + statement = lazy.DBConn.createStatement( + "DELETE FROM account_buddy WHERE account_id = :accountId" + ); + try { + statement.params.accountId = aId; + statement.execute(); + } finally { + statement.finalize(); + } + }, + + QueryInterface: ChromeUtils.generateQI(["imIContactsService"]), + classDescription: "Contacts", +}; diff --git a/comm/chat/components/src/imConversations.sys.mjs b/comm/chat/components/src/imConversations.sys.mjs new file mode 100644 index 0000000000..069ef24fd9 --- /dev/null +++ b/comm/chat/components/src/imConversations.sys.mjs @@ -0,0 +1,951 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Status } from "resource:///modules/imStatusUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { ClassInfo } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { Message } from "resource:///modules/jsProtoHelper.sys.mjs"; + +var gLastUIConvId = 0; +var gLastPrplConvId = 0; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "bundle", () => + Services.strings.createBundle("chrome://chat/locale/conversations.properties") +); + +export function imMessage(aPrplMessage) { + this.prplMessage = aPrplMessage; +} + +imMessage.prototype = { + __proto__: ClassInfo(["imIMessage", "prplIMessage"], "IM Message"), + cancelled: false, + color: "", + _displayMessage: null, + otrEncrypted: false, + + get displayMessage() { + // Explicitly test for null so that blank messages don't fall back to + // the original. Especially problematic in encryption extensions like OTR. + return this._displayMessage !== null + ? this._displayMessage + : this.prplMessage.originalMessage; + }, + set displayMessage(aMsg) { + this._displayMessage = aMsg; + }, + + get message() { + return this.prplMessage.message; + }, + set message(aMsg) { + this.prplMessage.message = aMsg; + }, + + // from prplIMessage + get who() { + return this.prplMessage.who; + }, + get time() { + return this.prplMessage.time; + }, + get id() { + return this.prplMessage.id; + }, + get remoteId() { + return this.prplMessage.remoteId; + }, + get alias() { + return this.prplMessage.alias; + }, + get iconURL() { + return this.prplMessage.iconURL; + }, + get conversation() { + return this.prplMessage.conversation; + }, + set conversation(aConv) { + this.prplMessage.conversation = aConv; + }, + get outgoing() { + return this.prplMessage.outgoing; + }, + get incoming() { + return this.prplMessage.incoming; + }, + get system() { + return this.prplMessage.system; + }, + get autoResponse() { + return this.prplMessage.autoResponse; + }, + get containsNick() { + return this.prplMessage.containsNick; + }, + get noLog() { + return this.prplMessage.noLog; + }, + get error() { + return this.prplMessage.error; + }, + get delayed() { + return this.prplMessage.delayed; + }, + get noFormat() { + return this.prplMessage.noFormat; + }, + get containsImages() { + return this.prplMessage.containsImages; + }, + get notification() { + return this.prplMessage.notification; + }, + get noLinkification() { + return this.prplMessage.noLinkification; + }, + get noCollapse() { + return this.prplMessage.noCollapse; + }, + get isEncrypted() { + return this.prplMessage.isEncrypted || this.otrEncrypted; + }, + get action() { + return this.prplMessage.action; + }, + get deleted() { + return this.prplMessage.deleted; + }, + get originalMessage() { + return this.prplMessage.originalMessage; + }, + getActions() { + return this.prplMessage.getActions(); + }, + whenDisplayed() { + return this.prplMessage.whenDisplayed(); + }, + whenRead() { + return this.prplMessage.whenRead(); + }, +}; + +/** + * @param {prplIConversation} aPrplConversation + * @param {number} [idToReuse] - ID to use for this UI conversation if it replaces another UI conversation. + */ +export function UIConversation(aPrplConversation, idToReuse) { + this._prplConv = {}; + if (idToReuse) { + this.id = idToReuse; + } else { + this.id = ++gLastUIConvId; + } + // Observers listening to this instance's notifications. + this._observers = []; + // Observers this instance has attached to prplIConversations. + this._convObservers = new WeakMap(); + this._messages = []; + this.changeTargetTo(aPrplConversation); + let iface = Ci["prplIConv" + (aPrplConversation.isChat ? "Chat" : "IM")]; + this._interfaces = this._interfaces.concat(iface); + // XPConnect will create a wrapper around 'this' after here, + // so the list of exposed interfaces shouldn't change anymore. + this.updateContactObserver(); + if (!idToReuse) { + Services.obs.notifyObservers(this, "new-ui-conversation"); + } +} + +UIConversation.prototype = { + __proto__: ClassInfo( + ["imIConversation", "prplIConversation", "nsIObserver"], + "UI conversation" + ), + _observedContact: null, + get contact() { + let target = this.target; + if (!target.isChat && target.buddy) { + return target.buddy.buddy.contact; + } + return null; + }, + updateContactObserver() { + let contact = this.contact; + if (contact && !this._observedContact) { + contact.addObserver(this); + this._observedContact = contact; + } else if (!contact && this.observedContact) { + this._observedContact.removeObserver(this); + delete this._observedContact; + } + }, + /** + * @type {prplIConversation} + */ + get target() { + return this._prplConv[this._currentTargetId]; + }, + set target(aPrplConversation) { + this.changeTargetTo(aPrplConversation); + }, + get hasMultipleTargets() { + return Object.keys(this._prplConv).length > 1; + }, + getTargetByAccount(aAccount) { + let accountId = aAccount.id; + for (let id in this._prplConv) { + let prplConv = this._prplConv[id]; + if (prplConv.account.id == accountId) { + return prplConv; + } + } + return null; + }, + _currentTargetId: 0, + changeTargetTo(aPrplConversation) { + let id = aPrplConversation.id; + if (this._currentTargetId == id) { + return; + } + + if (!(id in this._prplConv)) { + this._prplConv[id] = aPrplConversation; + let observeConv = this.observeConv.bind(this, id); + this._convObservers.set(aPrplConversation, observeConv); + aPrplConversation.addObserver(observeConv); + } + + let shouldNotify = this._currentTargetId; + this._currentTargetId = id; + if (!this.isChat) { + let buddy = this.buddy; + if (buddy) { + ({ statusType: this.statusType, statusText: this.statusText } = buddy); + } + } + if (shouldNotify) { + this.notifyObservers(this, "target-prpl-conversation-changed"); + let target = this.target; + let params = [target.title, target.account.protocol.name]; + this.systemMessage( + lazy.bundle.formatStringFromName("targetChanged", params) + ); + } + }, + // Returns a boolean indicating if the ui-conversation was closed. + // If the conversation was closed, aContactId.value is set to the contact id + // or 0 if no contact was associated with the conversation. + removeTarget(aPrplConversation, aContactId) { + let id = aPrplConversation.id; + if (!(id in this._prplConv)) { + throw new Error("unknown prpl conversation"); + } + + delete this._prplConv[id]; + if (this._currentTargetId != id) { + return false; + } + + for (let newId in this._prplConv) { + this.changeTargetTo(this._prplConv[newId]); + return false; + } + + if (this._observedContact) { + this._observedContact.removeObserver(this); + aContactId.value = this._observedContact.id; + delete this._observedContact; + } else { + aContactId.value = 0; + } + + delete this._currentTargetId; + this.notifyObservers(this, "ui-conversation-closed"); + return true; + }, + + _unreadMessageCount: 0, + get unreadMessageCount() { + return this._unreadMessageCount; + }, + _unreadTargetedMessageCount: 0, + get unreadTargetedMessageCount() { + return this._unreadTargetedMessageCount; + }, + _unreadIncomingMessageCount: 0, + get unreadIncomingMessageCount() { + return this._unreadIncomingMessageCount; + }, + _unreadOTRNotificationCount: 0, + get unreadOTRNotificationCount() { + return this._unreadOTRNotificationCount; + }, + markAsRead() { + delete this._unreadMessageCount; + delete this._unreadTargetedMessageCount; + delete this._unreadIncomingMessageCount; + delete this._unreadOTRNotificationCount; + if (this._messages.length) { + this._messages[this._messages.length - 1].whenDisplayed(); + } + this._notifyUnreadCountChanged(); + }, + _lastNotifiedUnreadCount: 0, + _notifyUnreadCountChanged() { + if (this._unreadIncomingMessageCount == this._lastNotifiedUnreadCount) { + return; + } + + this._lastNotifiedUnreadCount = this._unreadIncomingMessageCount; + for (let observer of this._observers) { + observer.observe( + this, + "unread-message-count-changed", + this._unreadIncomingMessageCount.toString() + ); + } + }, + getMessages() { + return this._messages; + }, + checkClose() { + if (!this._currentTargetId) { + // Already closed. + return true; + } + + if ( + !Services.prefs.getBoolPref("messenger.conversations.alwaysClose") && + ((this.isChat && !this.left) || + (!this.isChat && + (this.unreadIncomingMessageCount != 0 || + Services.prefs.getBoolPref( + "messenger.conversations.holdByDefault" + )))) + ) { + return false; + } + + this.close(); + return true; + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "contact-no-longer-dummy") { + let oldId = parseInt(aData); + // gConversationsService is ugly... :( + delete gConversationsService._uiConvByContactId[oldId]; + gConversationsService._uiConvByContactId[aSubject.id] = this; + } else if (aTopic == "account-buddy-status-changed") { + if ( + !this._statusUpdatePending && + aSubject.account.id == this.account.id && + aSubject.buddy.id == this.buddy.buddy.id + ) { + this._statusUpdatePending = true; + Services.tm.mainThread.dispatch( + this.updateBuddyStatus.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + } + } else if (aTopic == "account-buddy-icon-changed") { + if ( + !this._statusUpdatePending && + aSubject.account.id == this.account.id && + aSubject.buddy.id == this.buddy.buddy.id + ) { + this._iconUpdatePending = true; + Services.tm.mainThread.dispatch( + this.updateIcon.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + } + } else if ( + aTopic == "account-buddy-display-name-changed" && + aSubject.account.id == this.account.id && + aSubject.buddy.id == this.buddy.buddy.id + ) { + this.notifyObservers(this, "update-buddy-display-name"); + } + }, + + _iconUpdatePending: false, + updateIcon() { + delete this._iconUpdatePending; + this.notifyObservers(this, "update-buddy-icon"); + }, + + _statusUpdatePending: false, + updateBuddyStatus() { + delete this._statusUpdatePending; + let { statusType: statusType, statusText: statusText } = this.buddy; + + if ( + "statusType" in this && + this.statusType == statusType && + this.statusText == statusText + ) { + return; + } + + let wasUnknown = this.statusType == Ci.imIStatusInfo.STATUS_UNKNOWN; + this.statusType = statusType; + this.statusText = statusText; + + this.notifyObservers(this, "update-buddy-status"); + + let msg; + if (statusType == Ci.imIStatusInfo.STATUS_UNKNOWN) { + msg = lazy.bundle.formatStringFromName("statusUnknown", [this.title]); + } else { + let status = Status.toLabel(statusType); + let stringId = wasUnknown ? "statusChangedFromUnknown" : "statusChanged"; + if (this._justReconnected) { + stringId = "statusKnown"; + delete this._justReconnected; + } + if (statusText) { + msg = lazy.bundle.formatStringFromName(stringId + "WithStatusText", [ + this.title, + status, + statusText, + ]); + } else { + msg = lazy.bundle.formatStringFromName(stringId, [this.title, status]); + } + } + this.systemMessage(msg); + }, + + _disconnected: false, + disconnecting() { + if (this._disconnected) { + return; + } + + this._disconnected = true; + if (this.contact) { + // Handled by the contact observer. + return; + } + + if (this.isChat && this.left) { + this._wasLeft = true; + } else { + this.systemMessage(lazy.bundle.GetStringFromName("accountDisconnected")); + } + this.notifyObservers(this, "update-buddy-status"); + }, + connected() { + if (this._disconnected) { + delete this._disconnected; + let msg = lazy.bundle.GetStringFromName("accountReconnected"); + if (this.isChat) { + if (!this._wasLeft) { + this.systemMessage(msg); + // Reconnect chat if possible. + let chatRoomFields = this.target.chatRoomFields; + if (chatRoomFields) { + this.account.joinChat(chatRoomFields); + } + } + delete this._wasLeft; + } else { + this._justReconnected = true; + // Exclude convs with contacts, these receive presence info updates + // (and therefore a reconnected message). + if (!this.contact) { + this.systemMessage(msg); + } + } + } + this.notifyObservers(this, "update-buddy-status"); + }, + + observeConv(aTargetId, aSubject, aTopic, aData) { + if ( + aTargetId != this._currentTargetId && + (aTopic == "new-text" || + aTopic == "update-text" || + aTopic == "remove-text" || + (aTopic == "update-typing" && + this._prplConv[aTargetId].typingState == Ci.prplIConvIM.TYPING)) + ) { + this.target = this._prplConv[aTargetId]; + } + + this.notifyObservers(aSubject, aTopic, aData); + }, + + systemMessage(aText, aIsError, aNoCollapse) { + let flags = { + system: true, + noLog: true, + error: !!aIsError, + noCollapse: !!aNoCollapse, + }; + const message = new Message("system", aText, flags, this); + this.notifyObservers(message, "new-text"); + }, + + /** + * Emit a notification sound for a new chat message and trigger the + * global notificationbox to prompt the user with the verifiation request. + * + * @param String aText - The system message. + */ + notifyVerifyOTR(aText) { + this._unreadOTRNotificationCount++; + this.systemMessage(aText, false, true); + for (let observer of this._observers) { + observer.observe( + this, + "unread-message-count-changed", + this._unreadOTRNotificationCount.toString() + ); + } + }, + + // prplIConversation + get isChat() { + return this.target.isChat; + }, + get account() { + return this.target.account; + }, + get name() { + return this.target.name; + }, + get normalizedName() { + return this.target.normalizedName; + }, + get title() { + return this.target.title; + }, + get startDate() { + return this.target.startDate; + }, + get convIconFilename() { + return this.target.convIconFilename; + }, + get encryptionState() { + return this.target.encryptionState; + }, + initializeEncryption() { + this.target.initializeEncryption(); + }, + sendMsg(aMsg, aAction = false, aNotice = false) { + this.target.sendMsg(aMsg, aAction, aNotice); + }, + unInit() { + for (let id in this._prplConv) { + let conv = this._prplConv[id]; + gConversationsService.forgetConversation(conv); + } + if (this._observedContact) { + this._observedContact.removeObserver(this); + delete this._observedContact; + } + this._prplConv = {}; // Prevent .close from failing. + delete this._currentTargetId; + this.notifyObservers(this, "ui-conversation-destroyed"); + }, + close() { + for (let id in this._prplConv) { + let conv = this._prplConv[id]; + conv.close(); + } + if (!this.hasOwnProperty("_currentTargetId")) { + return; + } + delete this._currentTargetId; + this.notifyObservers(this, "ui-conversation-closed"); + Services.obs.notifyObservers(this, "ui-conversation-closed"); + }, + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + notifyObservers(aSubject, aTopic, aData) { + if (aTopic == "new-text" || aTopic == "update-text") { + aSubject = new imMessage(aSubject); + this.notifyObservers(aSubject, "received-message"); + if (aSubject.cancelled) { + return; + } + if (!aSubject.system) { + aSubject.conversation.prepareForDisplaying(aSubject); + } + } + if (aTopic == "new-text") { + this._messages.push(aSubject); + ++this._unreadMessageCount; + if (aSubject.incoming && !aSubject.system) { + ++this._unreadIncomingMessageCount; + if (!this.isChat || aSubject.containsNick) { + ++this._unreadTargetedMessageCount; + } + } + } else if (aTopic == "update-text") { + const index = this._messages.findIndex( + msg => msg.remoteId == aSubject.remoteId + ); + if (index != -1) { + this._messages.splice(index, 1, aSubject); + } + } else if (aTopic == "remove-text") { + const index = this._messages.findIndex(msg => msg.remoteId == aData); + if (index != -1) { + this._messages.splice(index, 1); + } + } + + if (aTopic == "chat-update-type") { + // bail if there is no change of the conversation type + if ( + (this.target.isChat && this._interfaces.includes(Ci.prplIConvChat)) || + (!this.target.isChat && this._interfaces.includes(Ci.prplIConvIM)) + ) { + return; + } + if (this._observedContact) { + this._observedContact.removeObserver(this); + } + this.target.removeObserver(this._convObservers.get(this.target)); + gConversationsService.updateConversation(this.target); + return; + } + + for (let observer of this._observers) { + if (!observer.observe && !this._observers.includes(observer)) { + // Observer removed by a previous call to another observer. + continue; + } + observer.observe(aSubject, aTopic, aData); + } + this._notifyUnreadCountChanged(); + + if (aTopic == "new-text" || aTopic == "update-text") { + // Even updated messages should be treated as new message for logs. + // TODO proper handling in logs is bug 1735353 + Services.obs.notifyObservers(aSubject, "new-text", aData); + if ( + aTopic == "new-text" && + aSubject.incoming && + !aSubject.system && + (!this.isChat || aSubject.containsNick) + ) { + this.notifyObservers(aSubject, "new-directed-incoming-message", aData); + Services.obs.notifyObservers( + aSubject, + "new-directed-incoming-message", + aData + ); + } + } + }, + + // Used above when notifying of new-texts originating in the + // UIConversation. This happens when this.systemMessage() is called. The + // conversation for the message is set as the UIConversation. + prepareForDisplaying(aMsg) {}, + + // prplIConvIM + get buddy() { + return this.target.buddy; + }, + get typingState() { + return this.target.typingState; + }, + sendTyping(aString) { + return this.target.sendTyping(aString); + }, + + // Chat only + getParticipants() { + return this.target.getParticipants(); + }, + get topic() { + return this.target.topic; + }, + set topic(aTopic) { + this.target.topic = aTopic; + }, + get topicSetter() { + return this.target.topicSetter; + }, + get topicSettable() { + return this.target.topicSettable; + }, + get noTopicString() { + return lazy.bundle.GetStringFromName("noTopic"); + }, + get nick() { + return this.target.nick; + }, + get left() { + return this.target.left; + }, + get joining() { + return this.target.joining; + }, +}; + +var gConversationsService; + +export function ConversationsService() { + gConversationsService = this; +} + +ConversationsService.prototype = { + get wrappedJSObject() { + return this; + }, + + initConversations() { + this._uiConv = {}; + this._uiConvByContactId = {}; + this._prplConversations = []; + Services.obs.addObserver(this, "account-disconnecting"); + Services.obs.addObserver(this, "account-connected"); + Services.obs.addObserver(this, "account-buddy-added"); + Services.obs.addObserver(this, "account-buddy-removed"); + }, + + unInitConversations() { + let UIConvs = this.getUIConversations(); + for (let UIConv of UIConvs) { + UIConv.unInit(); + } + delete this._uiConv; + delete this._uiConvByContactId; + // This should already be empty, but just to be sure... + for (let prplConv of this._prplConversations) { + prplConv.unInit(); + } + delete this._prplConversations; + Services.obs.removeObserver(this, "account-disconnecting"); + Services.obs.removeObserver(this, "account-connected"); + Services.obs.removeObserver(this, "account-buddy-added"); + Services.obs.removeObserver(this, "account-buddy-removed"); + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "account-connected") { + for (let id in this._uiConv) { + let conv = this._uiConv[id]; + if (conv.account.id == aSubject.id) { + conv.connected(); + } + } + } else if (aTopic == "account-disconnecting") { + for (let id in this._uiConv) { + let conv = this._uiConv[id]; + if (conv.account.id == aSubject.id) { + conv.disconnecting(); + } + } + } else if (aTopic == "account-buddy-added") { + let accountBuddy = aSubject; + let prplConversation = this.getConversationByNameAndAccount( + accountBuddy.normalizedName, + accountBuddy.account, + false + ); + if (!prplConversation) { + return; + } + + let uiConv = this.getUIConversation(prplConversation); + let contactId = accountBuddy.buddy.contact.id; + if (contactId in this._uiConvByContactId) { + // Trouble! There is an existing uiConv for this contact. + // We should avoid having two uiConvs with the same contact. + // This is ugly UX, but at least can only happen if there is + // already an accountBuddy with the same name for the same + // protocol on a different account, which should be rare. + this.removeConversation(prplConversation); + return; + } + // Link the existing uiConv to the contact. + this._uiConvByContactId[contactId] = uiConv; + uiConv.updateContactObserver(); + uiConv.notifyObservers(uiConv, "update-conv-buddy"); + } else if (aTopic == "account-buddy-removed") { + let accountBuddy = aSubject; + let contactId = accountBuddy.buddy.contact.id; + if (!(contactId in this._uiConvByContactId)) { + return; + } + let uiConv = this._uiConvByContactId[contactId]; + + // If there is more than one target on the uiConv, close the + // prplConv as we can't dissociate the uiConv from the contact. + // The conversation with the contact will continue with a different + // target. + if (uiConv.hasMultipleTargets) { + let prplConversation = uiConv.getTargetByAccount(accountBuddy.account); + if (prplConversation) { + this.removeConversation(prplConversation); + } + return; + } + + delete this._uiConvByContactId[contactId]; + uiConv.updateContactObserver(); + uiConv.notifyObservers(uiConv, "update-conv-buddy"); + } + }, + + addConversation(aPrplConversation) { + // Give an id to the new conversation. + aPrplConversation.id = ++gLastPrplConvId; + this._prplConversations.push(aPrplConversation); + + // Notify observers. + Services.obs.notifyObservers(aPrplConversation, "new-conversation"); + + // Update or create the corresponding UI conversation. + let contactId; + if (!aPrplConversation.isChat) { + let accountBuddy = aPrplConversation.buddy; + if (accountBuddy) { + contactId = accountBuddy.buddy.contact.id; + } + } + + if (contactId) { + if (contactId in this._uiConvByContactId) { + let uiConv = this._uiConvByContactId[contactId]; + uiConv.target = aPrplConversation; + this._uiConv[aPrplConversation.id] = uiConv; + return; + } + } + + let newUIConv = new UIConversation(aPrplConversation); + this._uiConv[aPrplConversation.id] = newUIConv; + if (contactId) { + this._uiConvByContactId[contactId] = newUIConv; + } + }, + /** + * Informs the conversation service that the type of the conversation changed, which then lets the + * UI components know to use a new UI conversation instance. + * + * @param {prplIConversation} aPrplConversation - The prpl conversation to update the UI conv for. + */ + updateConversation(aPrplConversation) { + let contactId; + let uiConv = this.getUIConversation(aPrplConversation); + + if (!aPrplConversation.isChat) { + let accountBuddy = aPrplConversation.buddy; + if (accountBuddy) { + contactId = accountBuddy.buddy.contact.id; + } + } + // Ensure conv is not in the by contact ID map + for (const [contactId, uiConversation] of Object.entries( + this._uiConvByContactId + )) { + if (uiConversation === uiConv) { + delete this._uiConvByContactId[contactId]; + break; + } + } + Services.obs.notifyObservers(uiConv, "ui-conversation-replaced"); + let uiConvId = uiConv.id; + // create new UI conv with correct interfaces. + uiConv = new UIConversation(aPrplConversation, uiConvId); + this._uiConv[aPrplConversation.id] = uiConv; + + // Ensure conv is in the by contact ID map if it has a contact + if (contactId) { + this._uiConvByContactId[contactId] = uiConv; + } + Services.obs.notifyObservers(uiConv, "conversation-update-type"); + }, + removeConversation(aPrplConversation) { + Services.obs.notifyObservers(aPrplConversation, "conversation-closed"); + + let uiConv = this.getUIConversation(aPrplConversation); + delete this._uiConv[aPrplConversation.id]; + let contactId = {}; + if (uiConv.removeTarget(aPrplConversation, contactId)) { + if (contactId.value) { + delete this._uiConvByContactId[contactId.value]; + } + Services.obs.notifyObservers(uiConv, "ui-conversation-closed"); + } + this.forgetConversation(aPrplConversation); + }, + forgetConversation(aPrplConversation) { + aPrplConversation.unInit(); + + this._prplConversations = this._prplConversations.filter( + c => c !== aPrplConversation + ); + }, + + getUIConversations() { + let rv = []; + if (this._uiConv) { + for (let prplConvId in this._uiConv) { + // Since an UIConversation may be linked to multiple prplConversations, + // we must ensure we don't return the same UIConversation twice, + // by checking the id matches that of the active prplConversation. + let uiConv = this._uiConv[prplConvId]; + if (prplConvId == uiConv.target.id) { + rv.push(uiConv); + } + } + } + return rv; + }, + getUIConversation(aPrplConversation) { + let id = aPrplConversation.id; + if (this._uiConv && id in this._uiConv) { + return this._uiConv[id]; + } + throw new Error("Unknown conversation"); + }, + getUIConversationByContactId(aId) { + return aId in this._uiConvByContactId ? this._uiConvByContactId[aId] : null; + }, + + getConversations() { + return this._prplConversations; + }, + getConversationById(aId) { + for (let conv of this._prplConversations) { + if (conv.id == aId) { + return conv; + } + } + return null; + }, + getConversationByNameAndAccount(aName, aAccount, aIsChat) { + let normalizedName = aAccount.normalize(aName); + for (let conv of this._prplConversations) { + if ( + aAccount.normalize(conv.name) == normalizedName && + aAccount.numericId == conv.account.numericId && + conv.isChat == aIsChat + ) { + return conv; + } + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["imIConversationsService"]), + classDescription: "Conversations", +}; diff --git a/comm/chat/components/src/imCore.sys.mjs b/comm/chat/components/src/imCore.sys.mjs new file mode 100644 index 0000000000..ba05bd4b63 --- /dev/null +++ b/comm/chat/components/src/imCore.sys.mjs @@ -0,0 +1,407 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { + ClassInfo, + initLogModule, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; + +var kQuitApplicationGranted = "quit-application-granted"; +var kProtocolPluginCategory = "im-protocol-plugin"; + +var kPrefReportIdle = "messenger.status.reportIdle"; +var kPrefUserIconFilename = "messenger.status.userIconFileName"; +var kPrefUserDisplayname = "messenger.status.userDisplayName"; +var kPrefTimeBeforeIdle = "messenger.status.timeBeforeIdle"; +var kPrefAwayWhenIdle = "messenger.status.awayWhenIdle"; +var kPrefDefaultMessage = "messenger.status.defaultIdleAwayMessage"; + +var NS_IOSERVICE_GOING_OFFLINE_TOPIC = "network:offline-about-to-go-offline"; +var NS_IOSERVICE_OFFLINE_STATUS_TOPIC = "network:offline-status-changed"; + +function UserStatus() { + this._observers = []; + + if (Services.prefs.getBoolPref(kPrefReportIdle)) { + this._addIdleObserver(); + } + Services.prefs.addObserver(kPrefReportIdle, this); + + if (Services.io.offline) { + this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE; + } + Services.obs.addObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC); + Services.obs.addObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC); +} +UserStatus.prototype = { + __proto__: ClassInfo("imIUserStatusInfo", "User status info"), + + unInit() { + this._observers = []; + Services.prefs.removeObserver(kPrefReportIdle, this); + if (this._observingIdleness) { + this._removeIdleObserver(); + } + Services.obs.removeObserver(this, NS_IOSERVICE_GOING_OFFLINE_TOPIC); + Services.obs.removeObserver(this, NS_IOSERVICE_OFFLINE_STATUS_TOPIC); + }, + _observingIdleness: false, + _addIdleObserver() { + this._observingIdleness = true; + this._idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + Services.obs.addObserver(this, "im-sent"); + + this._timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle); + if (this._timeBeforeIdle < 0) { + this._timeBeforeIdle = 0; + } + Services.prefs.addObserver(kPrefTimeBeforeIdle, this); + if (this._timeBeforeIdle) { + this._idleService.addIdleObserver(this, this._timeBeforeIdle); + } + }, + _removeIdleObserver() { + if (this._timeBeforeIdle) { + this._idleService.removeIdleObserver(this, this._timeBeforeIdle); + } + + Services.prefs.removeObserver(kPrefTimeBeforeIdle, this); + delete this._timeBeforeIdle; + + Services.obs.removeObserver(this, "im-sent"); + delete this._idleService; + delete this._observingIdleness; + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + if (aData == kPrefReportIdle) { + let reportIdle = Services.prefs.getBoolPref(kPrefReportIdle); + if (reportIdle && !this._observingIdleness) { + this._addIdleObserver(); + } else if (!reportIdle && this._observingIdleness) { + this._removeIdleObserver(); + } + } else if (aData == kPrefTimeBeforeIdle) { + let timeBeforeIdle = Services.prefs.getIntPref(kPrefTimeBeforeIdle); + if (timeBeforeIdle != this._timeBeforeIdle) { + if (this._timeBeforeIdle) { + this._idleService.removeIdleObserver(this, this._timeBeforeIdle); + } + this._timeBeforeIdle = timeBeforeIdle; + if (this._timeBeforeIdle) { + this._idleService.addIdleObserver(this, this._timeBeforeIdle); + } + } + } else { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + } else if (aTopic == NS_IOSERVICE_GOING_OFFLINE_TOPIC) { + this.offline = true; + } else if ( + aTopic == NS_IOSERVICE_OFFLINE_STATUS_TOPIC && + aData == "online" + ) { + this.offline = false; + } else { + this._checkIdle(); + } + }, + + _offlineStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE, + set offline(aOffline) { + let statusType = this.statusType; + let statusText = this.statusText; + if (aOffline) { + this._offlineStatusType = Ci.imIStatusInfo.STATUS_OFFLINE; + } else { + delete this._offlineStatusType; + } + if (this.statusType != statusType || this.statusText != statusText) { + this._notifyObservers("status-changed", this.statusText); + } + }, + + _idleTime: 0, + get idleTime() { + return this._idleTime; + }, + set idleTime(aIdleTime) { + this._idleTime = aIdleTime; + this._notifyObservers("idle-time-changed", aIdleTime); + }, + _idle: false, + _idleStatusText: "", + _idleStatusType: Ci.imIStatusInfo.STATUS_AVAILABLE, + _checkIdle() { + let idleTime = Math.floor(this._idleService.idleTime / 1000); + let idle = this._timeBeforeIdle && idleTime >= this._timeBeforeIdle; + if (idle == this._idle) { + return; + } + + let statusType = this.statusType; + let statusText = this.statusText; + this._idle = idle; + if (idle) { + this.idleTime = idleTime; + if (Services.prefs.getBoolPref(kPrefAwayWhenIdle)) { + this._idleStatusType = Ci.imIStatusInfo.STATUS_AWAY; + this._idleStatusText = Services.prefs.getComplexValue( + kPrefDefaultMessage, + Ci.nsIPrefLocalizedString + ).data; + } + } else { + this.idleTime = 0; + delete this._idleStatusType; + delete this._idleStatusText; + } + if (this.statusType != statusType || this.statusText != statusText) { + this._notifyObservers("status-changed", this.statusText); + } + }, + + _statusText: "", + get statusText() { + return this._statusText || this._idleStatusText; + }, + _statusType: Ci.imIStatusInfo.STATUS_AVAILABLE, + get statusType() { + return Math.min( + this._statusType, + this._idleStatusType, + this._offlineStatusType + ); + }, + setStatus(aStatus, aMessage) { + if (aStatus != Ci.imIStatusInfo.STATUS_UNKNOWN) { + this._statusType = aStatus; + } + if (aStatus != Ci.imIStatusInfo.STATUS_OFFLINE) { + this._statusText = aMessage; + } + this._notifyObservers("status-changed", aMessage); + }, + + _getProfileDir: () => Services.dirsvc.get("ProfD", Ci.nsIFile), + setUserIcon(aIconFile) { + let folder = this._getProfileDir(); + + let newName = ""; + if (aIconFile) { + // Get the extension (remove trailing dots - invalid Windows extension). + let ext = aIconFile.leafName.replace(/.*(\.[a-z0-9]+)\.*/i, "$1"); + // newName = userIcon-<timestamp(now)>.<aIconFile.extension> + newName = "userIcon-" + Math.floor(Date.now() / 1000) + ext; + + // Copy the new icon file to newName in the profile folder. + aIconFile.copyTo(folder, newName); + } + + // Get the previous file name before saving the new file name. + let oldFileName = Services.prefs.getCharPref(kPrefUserIconFilename); + Services.prefs.setCharPref(kPrefUserIconFilename, newName); + + // Now that the new icon has been copied to the profile directory + // and the pref value changed, we can remove the old icon. Ignore + // failures so that we always fire the user-icon-changed notification. + try { + if (oldFileName) { + folder.append(oldFileName); + if (folder.exists()) { + folder.remove(false); + } + } + } catch (e) { + console.error(e); + } + + this._notifyObservers("user-icon-changed", newName); + }, + getUserIcon() { + let filename = Services.prefs.getCharPref(kPrefUserIconFilename); + if (!filename) { + // No icon has been set. + return null; + } + + let file = this._getProfileDir(); + file.append(filename); + + if (!file.exists()) { + Services.console.logStringMessage("Invalid userIconFileName preference"); + return null; + } + + return Services.io.newFileURI(file); + }, + + get displayName() { + return Services.prefs.getStringPref(kPrefUserDisplayname); + }, + set displayName(aDisplayName) { + Services.prefs.setStringPref(kPrefUserDisplayname, aDisplayName); + this._notifyObservers("user-display-name-changed", aDisplayName); + }, + + addObserver(aObserver) { + if (!this._observers.includes(aObserver)) { + this._observers.push(aObserver); + } + }, + removeObserver(aObserver) { + this._observers = this._observers.filter(o => o !== aObserver); + }, + _notifyObservers(aTopic, aData) { + for (let observer of this._observers) { + observer.observe(this, aTopic, aData); + } + }, +}; + +export function CoreService() {} +CoreService.prototype = { + globalUserStatus: null, + + _initialized: false, + get initialized() { + return this._initialized; + }, + init() { + if (this._initialized) { + return; + } + + initLogModule("core", this); + + Services.obs.addObserver(this, kQuitApplicationGranted); + this._initialized = true; + + IMServices.cmd.initCommands(); + this._protos = {}; + + this.globalUserStatus = new UserStatus(); + this.globalUserStatus.addObserver({ + observe(aSubject, aTopic, aData) { + Services.obs.notifyObservers(aSubject, aTopic, aData); + }, + }); + + IMServices.accounts.initAccounts(); + IMServices.contacts.initContacts(); + IMServices.conversations.initConversations(); + Services.obs.notifyObservers(this, "prpl-init"); + + // Wait with automatic connections until the password service + // is available. + if ( + IMServices.accounts.autoLoginStatus == + Ci.imIAccountsService.AUTOLOGIN_ENABLED + ) { + Services.logins.initializationPromise.then(() => { + IMServices.accounts.processAutoLogin(); + }); + } + }, + observe(aObject, aTopic, aData) { + if (aTopic == kQuitApplicationGranted) { + this.quit(); + } + }, + quit() { + if (!this._initialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + + Services.obs.removeObserver(this, kQuitApplicationGranted); + Services.obs.notifyObservers(this, "prpl-quit"); + + IMServices.conversations.unInitConversations(); + IMServices.accounts.unInitAccounts(); + IMServices.contacts.unInitContacts(); + IMServices.cmd.unInitCommands(); + + this.globalUserStatus.unInit(); + delete this.globalUserStatus; + delete this._protos; + delete this._initialized; + }, + + getProtocols() { + if (!this._initialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + + let protocols = []; + for (let entry of Services.catMan.enumerateCategory( + kProtocolPluginCategory + )) { + let id = entry.data; + + // If the preference is set to disable this prpl, don't show it in the + // full list of protocols. + let pref = "chat.prpls." + id + ".disable"; + if ( + Services.prefs.getPrefType(pref) == Services.prefs.PREF_BOOL && + Services.prefs.getBoolPref(pref) + ) { + this.LOG("Disabling prpl: " + id); + continue; + } + + let proto = this.getProtocolById(id); + if (proto) { + protocols.push(proto); + } + } + return protocols; + }, + + getProtocolById(aPrplId) { + if (!this._initialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + + if (this._protos.hasOwnProperty(aPrplId)) { + return this._protos[aPrplId]; + } + + let cid; + try { + cid = Services.catMan.getCategoryEntry(kProtocolPluginCategory, aPrplId); + } catch (e) { + return null; // no protocol registered for this id. + } + + let proto = null; + try { + proto = Cc[cid].createInstance(Ci.prplIProtocol); + } catch (e) { + // This is a real error, the protocol is registered and failed to init. + let error = "failed to create an instance of " + cid + ": " + e; + dump(error + "\n"); + console.error(error); + } + if (!proto) { + return null; + } + + try { + proto.init(aPrplId); + } catch (e) { + console.error("Could not initialize protocol " + aPrplId + ": " + e); + return null; + } + + this._protos[aPrplId] = proto; + return proto; + }, + + QueryInterface: ChromeUtils.generateQI(["imICoreService"]), + classDescription: "Core", +}; diff --git a/comm/chat/components/src/logger.sys.mjs b/comm/chat/components/src/logger.sys.mjs new file mode 100644 index 0000000000..bde2e2945e --- /dev/null +++ b/comm/chat/components/src/logger.sys.mjs @@ -0,0 +1,971 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { GenericMessagePrototype } from "resource:///modules/jsProtoHelper.sys.mjs"; +import { + ClassInfo, + l10nHelper, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ToLocaleFormat: "resource:///modules/ToLocaleFormat.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/logger.properties") +); + +/* + * Maps file paths to promises returned by ongoing IOUtils operations on them. + * This is so that a file can be read after a pending write operation completes + * and vice versa (opening a file multiple times concurrently may fail on Windows). + */ +export var gFilePromises = new Map(); +/** + * Set containing log file paths that are scheduled to have deleted messages + * removed. + * + * @type {Set<string>} + */ +export var gPendingCleanup = new Set(); + +const kPendingLogCleanupPref = "chat.logging.cleanup.pending"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SHOULD_CLEANUP_LOGS", + "chat.logging.cleanup", + true +); + +// Uses above map to queue operations on a file. +export function queueFileOperation(aPath, aOperation) { + // Ensure the operation is queued regardless of whether the last one succeeded. + // This is safe since the promise is returned and consumers are expected to + // handle any errors. If there's no promise existing for the given path already, + // queue the operation on a dummy pre-resolved promise. + let promise = (gFilePromises.get(aPath) || Promise.resolve()).then( + aOperation, + aOperation + ); + gFilePromises.set(aPath, promise); + + let cleanup = () => { + // If no further operations have been queued, remove the reference from the map. + if (gFilePromises.get(aPath) === promise) { + gFilePromises.delete(aPath); + } + }; + // Ensure we clear unused promises whether they resolved or rejected. + promise.then(cleanup, cleanup); + + return promise; +} + +/** + * Convenience method to append to a file using the above queue system. If any of + * the I/O operations reject, the returned promise will reject with the same reason. + * We open the file, append, and close it immediately. The alternative is to keep + * it open and append as required, but we want to make sure we don't open a file + * for reading while it's already open for writing, so we close it every time + * (opening a file multiple times concurrently may fail on Windows). + * Note: This function creates parent directories if required. + */ +export function appendToFile(aPath, aString, aCreate) { + return queueFileOperation(aPath, async function () { + await IOUtils.makeDirectory(PathUtils.parent(aPath)); + const mode = aCreate ? "create" : "append"; + try { + await IOUtils.writeUTF8(aPath, aString, { + mode, + }); + } catch (error) { + // Ignore existing file when adding the header. + if ( + aCreate && + error.name == "NoModificationAllowedError" && + error.message.startsWith("Refusing to overwrite the file") + ) { + return; + } + throw error; + } + }); +} + +// This function checks names against OS naming conventions and alters them +// accordingly so that they can be used as file/folder names. +export function encodeName(aName) { + // Reserved device names by Windows (prefixing "%"). + let reservedNames = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i; + if (reservedNames.test(aName)) { + return "%" + aName; + } + + // "." and " " must not be at the end of a file or folder name (appending "_"). + if (/[\. _]/.test(aName.slice(-1))) { + aName += "_"; + } + + // Reserved characters are replaced by %[hex value]. encodeURIComponent() is + // not sufficient, nevertheless decodeURIComponent() can be used to decode. + function encodeReservedChars(match) { + return "%" + match.charCodeAt(0).toString(16); + } + return aName.replace(/[<>:"\/\\|?*&%]/g, encodeReservedChars); +} + +export function getLogFolderPathForAccount(aAccount) { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "logs", + aAccount.protocol.normalizedName, + encodeName(aAccount.normalizedName) + ); +} + +export function getLogFilePathForConversation(aConv, aStartTime) { + if (!aStartTime) { + aStartTime = aConv.startDate / 1000; + } + let path = getLogFolderPathForAccount(aConv.account); + let name = aConv.normalizedName; + if (aConv.isChat) { + name += ".chat"; + } + return PathUtils.join(path, encodeName(name), getNewLogFileName(aStartTime)); +} + +export function getNewLogFileName(aStartTime) { + let date = aStartTime ? new Date(aStartTime) : new Date(); + let dateTime = lazy.ToLocaleFormat("%Y-%m-%d.%H%M%S", date); + let offset = date.getTimezoneOffset(); + if (offset < 0) { + dateTime += "+"; + offset *= -1; + } else { + dateTime += "-"; + } + let minutes = offset % 60; + offset = (offset - minutes) / 60; + function twoDigits(number) { + if (number == 0) { + return "00"; + } + return number < 10 ? "0" + number : number; + } + return dateTime + twoDigits(offset) + twoDigits(minutes) + ".json"; +} + +/** + * Schedules a cleanup of the logfiles contents, removing the message texts + * from messages that were marked as deleted. This can be disabled by a pref. + * + * @param {string} path - Path to the logfile to clean. + */ +function queueLogFileCleanup(path) { + if (gPendingCleanup.has(path) || !lazy.SHOULD_CLEANUP_LOGS) { + return; + } + let idleCallback = () => { + if (gFilePromises.has(path)) { + gFilePromises.get(path).finally(() => { + ChromeUtils.idleDispatch(idleCallback); + }); + return; + } + // Queue a new file operation to ensure nothing gets appended between + // reading the log and writing it back. This means we might run this when + // the application isn't idle, but due to the async operations that is + // very hard to guarantee either way. + queueFileOperation(path, async () => { + try { + let logContents = await IOUtils.readUTF8(path); + let logLines = logContents.split("\n").map(line => { + try { + return JSON.parse(line); + } catch { + return line; + } + }); + let lastDeletionIndex = 0; + let deletedMessages = new Set( + logLines + .filter((message, index) => { + if (message.flags?.includes("deleted") && message.remoteId) { + lastDeletionIndex = index; + return true; + } + return false; + }) + .map(message => message.remoteId) + ); + for (let [index, message] of logLines.entries()) { + // If we are past the last deletion in the logs, there is no more + // work to be done. + if (index >= lastDeletionIndex) { + break; + } + if ( + deletedMessages.has(message.remoteId) && + !message.flags?.includes("deleted") + ) { + // Void the text of deleted messages but keep the message + // metadata for journaling. + message.text = ""; + } + } + let cleanedLog = logLines + .map(line => { + if (typeof line === "string") { + return line; + } + return JSON.stringify(line); + }) + .join("\n"); + await IOUtils.writeUTF8(path, cleanedLog); + } catch (error) { + console.error( + "Error cleaning up log file contents for " + path + ": " + error + ); + } finally { + gPendingCleanup.delete(path); + Services.prefs.setStringPref( + kPendingLogCleanupPref, + JSON.stringify(Array.from(gPendingCleanup.values())) + ); + } + }); + }; + ChromeUtils.idleDispatch(idleCallback); + gPendingCleanup.add(path); + Services.prefs.setStringPref( + kPendingLogCleanupPref, + JSON.stringify(Array.from(gPendingCleanup.values())) + ); +} + +/** + * Schedule pending log cleanups that weren't completed last time the + * application was running. + */ +function initLogCleanup() { + if (!lazy.SHOULD_CLEANUP_LOGS) { + return; + } + // Capture the value of the pending cleanups before it gets overridden by + // newly scheduled cleanups. + let pendingCleanupPathValue = Services.prefs.getStringPref( + kPendingLogCleanupPref, + "[]" + ); + // We are in no hurry to queue these cleanups, worst case we try to schedule + // a cleanup for a file that is already scheduled. + ChromeUtils.idleDispatch(() => { + let pendingCleanupPaths = JSON.parse(pendingCleanupPathValue) ?? []; + if (!Array.isArray(pendingCleanupPaths)) { + console.error( + "Pending chat log cleanup pref is not a valid array. " + + "Assuming all chat logs are clean." + ); + return; + } + for (const path of pendingCleanupPaths) { + if (typeof path === "string") { + queueLogFileCleanup(path); + } + } + }); +} + +// One of these is maintained for every conversation being logged. It initializes +// a log file and appends to it as required. +function LogWriter(aConversation) { + this._conv = aConversation; + this.paths = []; + this.startNewFile(this._conv.startDate / 1000); +} +LogWriter.prototype = { + // All log file paths used by this LogWriter. + paths: [], + // Path of the log file that is currently being written to. + get currentPath() { + return this.paths[this.paths.length - 1]; + }, + // Constructor sets this to a promise that will resolve when the log header + // has been written. + _initialized: null, + _startTime: null, + _lastMessageTime: null, + _messageCount: 0, + startNewFile(aStartTime, aContinuedSession) { + // We start a new log file every 1000 messages. The start time of this new + // log file is the time of the next message. Since message times are in seconds, + // if we receive 1000 messages within a second after starting the new file, + // we will create another file, using the same start time - and so the same + // file name. To avoid this, ensure the new start time is at least one second + // greater than the current one. This is ugly, but should rarely be needed. + aStartTime = Math.max(aStartTime, this._startTime + 1000); + this._startTime = this._lastMessageTime = aStartTime; + this._messageCount = 0; + this.paths.push(getLogFilePathForConversation(this._conv, aStartTime)); + let account = this._conv.account; + let header = { + date: new Date(this._startTime), + name: this._conv.name, + title: this._conv.title, + account: account.normalizedName, + protocol: account.protocol.normalizedName, + isChat: this._conv.isChat, + normalizedName: this._conv.normalizedName, + }; + if (aContinuedSession) { + header.continuedSession = true; + } + header = JSON.stringify(header) + "\n"; + + this._initialized = appendToFile(this.currentPath, header, true); + // Catch the error separately so that _initialized will stay rejected if + // writing the header failed. + this._initialized.catch(aError => + console.error("Failed to initialize log file:\n" + aError) + ); + }, + // We start a new log file in the following cases: + // - If it has been 30 minutes since the last message. + kInactivityLimit: 30 * 60 * 1000, + // - If at midnight, it's been longer than 3 hours since we started the file. + kDayOverlapLimit: 3 * 60 * 60 * 1000, + // - After every 1000 messages. + kMessageCountLimit: 1000, + async logMessage(aMessage) { + // aMessage.time is in seconds, we need it in milliseconds. + let messageTime = aMessage.time * 1000; + let messageMidnight = new Date(messageTime).setHours(0, 0, 0, 0); + + let inactivityLimitExceeded = + !aMessage.delayed && + messageTime - this._lastMessageTime > this.kInactivityLimit; + let dayOverlapLimitExceeded = + !aMessage.delayed && + messageMidnight - this._startTime > this.kDayOverlapLimit; + + if ( + inactivityLimitExceeded || + dayOverlapLimitExceeded || + this._messageCount == this.kMessageCountLimit + ) { + // We start a new session if the inactivity limit was exceeded. + this.startNewFile(messageTime, !inactivityLimitExceeded); + } + ++this._messageCount; + + if (!aMessage.delayed) { + this._lastMessageTime = messageTime; + } + + let msg = { + date: new Date(messageTime), + who: aMessage.who, + text: aMessage.displayMessage, + flags: [ + "outgoing", + "incoming", + "system", + "autoResponse", + "containsNick", + "error", + "delayed", + "noFormat", + "containsImages", + "notification", + "noLinkification", + "isEncrypted", + "action", + "deleted", + ].filter(f => aMessage[f]), + remoteId: aMessage.remoteId, + }; + let alias = aMessage.alias; + if (alias && alias != msg.who) { + msg.alias = alias; + } + let lineToWrite = JSON.stringify(msg) + "\n"; + + await this._initialized; + try { + await appendToFile(this.currentPath, lineToWrite); + } catch (error) { + console.error("Failed to log message:\n" + error); + } + if (aMessage.deleted) { + queueLogFileCleanup(this.currentPath); + } + }, +}; + +var dummyLogWriter = { + paths: null, + currentPath: null, + logMessage() {}, +}; + +var gLogWritersById = new Map(); +export function getLogWriter(aConversation) { + let id = aConversation.id; + if (!gLogWritersById.has(id)) { + let prefName = + "purple.logging.log_" + (aConversation.isChat ? "chats" : "ims"); + if (Services.prefs.getBoolPref(prefName)) { + gLogWritersById.set(id, new LogWriter(aConversation)); + } else { + gLogWritersById.set(id, dummyLogWriter); + } + } + return gLogWritersById.get(id); +} + +export function closeLogWriter(aConversation) { + gLogWritersById.delete(aConversation.id); +} + +/** + * Takes a properly formatted log file name and extracts the date information + * and filetype, returning the results as an Array. + * + * Filenames are expected to be formatted as: + * + * YYYY-MM-DD.HHmmSS+ZZzz.format + * + * @param aFilename the name of the file + * @returns an Array, where the first element is a Date object for the date + * that the log file represents, and the file type as a string. + */ +function getDateFromFilename(aFilename) { + const kRegExp = + /([\d]{4})-([\d]{2})-([\d]{2}).([\d]{2})([\d]{2})([\d]{2})([+-])([\d]{2})([\d]{2}).*\.([A-Za-z]+)$/; + + let r = aFilename.match(kRegExp); + if (!r) { + console.error( + "Found log file with name not matching YYYY-MM-DD.HHmmSS+ZZzz.format: " + + aFilename + ); + return []; + } + + // We ignore the timezone offset for now (FIXME) + return [new Date(r[1], r[2] - 1, r[3], r[4], r[5], r[6]), r[10]]; +} + +function LogMessage(aData, aConversation) { + this._init(aData.who, aData.text, {}, aConversation); + // Not overriding time using the init options, since init also sets the + // property. + this.time = Math.round(new Date(aData.date) / 1000); + if ("alias" in aData) { + this._alias = aData.alias; + } + this.remoteId = aData.remoteId; + if (aData.flags) { + for (let flag of aData.flags) { + this[flag] = true; + } + } +} + +LogMessage.prototype = { + __proto__: GenericMessagePrototype, + _interfaces: [Ci.imIMessage, Ci.prplIMessage], + get displayMessage() { + return this.originalMessage; + }, +}; + +function LogConversation(aMessages, aProperties) { + this._messages = aMessages; + for (let property in aProperties) { + this[property] = aProperties[property]; + } +} +LogConversation.prototype = { + __proto__: ClassInfo("imILogConversation", "Log conversation object"), + get isChat() { + return this._isChat; + }, + get buddy() { + return null; + }, + get account() { + return { + alias: "", + name: this._accountName, + normalizedName: this._accountName, + protocol: { name: this._protocolName }, + statusInfo: IMServices.core.globalUserStatus, + }; + }, + getMessages() { + // Start with the newest message to filter out older versions of the same + // message. Also filter out deleted messages. + return this._messages.map(m => new LogMessage(m, this)); + }, +}; + +/** + * A Log object represents one or more log files. The constructor expects one + * argument, which is either a single path to a json log file or an array of + * objects each having two properties: + * path: The full path of the (json only) log file it represents. + * time: The Date object extracted from the filename of the logfile. + * + * The returned Log object's time property will be: + * For a single file - exact time extracted from the name of the log file. + * For a set of files - the time extracted, reduced to the day. + */ +function Log(aEntries) { + if (typeof aEntries == "string") { + // Assume that aEntries is a single path. + let path = aEntries; + this.path = path; + let [date, format] = getDateFromFilename(PathUtils.filename(path)); + if (!date || !format) { + this.time = 0; + return; + } + this.time = date.valueOf() / 1000; + // Wrap the path in an array + this._entryPaths = [path]; + return; + } + + if (!aEntries.length) { + throw new Error( + "Log was passed an invalid argument, " + + "expected a non-empty array or a string." + ); + } + + // Assume aEntries is an array of objects. + // Sort our list of entries for this day in increasing order. + aEntries.sort((aLeft, aRight) => aLeft.time - aRight.time); + + this._entryPaths = aEntries.map(entry => entry.path); + // Calculate the timestamp for the first entry down to the day. + let timestamp = new Date(aEntries[0].time); + timestamp.setHours(0); + timestamp.setMinutes(0); + timestamp.setSeconds(0); + this.time = timestamp.valueOf() / 1000; + // Path is used to uniquely identify a Log, and sometimes used to + // quickly determine which directory a log file is from. We'll use + // the first file's path. + this.path = aEntries[0].path; +} +Log.prototype = { + __proto__: ClassInfo("imILog", "Log object"), + _entryPaths: null, + async getConversation() { + /* + * Read the set of log files asynchronously and return a promise that + * resolves to a LogConversation instance. Even if a file contains some + * junk (invalid JSON), messages that are valid will be read. If the first + * line of metadata is corrupt however, the data isn't useful and the + * promise will resolve to null. + */ + let messages = []; + let properties = {}; + let firstFile = true; + let decoder = new TextDecoder(); + let lastRemoteIdIndex = {}; + for (let path of this._entryPaths) { + let lines; + try { + let contents = await queueFileOperation(path, () => IOUtils.read(path)); + lines = decoder.decode(contents).split("\n"); + } catch (aError) { + console.error('Error reading log file "' + path + '":\n' + aError); + continue; + } + let nextLine = lines.shift(); + let filename = PathUtils.filename(path); + + let data; + try { + // This will fail if either nextLine is undefined, or not valid JSON. + data = JSON.parse(nextLine); + } catch (aError) { + messages.push({ + who: "sessionstart", + date: getDateFromFilename(filename)[0], + text: lazy._("badLogfile", filename), + flags: ["noLog", "notification", "error", "system"], + }); + continue; + } + + if (firstFile || !data.continuedSession) { + messages.push({ + who: "sessionstart", + date: getDateFromFilename(filename)[0], + text: "", + flags: ["noLog", "notification"], + }); + } + + if (firstFile) { + properties.startDate = new Date(data.date) * 1000; + properties.name = data.name; + properties.title = data.title; + properties._accountName = data.account; + properties._protocolName = data.protocol; + properties._isChat = data.isChat; + properties.normalizedName = data.normalizedName; + firstFile = false; + } + + while (lines.length) { + nextLine = lines.shift(); + if (!nextLine) { + break; + } + try { + let message = JSON.parse(nextLine); + + // Backwards compatibility for old action messages. + if ( + !message.flags.includes("action") && + message.text?.startsWith("/me ") + ) { + message.flags.push("action"); + message.text = message.text.slice(4); + } + + if (message.remoteId) { + lastRemoteIdIndex[message.remoteId] = messages.length; + } + messages.push(message); + } catch (e) { + // If a message line contains junk, just ignore the error and + // continue reading the conversation. + } + } + } + + if (firstFile) { + // All selected log files are invalid. + return null; + } + + // Ignore older versions of edited messages and deleted messages. + messages = messages.filter((message, index) => { + if ( + message.remoteId && + lastRemoteIdIndex.hasOwnProperty(message.remoteId) && + index < lastRemoteIdIndex[message.remoteId] + ) { + return false; + } + return !message.flags.includes("deleted"); + }); + + return new LogConversation(messages, properties); + }, +}; + +/** + * logsGroupedByDay() organizes log entries by date. + * + * @param {string[]} aEntries - paths of log files to be parsed. + * @returns {imILog[]} Logs, ordered by day. + */ +function logsGroupedByDay(aEntries) { + if (!Array.isArray(aEntries)) { + return []; + } + + let entries = {}; + for (let path of aEntries) { + let [logDate, logFormat] = getDateFromFilename(PathUtils.filename(path)); + if (!logDate) { + // We'll skip this one, since it's got a busted filename. + continue; + } + + let dateForID = new Date(logDate); + let dayID; + // If the file isn't a JSON file, ignore it. + if (logFormat != "json") { + continue; + } + // We want to cluster all of the logs that occur on the same day + // into the same Arrays. We clone the date for the log, reset it to + // the 0th hour/minute/second, and use that to construct an ID for the + // Array we'll put the log in. + dateForID.setHours(0); + dateForID.setMinutes(0); + dateForID.setSeconds(0); + dayID = dateForID.toISOString(); + + if (!(dayID in entries)) { + entries[dayID] = []; + } + + entries[dayID].push({ + path, + time: logDate, + }); + } + + let days = Object.keys(entries); + days.sort(); + return days.map(dayID => new Log(entries[dayID])); +} + +export function Logger() { + IOUtils.profileBeforeChange.addBlocker( + "Chat logger: writing all pending messages", + async function () { + for (let promise of gFilePromises.values()) { + try { + await promise; + } catch (aError) { + // Ignore the error, whatever queued the operation will take care of it. + } + } + } + ); + + Services.obs.addObserver(this, "new-text"); + Services.obs.addObserver(this, "conversation-closed"); + Services.obs.addObserver(this, "conversation-left-chat"); + initLogCleanup(); +} + +Logger.prototype = { + // Returned Promise resolves to an array of entries for the + // log folder if it exists, otherwise null. + async _getLogEntries(aAccount, aNormalizedName) { + let path; + try { + path = PathUtils.join( + getLogFolderPathForAccount(aAccount), + encodeName(aNormalizedName) + ); + if (await queueFileOperation(path, () => IOUtils.exists(path))) { + return await IOUtils.getChildren(path); + } + } catch (aError) { + console.error( + 'Error getting directory entries for "' + path + '":\n' + aError + ); + } + return []; + }, + async getLogFromFile(aFilePath, aGroupByDay) { + if (!aGroupByDay) { + return new Log(aFilePath); + } + let [targetDate] = getDateFromFilename(PathUtils.filename(aFilePath)); + if (!targetDate) { + return null; + } + + targetDate = targetDate.toDateString(); + + // We'll assume that the files relevant to our interests are + // in the same folder as the one provided. + let relevantEntries = []; + for (const path of await IOUtils.getChildren(PathUtils.parent(aFilePath))) { + const stat = await IOUtils.stat(path); + if (stat.type === "directory") { + continue; + } + let [logTime] = getDateFromFilename(PathUtils.filename(path)); + // If someone placed a 'foreign' file into the logs directory, + // pattern matching fails and getDateFromFilename() returns []. + if (logTime && targetDate == logTime.toDateString()) { + relevantEntries.push({ + path, + time: logTime, + }); + } + } + return new Log(relevantEntries); + }, + + async getLogPathsForConversation(aConversation) { + let writer = gLogWritersById.get(aConversation.id); + // Resolve to null if we haven't created a LogWriter yet for this conv, or + // if logging is disabled (paths will be null). + if (!writer || !writer.paths) { + return null; + } + let paths = writer.paths; + // Wait for any pending file operations to finish, then resolve to the paths + // regardless of whether these operations succeeded. + for (let path of paths) { + await gFilePromises.get(path); + } + return paths; + }, + async getLogsForContact(aContact) { + let entries = []; + for (let buddy of aContact.getBuddies()) { + for (let accountBuddy of buddy.getAccountBuddies()) { + entries = entries.concat( + await this._getLogEntries( + accountBuddy.account, + accountBuddy.normalizedName + ) + ); + } + } + return logsGroupedByDay(entries); + }, + getLogsForConversation(aConversation) { + let name = aConversation.normalizedName; + if (aConversation.isChat) { + name += ".chat"; + } + + return this._getLogEntries(aConversation.account, name).then(entries => + logsGroupedByDay(entries) + ); + }, + async getSimilarLogs(log) { + let entries; + try { + entries = await IOUtils.getChildren(PathUtils.parent(log.path)); + } catch (aError) { + console.error( + 'Error getting similar logs for "' + log.path + '":\n' + aError + ); + } + // If there was an error, this will return an empty array. + return logsGroupedByDay(entries); + }, + + getLogFolderPathForAccount(aAccount) { + return getLogFolderPathForAccount(aAccount); + }, + + deleteLogFolderForAccount(aAccount) { + if (!aAccount.disconnecting && !aAccount.disconnected) { + throw new Error( + "Account must be disconnected first before deleting logs." + ); + } + + if (aAccount.disconnecting) { + console.error( + "Account is still disconnecting while we attempt to remove logs." + ); + } + + let logPath = this.getLogFolderPathForAccount(aAccount); + // Find all operations on files inside the log folder. + let pendingPromises = []; + function checkLogFiles(promiseOperation, filePath) { + if (filePath.startsWith(logPath)) { + pendingPromises.push(promiseOperation); + } + } + gFilePromises.forEach(checkLogFiles); + // After all operations finish, remove the whole log folder. + return Promise.all(pendingPromises) + .then(values => { + IOUtils.remove(logPath, { recursive: true }); + }) + .catch(aError => + console.error("Failed to remove log folders:\n" + aError) + ); + }, + + async forEach(aCallback) { + let getAllSubdirs = async function (aPaths, aErrorMsg) { + let entries = []; + for (let path of aPaths) { + try { + entries = entries.concat(await IOUtils.getChildren(path)); + } catch (aError) { + if (aErrorMsg) { + console.error(aErrorMsg + "\n" + aError); + } + } + } + let filteredPaths = []; + for (let path of entries) { + const stat = await IOUtils.stat(path); + if (stat.type === "directory") { + filteredPaths.push(path); + } + } + return filteredPaths; + }; + + let logsPath = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "logs" + ); + let prpls = await getAllSubdirs([logsPath]); + let accounts = await getAllSubdirs( + prpls, + "Error while sweeping prpl folder:" + ); + let logFolders = await getAllSubdirs( + accounts, + "Error while sweeping account folder:" + ); + for (let folder of logFolders) { + try { + for (const path of await IOUtils.getChildren(folder)) { + const stat = await IOUtils.stat(path); + if (stat.type === "directory" || !path.endsWith(".json")) { + continue; + } + await aCallback.processLog(path); + } + } catch (aError) { + // If the callback threw, reject the promise and let the caller handle it. + if (!DOMException.isInstance(aError)) { + throw aError; + } + console.error("Error sweeping log folder:\n" + aError); + } + } + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "new-text": + let excludeBecauseEncrypted = false; + if (aSubject.isEncrypted) { + excludeBecauseEncrypted = !Services.prefs.getBoolPref( + "messenger.account." + + aSubject.conversation.account.id + + ".options.otrAllowMsgLog", + Services.prefs.getBoolPref("chat.otr.default.allowMsgLog") + ); + } + if (!aSubject.noLog && !excludeBecauseEncrypted) { + let log = getLogWriter(aSubject.conversation); + log.logMessage(aSubject); + } + break; + case "conversation-closed": + case "conversation-left-chat": + closeLogWriter(aSubject); + break; + default: + throw new Error("Unexpected notification " + aTopic); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver", "imILogger"]), + classDescription: "Logger", +}; diff --git a/comm/chat/components/src/moz.build b/comm/chat/components/src/moz.build new file mode 100644 index 0000000000..cbab7e998b --- /dev/null +++ b/comm/chat/components/src/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"] + +EXTRA_JS_MODULES += [ + "imAccounts.sys.mjs", + "imCommands.sys.mjs", + "imContacts.sys.mjs", + "imConversations.sys.mjs", + "imCore.sys.mjs", + "logger.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/comm/chat/components/src/test/test_accounts.js b/comm/chat/components/src/test/test_accounts.js new file mode 100644 index 0000000000..267095455f --- /dev/null +++ b/comm/chat/components/src/test/test_accounts.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); + +function run_test() { + do_get_profile(); + + // Test the handling of accounts for unknown protocols. + const kAccountName = "Unknown"; + const kPrplId = "prpl-unknown"; + + let prefs = Services.prefs; + prefs.setCharPref("messenger.account.account1.name", kAccountName); + prefs.setCharPref("messenger.account.account1.prpl", kPrplId); + prefs.setCharPref("mail.accountmanager.accounts", "account1"); + prefs.setCharPref("mail.account.account1.server", "server1"); + prefs.setCharPref("mail.server.server1.imAccount", "account1"); + prefs.setCharPref("mail.server.server1.type", "im"); + prefs.setCharPref("mail.server.server1.userName", kAccountName); + prefs.setCharPref("mail.server.server1.hostname", kPrplId); + try { + // Having an implementation of nsIXULAppInfo is required for + // IMServices.core.init to work. + updateAppInfo(); + IMServices.core.init(); + + let account = IMServices.accounts.getAccountByNumericId(1); + Assert.ok(account instanceof Ci.imIAccount); + Assert.equal(account.name, kAccountName); + Assert.equal(account.normalizedName, kAccountName); + Assert.equal(account.protocol.id, kPrplId); + Assert.equal( + account.connectionErrorReason, + Ci.imIAccount.ERROR_UNKNOWN_PRPL + ); + } finally { + IMServices.core.quit(); + + prefs.deleteBranch("messenger"); + } +} diff --git a/comm/chat/components/src/test/test_commands.js b/comm/chat/components/src/test/test_commands.js new file mode 100644 index 0000000000..de0fd0e665 --- /dev/null +++ b/comm/chat/components/src/test/test_commands.js @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +// We don't load the command service via Services as we want to access +// _findCommands in order to avoid having to intercept command execution. +var { CommandsService } = ChromeUtils.importESModule( + "resource:///modules/imCommands.sys.mjs" +); + +var kPrplId = "green"; +var kPrplId2 = "red"; + +var fakeAccount = { + connected: true, + protocol: { id: kPrplId }, +}; +var fakeDisconnectedAccount = { + connected: false, + protocol: { id: kPrplId }, +}; +var fakeAccount2 = { + connected: true, + protocol: { id: kPrplId2 }, +}; + +var fakeConversation = { + account: fakeAccount, + isChat: true, +}; + +function fakeCommand(aName, aUsageContext) { + this.name = aName; + if (aUsageContext) { + this.usageContext = aUsageContext; + } +} +fakeCommand.prototype = { + get helpString() { + return ""; + }, + usageContext: Ci.imICommand.CMD_CONTEXT_ALL, + priority: Ci.imICommand.CMD_PRIORITY_PRPL, + run: (aMsg, aConv) => true, +}; + +function run_test() { + let cmdserv = new CommandsService(); + cmdserv.initCommands(); + + // Some commands providing multiple possible completions. + cmdserv.registerCommand(new fakeCommand("banana"), kPrplId2); + cmdserv.registerCommand(new fakeCommand("baloney"), kPrplId2); + + // MUC-only command. + cmdserv.registerCommand( + new fakeCommand("balderdash", Ci.imICommand.CMD_CONTEXT_CHAT), + kPrplId + ); + + // Name clashes with global command. + cmdserv.registerCommand(new fakeCommand("offline"), kPrplId); + + // Name starts with another command name. + cmdserv.registerCommand(new fakeCommand("helpme"), kPrplId); + + // Command name contains numbers. + cmdserv.registerCommand(new fakeCommand("r9kbeta"), kPrplId); + + // Array of (possibly partial) command names as entered by the user. + let testCmds = [ + "x", + "b", + "ba", + "bal", + "back", + "hel", + "help", + "off", + "offline", + ]; + + // We test an array of different possible conversations. + // cmdlist lists all the available commands for the given conversation. + // results is an array which for each testCmd provides an array containing + // data with which the return value of _findCommands can be checked. In + // particular, the name of the command and whether the first (i.e. preferred) + // entry in the returned array of commands is a prpl command. (If the latter + // boolean is not given, false is assumed, if the name is not given, that + // corresponds to no commands being returned.) + let testData = [ + { + desc: "No conversation argument.", + cmdlist: "away, back, busy, dnd, help, offline, raw, say", + results: [ + [], + [], + ["back"], + [], + ["back"], + ["help"], + ["help"], + ["offline"], + ["offline"], + ], + }, + { + desc: "Disconnected conversation with fakeAccount.", + conv: { + account: fakeDisconnectedAccount, + }, + cmdlist: + "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say", + results: [ + [], + [], + ["back"], + [], + ["back"], + ["help"], + ["help"], + ["offline"], + ["offline"], + ], + }, + { + desc: "Conversation with fakeAccount.", + conv: { + account: fakeAccount, + }, + cmdlist: + "away, back, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say", + results: [ + [], + [], + ["back"], + [], + ["back"], + [], + ["help"], + ["offline"], + ["offline"], + ], + }, + { + desc: "MUC with fakeAccount.", + conv: { + account: fakeAccount, + isChat: true, + }, + cmdlist: + "away, back, balderdash, busy, dnd, help, helpme, offline, offline, r9kbeta, raw, say", + results: [ + [], + [], + [], + ["balderdash", true], + ["back"], + [], + ["help"], + ["offline"], + ["offline"], + ], + }, + { + desc: "Conversation with fakeAccount2.", + conv: { + account: fakeAccount2, + }, + cmdlist: + "away, back, baloney, banana, busy, dnd, help, offline, raw, say", + results: [ + [], + [], + [], + ["baloney", true], + ["back"], + ["help"], + ["help"], + ["offline"], + ["offline"], + ], + }, + { + desc: "MUC with fakeAccount2.", + conv: { + account: fakeAccount2, + isChat: true, + }, + cmdlist: + "away, back, baloney, banana, busy, dnd, help, offline, raw, say", + results: [ + [], + [], + [], + ["baloney", true], + ["back"], + ["help"], + ["help"], + ["offline"], + ["offline"], + ], + }, + ]; + + for (let test of testData) { + info("The following tests are with: " + test.desc); + + // Check which commands are available in which context. + let cmdlist = cmdserv + .listCommandsForConversation(test.conv) + .map(aCmd => aCmd.name) + .sort() + .join(", "); + Assert.equal(cmdlist, test.cmdlist); + + for (let testCmd of testCmds) { + info("Testing command found for '" + testCmd + "'"); + let expectedResult = test.results.shift(); + let cmdArray = cmdserv._findCommands(test.conv, testCmd); + // Check whether commands are only returned when appropriate. + Assert.equal(cmdArray.length > 0, expectedResult.length > 0); + if (cmdArray.length) { + // Check if the right command was returned. + Assert.equal(cmdArray[0].name, expectedResult[0]); + Assert.equal( + cmdArray[0].priority == Ci.imICommand.CMD_PRIORITY_PRPL, + !!expectedResult[1] + ); + } + } + } + + // Array of messages to test command execution of. + let testMessages = [ + { + message: "/r9kbeta", + result: true, + }, + { + message: "/helpme 2 arguments", + result: true, + }, + { + message: "nocommand", + result: false, + }, + { + message: "/-a", + result: false, + }, + { + message: "/notregistered", + result: false, + }, + ]; + + // Test command execution. + for (let executionTest of testMessages) { + info("Testing command execution for '" + executionTest.message + "'"); + Assert.equal( + cmdserv.executeCommand(executionTest.message, fakeConversation), + executionTest.result + ); + } + + cmdserv.unInitCommands(); +} diff --git a/comm/chat/components/src/test/test_conversations.js b/comm/chat/components/src/test/test_conversations.js new file mode 100644 index 0000000000..c1ede89734 --- /dev/null +++ b/comm/chat/components/src/test/test_conversations.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { GenericConvIMPrototype, Message } = ChromeUtils.importESModule( + "resource:///modules/jsProtoHelper.sys.mjs" +); +var { imMessage, UIConversation } = ChromeUtils.importESModule( + "resource:///modules/imConversations.sys.mjs" +); + +// Fake prplConversation +var _id = 0; +function Conversation(aName) { + this._name = aName; + this._observers = []; + this._date = Date.now() * 1000; + this.id = ++_id; +} +Conversation.prototype = { + __proto__: GenericConvIMPrototype, + _account: { + imAccount: { + protocol: { name: "Fake Protocol" }, + alias: "", + name: "Fake Account", + }, + ERROR(e) { + throw e; + }, + DEBUG() {}, + }, + addObserver(aObserver) { + if (!(aObserver instanceof Ci.nsIObserver)) { + aObserver = { observe: aObserver }; + } + GenericConvIMPrototype.addObserver.call(this, aObserver); + }, +}; + +// Ensure that when iMsg.message is set to a message (including the empty +// string), it returns that message. If not, it should return the original +// message. This prevents regressions due to JS coercions. +var test_null_message = function () { + let originalMessage = "Hi!"; + let pMsg = new Message( + "buddy", + originalMessage, + { + outgoing: true, + _alias: "buddy", + time: Date.now(), + }, + null + ); + let iMsg = new imMessage(pMsg); + equal(iMsg.message, originalMessage, "Expected the original message."); + // Setting the message should prevent a fallback to the original. + iMsg.message = ""; + equal( + iMsg.message, + "", + "Expected an empty string; not the original message." + ); + equal( + iMsg.originalMessage, + originalMessage, + "Expected the original message." + ); +}; + +// ROT13, used as an example transformation. +function rot13(aString) { + return aString.replace(/[a-zA-Z]/g, function (c) { + return String.fromCharCode( + c.charCodeAt(0) + (c.toLowerCase() < "n" ? 1 : -1) * 13 + ); + }); +} + +// A test that exercises the message transformation pipeline. +// +// From the sending users perspective, this looks like: +// -> protocol sendMsg +// -> protocol notifyObservers `preparing-message` +// -> protocol prepareForSending +// -> protocol notifyObservers `sending-message` +// -> protocol dispatchMessage (jsProtoHelper specific) +// -> protocol writeMessage +// -> protocol notifyObservers `new-text` +// -> UIConv notifyObservers `received-message` +// -> protocol prepareForDisplaying +// -> UIConv notifyObservers `new-text` +// +// From the receiving users perspective, they get: +// -> protocol writeMessage +// -> protocol notifyObservers `new-text` +// -> UIConv notifyObservers `received-message` +// -> protocol prepareForDisplaying +// -> UIConv notifyObservers `new-text` +// +// The test walks the sending path, which covers both. +add_task(function test_message_transformation() { + let conv = new Conversation(); + conv.dispatchMessage = function (aMsg) { + this.writeMessage("user", aMsg, { outgoing: true }); + }; + + let message = "Hello!"; + let receivedMsg = false, + newTxt = false; + + let uiConv = new UIConversation(conv); + uiConv.addObserver({ + observe(aObject, aTopic, aMsg) { + switch (aTopic) { + case "sending-message": + ok(!newTxt, "sending-message should fire before new-text."); + ok( + !receivedMsg, + "sending-message should fire before received-message." + ); + ok( + aObject.QueryInterface(Ci.imIOutgoingMessage), + "Wrong message type." + ); + aObject.message = rot13(aObject.message); + break; + case "received-message": + ok(!newTxt, "received-message should fire before new-text."); + ok( + !receivedMsg, + "Sanity check that receive-message hasn't fired yet." + ); + ok(aObject.outgoing, "Expected an outgoing message."); + ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type."); + equal( + aObject.displayMessage, + rot13(message), + "Expected to have been rotated while sending-message." + ); + aObject.displayMessage = rot13(aObject.displayMessage); + receivedMsg = true; + break; + case "new-text": + ok(!newTxt, "Sanity check that new-text hasn't fired yet."); + ok(receivedMsg, "Expected received-message to have fired."); + ok(aObject.outgoing, "Expected an outgoing message."); + ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type."); + equal( + aObject.displayMessage, + message, + "Expected to have been rotated back to msg in received-message." + ); + newTxt = true; + break; + } + }, + }); + + uiConv.sendMsg(message); + ok(newTxt, "Expected new-text to have fired."); +}); + +// A test that cancels a message before it gets displayed. +add_task(function test_cancel_display_message() { + let conv = new Conversation(); + conv.dispatchMessage = function (aMsg) { + this.writeMessage("user", aMsg, { outgoing: true }); + }; + + let received = false; + let uiConv = new UIConversation(conv); + uiConv.addObserver({ + observe(aObject, aTopic, aMsg) { + switch (aTopic) { + case "received-message": + ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type."); + aObject.cancelled = true; + received = true; + break; + case "new-text": + ok(false, "Should not fire for a cancelled message."); + break; + } + }, + }); + + uiConv.sendMsg("Hi!"); + ok(received, "The received-message notification was never fired."); +}); + +var test_update_message = function () { + let conv = new Conversation(); + + let uiConv = new UIConversation(conv); + let message = "Hello!"; + let receivedMsg = false; + let updateText = false; + + uiConv.addObserver({ + observe(aObject, aTopic, aMsg) { + switch (aTopic) { + case "received-message": + ok(!updateText, "received-message should fire before update-text."); + ok( + !receivedMsg, + "Sanity check that receive-message hasn't fired yet." + ); + ok(aObject.incoming, "Expected an incoming message."); + ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type."); + equal(aObject.displayMessage, message, "Wrong message contents"); + aObject.displayMessage = rot13(aObject.displayMessage); + receivedMsg = true; + break; + case "update-text": + ok(!updateText, "Sanity check that update-text hasn't fired yet."); + ok(receivedMsg, "Expected received-message to have fired."); + ok(aObject.incoming, "Expected an incoming message."); + ok(aObject.QueryInterface(Ci.imIMessage), "Wrong message type."); + equal( + aObject.displayMessage, + rot13(message), + "Expected to have been rotated in received-message." + ); + updateText = true; + break; + } + }, + }); + + conv.updateMessage("user", message, { incoming: true, remoteId: "foo" }); + ok(updateText, "Expected update-text to have fired."); +}; + +add_task(test_null_message); +add_task(test_update_message); diff --git a/comm/chat/components/src/test/test_init.js b/comm/chat/components/src/test/test_init.js new file mode 100644 index 0000000000..48f064027f --- /dev/null +++ b/comm/chat/components/src/test/test_init.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); + +// Modules that should only be loaded once a chat account exists. +var ACCOUNT_MODULES = new Set([ + "resource:///modules/matrixAccount.sys.mjs", + "resource:///modules/matrix-sdk.sys.mjs", + "resource:///modules/ircAccount.sys.mjs", + "resource:///modules/ircHandlers.sys.mjs", + "resource:///modules/xmpp-base.sys.mjs", + "resource:///modules/xmpp-session.sys.mjs", +]); + +add_task(function test_coreInitLoadedModules() { + do_get_profile(); + // Make sure protocols are all loaded. + IMServices.core.init(); + IMServices.core.getProtocols(); + + for (const module of ACCOUNT_MODULES) { + ok(!Cu.isESModuleLoaded(module), `${module} should be loaded later`); + } +}); diff --git a/comm/chat/components/src/test/test_logger.js b/comm/chat/components/src/test/test_logger.js new file mode 100644 index 0000000000..be93d8b300 --- /dev/null +++ b/comm/chat/components/src/test/test_logger.js @@ -0,0 +1,860 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +do_get_profile(); + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); + +const { + Logger, + gFilePromises, + gPendingCleanup, + queueFileOperation, + getLogFolderPathForAccount, + encodeName, + getLogFilePathForConversation, + getNewLogFileName, + appendToFile, + getLogWriter, + closeLogWriter, +} = ChromeUtils.importESModule("resource:///modules/logger.sys.mjs"); + +var logDirPath = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "logs" +); + +var dummyAccount = { + name: "dummy-account", + normalizedName: "dummyaccount", + protocol: { + normalizedName: "dummy", + id: "prpl-dummy", + }, +}; + +var dummyConv = { + account: dummyAccount, + id: 0, + title: "dummy conv", + normalizedName: "dummyconv", + get name() { + return this.normalizedName; + }, + get startDate() { + return new Date(2011, 5, 28).valueOf() * 1000; + }, + isChat: false, +}; + +// A day after the first one. +var dummyConv2 = { + account: dummyAccount, + id: 0, + title: "dummy conv", + normalizedName: "dummyconv", + get name() { + return this.normalizedName; + }, + get startDate() { + return new Date(2011, 5, 29).valueOf() * 1000; + }, + isChat: false, +}; + +var dummyMUC = { + account: dummyAccount, + id: 1, + title: "Dummy MUC", + normalizedName: "dummymuc", + get name() { + return this.normalizedName; + }, + startDate: new Date(2011, 5, 28).valueOf() * 1000, + isChat: true, +}; + +var encodeName_input = [ + "CON", + "PRN", + "AUX", + "NUL", + "COM3", + "LPT5", + "file", + "file.", + "file ", + "file_", + "file<", + "file>", + "file:", + 'file"', + "file/", + "file\\", + "file|", + "file?", + "file*", + "file&", + "file%", + "fi<le", + "fi>le", + "fi:le", + 'fi"le', + "fi/le", + "fi\\le", + "fi|le", + "fi?le", + "fi*le", + "fi&le", + "fi%le", + "<file", + ">file", + ":file", + '"file', + "/file", + "\\file", + "|file", + "?file", + "*file", + "&file", + "%file", + "\\fi?*&%le<>", +]; + +var encodeName_output = [ + "%CON", + "%PRN", + "%AUX", + "%NUL", + "%COM3", + "%LPT5", + "file", + "file._", + "file _", + "file__", + "file%3c", + "file%3e", + "file%3a", + "file%22", + "file%2f", + "file%5c", + "file%7c", + "file%3f", + "file%2a", + "file%26", + "file%25", + "fi%3cle", + "fi%3ele", + "fi%3ale", + "fi%22le", + "fi%2fle", + "fi%5cle", + "fi%7cle", + "fi%3fle", + "fi%2ale", + "fi%26le", + "fi%25le", + "%3cfile", + "%3efile", + "%3afile", + "%22file", + "%2ffile", + "%5cfile", + "%7cfile", + "%3ffile", + "%2afile", + "%26file", + "%25file", + "%5c" + "fi" + "%3f%2a%26%25" + "le" + "%3c%3e", // eslint-disable-line no-useless-concat +]; + +var test_queueFileOperation = async function () { + let dummyRejectedOperation = () => Promise.reject("Rejected!"); + let dummyResolvedOperation = () => Promise.resolve("Resolved!"); + + // Immediately after calling qFO, "path1" should be mapped to p1. + // After yielding, the reference should be cleared from the map. + let p1 = queueFileOperation("path1", dummyResolvedOperation); + equal(gFilePromises.get("path1"), p1); + await p1; + ok(!gFilePromises.has("path1")); + + // Repeat above test for a rejected promise. + let p2 = queueFileOperation("path2", dummyRejectedOperation); + equal(gFilePromises.get("path2"), p2); + // This should throw since p2 rejected. Drop the error. + await p2.then( + () => do_throw(), + () => {} + ); + ok(!gFilePromises.has("path2")); + + let onPromiseComplete = (aPromise, aHandler) => { + return aPromise.then(aHandler, aHandler); + }; + let test_queueOrder = aOperation => { + let promise = queueFileOperation("queueOrderPath", aOperation); + let firstOperationComplete = false; + onPromiseComplete(promise, () => (firstOperationComplete = true)); + return queueFileOperation("queueOrderPath", () => { + ok(firstOperationComplete); + }); + }; + // Test the queue order for rejected and resolved promises. + await test_queueOrder(dummyResolvedOperation); + await test_queueOrder(dummyRejectedOperation); +}; + +var test_getLogFolderPathForAccount = async function () { + let path = getLogFolderPathForAccount(dummyAccount); + equal( + PathUtils.join( + logDirPath, + dummyAccount.protocol.normalizedName, + encodeName(dummyAccount.normalizedName) + ), + path + ); +}; + +// Tests the global function getLogFilePathForConversation in logger.js. +var test_getLogFilePathForConversation = async function () { + let path = getLogFilePathForConversation(dummyConv); + let expectedPath = PathUtils.join( + logDirPath, + dummyAccount.protocol.normalizedName, + encodeName(dummyAccount.normalizedName) + ); + expectedPath = PathUtils.join( + expectedPath, + encodeName(dummyConv.normalizedName) + ); + expectedPath = PathUtils.join( + expectedPath, + getNewLogFileName(dummyConv.startDate / 1000) + ); + equal(path, expectedPath); +}; + +var test_getLogFilePathForMUC = async function () { + let path = getLogFilePathForConversation(dummyMUC); + let expectedPath = PathUtils.join( + logDirPath, + dummyAccount.protocol.normalizedName, + encodeName(dummyAccount.normalizedName) + ); + expectedPath = PathUtils.join( + expectedPath, + encodeName(dummyMUC.normalizedName + ".chat") + ); + expectedPath = PathUtils.join( + expectedPath, + getNewLogFileName(dummyMUC.startDate / 1000) + ); + equal(path, expectedPath); +}; + +var test_appendToFile = async function () { + const kStringToWrite = "Hello, world!"; + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "testFile.txt" + ); + await IOUtils.write(path, new Uint8Array()); + appendToFile(path, kStringToWrite); + appendToFile(path, kStringToWrite); + ok(await queueFileOperation(path, () => IOUtils.exists(path))); + let text = await queueFileOperation(path, () => IOUtils.readUTF8(path)); + // The read text should be equal to kStringToWrite repeated twice. + equal(text, kStringToWrite + kStringToWrite); + await IOUtils.remove(path); +}; + +add_task(async function test_appendToFileHeader() { + const kStringToWrite = "Lorem ipsum"; + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "headerTestFile.txt" + ); + await appendToFile(path, kStringToWrite, true); + await appendToFile(path, kStringToWrite, true); + let text = await queueFileOperation(path, () => IOUtils.readUTF8(path)); + // The read text should be equal to kStringToWrite once, since the second + // create should just noop. + equal(text, kStringToWrite); + await IOUtils.remove(path); +}); + +// Tests the getLogPathsForConversation API defined in the imILogger interface. +var test_getLogPathsForConversation = async function () { + let logger = new Logger(); + let paths = await logger.getLogPathsForConversation(dummyConv); + // The path should be null since a LogWriter hasn't been created yet. + equal(paths, null); + let logWriter = getLogWriter(dummyConv); + paths = await logger.getLogPathsForConversation(dummyConv); + equal(paths.length, 1); + equal(paths[0], logWriter.currentPath); + ok(await IOUtils.exists(paths[0])); + // Ensure this doesn't interfere with future tests. + await IOUtils.remove(paths[0]); + closeLogWriter(dummyConv); +}; + +var test_logging = async function () { + let logger = new Logger(); + let oneSec = 1000000; // Microseconds. + + // Creates a set of dummy messages for a conv (sets appropriate times). + let getMsgsForConv = function (aConv) { + // Convert to seconds because that's what logMessage expects. + let startTime = Math.round(aConv.startDate / oneSec); + return [ + { + time: startTime + 1, + who: "personA", + displayMessage: "Hi!", + outgoing: true, + }, + { + time: startTime + 2, + who: "personB", + displayMessage: "Hello!", + incoming: true, + }, + { + time: startTime + 3, + who: "personA", + displayMessage: "What's up?", + outgoing: true, + }, + { + time: startTime + 4, + who: "personB", + displayMessage: "Nothing much!", + incoming: true, + }, + { + time: startTime + 5, + who: "personB", + displayMessage: "Encrypted msg", + remoteId: "identifier", + incoming: true, + isEncrypted: true, + }, + { + time: startTime + 6, + who: "personA", + displayMessage: "Deleted", + remoteId: "otherID", + outgoing: true, + isEncrypted: true, + deleted: true, + }, + ]; + }; + let firstDayMsgs = getMsgsForConv(dummyConv); + let secondDayMsgs = getMsgsForConv(dummyConv2); + + let logMessagesForConv = async function (aConv, aMessages) { + let logWriter = getLogWriter(aConv); + for (let message of aMessages) { + logWriter.logMessage(message); + } + // If we don't wait for the messages to get written, we have no guarantee + // later in the test that the log files were created, and getConversation + // will return an EmptyEnumerator. Logging the messages is queued on the + // _initialized promise, so we need to await on that first. + await logWriter._initialized; + await gFilePromises.get(logWriter.currentPath); + // Ensure two different files for the different dates. + closeLogWriter(aConv); + }; + await logMessagesForConv(dummyConv, firstDayMsgs); + await logMessagesForConv(dummyConv2, secondDayMsgs); + + // Write a zero-length file and a file with incorrect JSON for each day + // to ensure they are handled correctly. + let logDir = PathUtils.parent(getLogFilePathForConversation(dummyConv)); + let createBadFiles = async function (aConv) { + let blankFile = PathUtils.join( + logDir, + getNewLogFileName((aConv.startDate + oneSec) / 1000) + ); + let invalidJSONFile = PathUtils.join( + logDir, + getNewLogFileName((aConv.startDate + 2 * oneSec) / 1000) + ); + await IOUtils.write(blankFile, new Uint8Array()); + await IOUtils.writeUTF8(invalidJSONFile, "This isn't JSON!"); + }; + await createBadFiles(dummyConv); + await createBadFiles(dummyConv2); + + let testMsgs = function (aMsgs, aExpectedMsgs, aExpectedSessions) { + // Ensure the number of session messages is correct. + let sessions = aMsgs.filter(aMsg => aMsg.who == "sessionstart").length; + equal(sessions, aExpectedSessions); + + // Discard session messages, etc. + aMsgs = aMsgs.filter(aMsg => !aMsg.noLog); + + equal(aMsgs.length, aExpectedMsgs.length); + + for (let i = 0; i < aMsgs.length; ++i) { + let message = aMsgs[i], + expectedMessage = aExpectedMsgs[i]; + for (let prop in expectedMessage) { + ok(prop in message); + equal(expectedMessage[prop], message[prop]); + } + } + }; + + // Accepts time in seconds, reduces it to a date, and returns the value in millis. + let reduceTimeToDate = function (aTime) { + let date = new Date(aTime * 1000); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + return date.valueOf(); + }; + + // Group expected messages by day. + let messagesByDay = new Map(); + messagesByDay.set( + reduceTimeToDate(firstDayMsgs[0].time), + firstDayMsgs.filter(msg => !msg.deleted) + ); + messagesByDay.set( + reduceTimeToDate(secondDayMsgs[0].time), + secondDayMsgs.filter(msg => !msg.deleted) + ); + + let logs = await logger.getLogsForConversation(dummyConv); + for (let log of logs) { + let conv = await log.getConversation(); + let date = reduceTimeToDate(log.time); + // 3 session messages - for daily logs, bad files are included. + testMsgs(conv.getMessages(), messagesByDay.get(date), 3); + } + + // Remove the created log files, testing forEach in the process. + await logger.forEach({ + async processLog(aLog) { + let info = await IOUtils.stat(aLog); + notEqual(info.type, "directory"); + ok(aLog.endsWith(".json")); + await IOUtils.remove(aLog); + }, + }); + let logFolder = PathUtils.parent(getLogFilePathForConversation(dummyConv)); + // The folder should now be empty - this will throw if it isn't. + await IOUtils.remove(logFolder, { ignoreAbsent: false }); +}; + +var test_logFileSplitting = async function () { + // Start clean, remove the log directory. + await IOUtils.remove(logDirPath, { recursive: true }); + let logWriter = getLogWriter(dummyConv); + let startTime = logWriter._startTime / 1000; // Message times are in seconds. + let oldPath = logWriter.currentPath; + let message = { + time: startTime, + who: "John Doe", + originalMessage: "Hello, world!", + outgoing: true, + }; + + let logMessage = async function (aMessage) { + logWriter.logMessage(aMessage); + await logWriter._initialized; + await gFilePromises.get(logWriter.currentPath); + }; + + await logMessage(message); + message.time += logWriter.kInactivityLimit / 1000 + 1; + // This should go in a new log file. + await logMessage(message); + notEqual(logWriter.currentPath, oldPath); + // The log writer's new start time should be the time of the message. + equal(message.time * 1000, logWriter._startTime); + + let getCurrentHeader = async function () { + return JSON.parse( + (await IOUtils.readUTF8(logWriter.currentPath)).split("\n")[0] + ); + }; + + // The header of the new log file should not have the continuedSession flag set. + ok(!(await getCurrentHeader()).continuedSession); + + // Set the start time sufficiently before midnight, and the last message time + // to just before midnight. A new log file should be created at midnight. + logWriter._startTime = new Date(logWriter._startTime).setHours( + 24, + 0, + 0, + -(logWriter.kDayOverlapLimit + 1) + ); + let nearlyMidnight = new Date(logWriter._startTime).setHours(24, 0, 0, -1); + oldPath = logWriter.currentPath; + logWriter._lastMessageTime = nearlyMidnight; + message.time = new Date(nearlyMidnight).setHours(24, 0, 0, 1) / 1000; + await logMessage(message); + // The message should have gone in a new file. + notEqual(oldPath, logWriter.currentPath); + // The header should have the continuedSession flag set this time. + ok((await getCurrentHeader()).continuedSession); + + // Ensure a new file is created every kMessageCountLimit messages. + oldPath = logWriter.currentPath; + let messageCountLimit = logWriter.kMessageCountLimit; + for (let i = 0; i < messageCountLimit; ++i) { + logMessage(message); + } + await logMessage(message); + notEqual(oldPath, logWriter.currentPath); + // The header should have the continuedSession flag set this time too. + ok((await getCurrentHeader()).continuedSession); + // Again, to make sure it still works correctly after splitting it once already. + oldPath = logWriter.currentPath; + // We already logged one message to ensure it went into a new file, so i = 1. + for (let i = 1; i < messageCountLimit; ++i) { + logMessage(message); + } + await logMessage(message); + notEqual(oldPath, logWriter.currentPath); + ok((await getCurrentHeader()).continuedSession); + + // The new start time is the time of the message. If we log sufficiently more + // messages with the same time property, ensure that the start time of the next + // log file is greater than the previous one, and that a new path is being used. + let oldStartTime = logWriter._startTime; + oldPath = logWriter.currentPath; + logWriter._messageCount = messageCountLimit; + await logMessage(message); + notEqual(oldPath, logWriter.currentPath); + ok(logWriter._startTime > oldStartTime); + + // Do it again with the same message. + oldStartTime = logWriter._startTime; + oldPath = logWriter.currentPath; + logWriter._messageCount = messageCountLimit; + await logMessage(message); + notEqual(oldPath, logWriter.currentPath); + ok(logWriter._startTime > oldStartTime); + + // Clean up. + await IOUtils.remove(logDirPath, { recursive: true }); + closeLogWriter(dummyConv); +}; + +add_task(async function test_logWithEdits() { + // Start clean, remove the log directory. + await IOUtils.remove(logDirPath, { recursive: true }); + let logger = new Logger(); + let logFilePath = getLogFilePathForConversation(dummyConv); + await IOUtils.writeUTF8( + logFilePath, + [ + { + date: "2022-03-04T12:00:03.508Z", + name: "test", + title: "test", + account: "@test:example.com", + protocol: "matrix", + isChat: false, + normalizedName: "!foobar:example.com", + }, + { + date: "2022-03-04T11:59:48.000Z", + who: "@other:example.com", + text: "Decrypting...", + flags: ["incoming", "delayed", "isEncrypted"], + remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk", + alias: "other", + }, + { + date: "2022-03-04T11:59:51.000Z", + who: "@other:example.com", + text: "Decrypting...", + flags: ["incoming", "delayed", "isEncrypted"], + remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ", + alias: "other", + }, + { + date: "2022-03-04T11:59:53.000Z", + who: "@other:example.com", + text: "Decrypting...", + flags: ["incoming", "delayed", "isEncrypted"], + remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ", + alias: "other", + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "Decrypting...", + flags: ["incoming", "delayed", "isEncrypted"], + remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM", + alias: "other", + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "Lorem ipsum dolor sit amet", + flags: ["incoming", "isEncrypted"], + remoteId: "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM", + alias: "other", + }, + { + date: "2022-03-04T11:59:53.000Z", + who: "@other:example.com", + text: "consectetur adipiscing elit", + flags: ["incoming", "isEncrypted"], + remoteId: "$Z6ILSf7cBMRbr_B6Z6DPHJWzf-Utxa8_s0f6vxhR_VQ", + alias: "other", + }, + { + date: "2022-03-04T11:59:51.000Z", + who: "@other:example.com", + text: "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + flags: ["incoming", "isEncrypted"], + remoteId: "$00zdmKvErkDR4wMaxZBCFsV1WwqPQRolP0kYiXPIXsQ", + alias: "other", + }, + { + date: "2022-03-04T11:59:48.000Z", + who: "@other:example.com", + text: "Ut enim ad minim veniam", + flags: ["incoming", "isEncrypted"], + remoteId: "$AjmS57jkBbYnSnC01r3fXya8BfuHIMAw9mOYQRlnkFk", + alias: "other", + }, + ] + .map(message => JSON.stringify(message)) + .join("\n"), + { + mode: "create", + } + ); + let logs = await logger.getLogsForConversation(dummyConv); + equal(logs.length, 1); + const conv = await logs[0].getConversation(); + const messages = conv.getMessages(); + equal(messages.length, 5); + for (const msg of messages) { + if (msg.who !== "sessionstart") { + notEqual(msg.displayMessage, "Decrypting..."); + } + } + + // Clean up. + await IOUtils.remove(logDirPath, { recursive: true }); +}); + +// Ensure that any message with a remoteId that has a deleted flag in the +// latest version is not visible in logs. +add_task(async function test_logWithDeletedMessages() { + // Start clean, remove the log directory. + await IOUtils.remove(logDirPath, { recursive: true }); + let logger = new Logger(); + let logFilePath = getLogFilePathForConversation(dummyConv); + const remoteId = "$GFlcel-9tWrTvSb7HM_113-WpkzEdB4neglPVpZn3dM"; + await IOUtils.writeUTF8( + logFilePath, + [ + { + date: "2022-03-04T12:00:03.508Z", + name: "test", + title: "test", + account: "@test:example.com", + protocol: "matrix", + isChat: false, + normalizedName: "!foobar:example.com", + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "Decrypting...", + flags: ["incoming", "isEncrypted"], + remoteId, + alias: "other", + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "Message was redacted.", + flags: ["incoming", "isEncrypted", "deleted"], + remoteId, + alias: "other", + }, + ] + .map(message => JSON.stringify(message)) + .join("\n"), + { + mode: "create", + } + ); + let logs = await logger.getLogsForConversation(dummyConv); + equal(logs.length, 1); + const conv = await logs[0].getConversation(); + const messages = conv.getMessages(); + equal(messages.length, 1); + equal(messages[0].who, "sessionstart"); + + // Clean up. + await IOUtils.remove(logDirPath, { recursive: true }); +}); + +add_task(async function test_logDeletedMessageCleanup() { + // Start clean, remove the log directory. + await IOUtils.remove(logDirPath, { recursive: true }); + let logger = new Logger(); + let logWriter = getLogWriter(dummyConv); + let remoteId = "testId"; + + let logMessage = async function (aMessage) { + logWriter.logMessage(aMessage); + await logWriter._initialized; + await gFilePromises.get(logWriter.currentPath); + }; + + await logMessage({ + time: Math.floor(dummyConv.startDate / 1000000) + 10, + who: "test", + displayMessage: "delete me", + remoteId, + incoming: true, + }); + + await logMessage({ + time: Math.floor(dummyConv.startDate / 1000000) + 20, + who: "test", + displayMessage: "Message is deleted", + remoteId, + deleted: true, + incoming: true, + }); + ok(gPendingCleanup.has(logWriter.currentPath)); + equal( + Services.prefs.getStringPref("chat.logging.cleanup.pending"), + JSON.stringify([logWriter.currentPath]) + ); + + await new Promise(resolve => ChromeUtils.idleDispatch(resolve)); + await (gFilePromises.get(logWriter.currentPath) || Promise.resolve()); + + ok(!gPendingCleanup.has(logWriter.currentPath)); + equal(Services.prefs.getStringPref("chat.logging.cleanup.pending"), "[]"); + + let logs = await logger.getLogsForConversation(dummyConv); + equal(logs.length, 1, "Only a single log file for this conversation"); + let conv = await logs[0].getConversation(); + let messages = conv.getMessages(); + equal(messages.length, 1, "Only the log header is left"); + equal(messages[0].who, "sessionstart"); + + // Check that the message contents were removed from the file on disk. The + // log parser above removes it either way. + let logOnDisk = await IOUtils.readUTF8(logWriter.currentPath); + let rawMessages = logOnDisk + .split("\n") + .filter(Boolean) + .map(line => JSON.parse(line)); + equal(rawMessages.length, 3); + equal(rawMessages[1].text, "", "Deleted message content was removed"); + equal( + rawMessages[2].text, + "Message is deleted", + "Deletion content is unaffected" + ); + + // Clean up. + await IOUtils.remove(logDirPath, { recursive: true }); + + closeLogWriter(dummyConv); +}); + +add_task(async function test_displayOldActionLog() { + // Start clean, remove the log directory. + await IOUtils.remove(logDirPath, { recursive: true }); + let logger = new Logger(); + let logFilePath = getLogFilePathForConversation(dummyConv); + await IOUtils.writeUTF8( + logFilePath, + [ + { + date: "2022-03-04T12:00:03.508Z", + name: "test", + title: "test", + account: "@test:example.com", + protocol: "matrix", + isChat: false, + normalizedName: "!foobar:example.com", + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "/me an old action", + flags: ["incoming"], + }, + { + date: "2022-03-04T11:59:56.000Z", + who: "@other:example.com", + text: "a new action", + flags: ["incoming", "action"], + }, + ] + .map(message => JSON.stringify(message)) + .join("\n"), + { + mode: "create", + } + ); + let logs = await logger.getLogsForConversation(dummyConv); + equal(logs.length, 1); + for (let log of logs) { + const conv = await log.getConversation(); + const messages = conv.getMessages(); + equal(messages.length, 3); + for (let message of messages) { + if (message.who !== "sessionstart") { + ok(message.action, "Message is marked as action"); + ok( + !message.displayMessage.startsWith("/me"), + "Message has no leading /me" + ); + } + } + } + + // Clean up. + await IOUtils.remove(logDirPath, { recursive: true }); +}); + +add_task(function test_encodeName() { + // Test encodeName(). + for (let i = 0; i < encodeName_input.length; ++i) { + equal(encodeName(encodeName_input[i]), encodeName_output[i]); + } +}); + +add_task(test_getLogFolderPathForAccount); + +add_task(test_getLogFilePathForConversation); + +add_task(test_getLogFilePathForMUC); + +add_task(test_queueFileOperation); + +add_task(test_appendToFile); + +add_task(test_getLogPathsForConversation); + +add_task(test_logging); + +add_task(test_logFileSplitting); diff --git a/comm/chat/components/src/test/xpcshell.ini b/comm/chat/components/src/test/xpcshell.ini new file mode 100644 index 0000000000..63cce6e7e1 --- /dev/null +++ b/comm/chat/components/src/test/xpcshell.ini @@ -0,0 +1,9 @@ +[DEFAULT] +head = +tail = + +[test_accounts.js] +[test_commands.js] +[test_conversations.js] +[test_init.js] +[test_logger.js] |