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/mail/components/im | |
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/mail/components/im')
222 files changed, 16628 insertions, 0 deletions
diff --git a/comm/mail/components/im/IMIncomingServer.sys.mjs b/comm/mail/components/im/IMIncomingServer.sys.mjs new file mode 100644 index 0000000000..aea800cec7 --- /dev/null +++ b/comm/mail/components/im/IMIncomingServer.sys.mjs @@ -0,0 +1,359 @@ +/* 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"; + +export function IMIncomingServer() {} + +IMIncomingServer.prototype = { + get wrappedJSObject() { + return this; + }, + _imAccount: null, + get imAccount() { + if (this._imAccount) { + return this._imAccount; + } + + let id = this.getCharValue("imAccount"); + if (!id) { + return null; + } + IMServices.core.init(); + return (this._imAccount = IMServices.accounts.getAccountById(id)); + }, + set imAccount(aImAccount) { + this._imAccount = aImAccount; + this.setCharValue("imAccount", aImAccount.id); + }, + _prefBranch: null, + valid: true, + hidden: false, + get offlineSupportLevel() { + return 0; + }, + get supportsDiskSpace() { + return false; + }, + _key: "", + get key() { + return this._key; + }, + set key(aKey) { + this._key = aKey; + this._prefBranch = Services.prefs.getBranch("mail.server." + aKey + "."); + }, + equals(aServer) { + return "wrappedJSObject" in aServer && aServer.wrappedJSObject == this; + }, + + clearAllValues() { + IMServices.accounts.deleteAccount(this.imAccount.id); + for (let prefName of this._prefBranch.getChildList("")) { + this._prefBranch.clearUserPref(prefName); + } + delete this._prefBranch; + delete this._imAccount; + }, + + // Returns the directory where the account would have its data stored. + // There are currently conversation logs only. + // It may not exist yet. + // This is used in account removal dialog and should return the same path + // that the removeFiles() function deletes. + get localPath() { + let logPath = IMServices.logs.getLogFolderPathForAccount(this.imAccount); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(logPath); + return file; + }, + + // Removes files created by this account. + removeFiles() { + IMServices.logs.deleteLogFolderForAccount(this.imAccount); + }, + + // called by nsMsgAccountManager while deleting an account: + forgetSessionPassword() {}, + + forgetPassword() { + // Password is cleared in imAccount.remove() + // TODO: this may need to be implemented here as a separate function + // once IM accounts support changing username/hostname. + }, + + // Shown in the "Remove Account" confirm prompt. + get prettyName() { + let protocol = this.imAccount.protocol.name || this.imAccount.protocol.id; + return protocol + " - " + this.imAccount.name; + }, + + // XXX Flo: I don't think constructedPrettyName is visible in the UI + get constructedPrettyName() { + return "constructedPrettyName FIXME"; + }, + + port: -1, + accountManagerChrome: "am-im.xhtml", + + // FIXME need a new imIIncomingService iface + classinfo for these 3 properties :( + get password() { + return this.imAccount.password; + }, + set password(aPassword) { + this.imAccount.password = aPassword; + }, + get alias() { + return this.imAccount.alias; + }, + set alias(aAlias) { + this.imAccount.alias = aAlias; + }, + get autojoin() { + try { + let prefName = "messenger.account." + this.imAccount.id + ".autoJoin"; + return Services.prefs.getStringPref(prefName); + } catch (e) { + return ""; + } + }, + set autojoin(aAutoJoin) { + let prefName = "messenger.account." + this.imAccount.id + ".autoJoin"; + Services.prefs.setStringPref(prefName, aAutoJoin); + }, + get autologin() { + try { + let prefName = "messenger.account." + this.imAccount.id + ".autoLogin"; + return Services.prefs.getBoolPref(prefName); + } catch (e) { + return false; + } + }, + set autologin(aAutoLogin) { + let prefName = "messenger.account." + this.imAccount.id + ".autoLogin"; + Services.prefs.setBoolPref(prefName, aAutoLogin); + }, + + // This is used for user-visible advanced preferences. + setUnicharValue(aPrefName, aValue) { + if (aPrefName == "autojoin") { + this.autojoin = aValue; + } else if (aPrefName == "alias") { + this.alias = aValue; + } else if (aPrefName == "password") { + this.password = aValue; + } else { + this.imAccount.setString(aPrefName, aValue); + } + }, + getUnicharValue(aPrefName) { + if (aPrefName == "autojoin") { + return this.autojoin; + } + if (aPrefName == "alias") { + return this.alias; + } + if (aPrefName == "password") { + return this.password; + } + + try { + let prefName = + "messenger.account." + this.imAccount.id + ".options." + aPrefName; + return Services.prefs.getStringPref(prefName); + } catch (x) { + return this._getDefault(aPrefName); + } + }, + setBoolValue(aPrefName, aValue) { + if (aPrefName == "autologin") { + this.autologin = aValue; + } + this.imAccount.setBool(aPrefName, aValue); + }, + getBoolValue(aPrefName) { + if (aPrefName == "autologin") { + return this.autologin; + } + try { + let prefName = + "messenger.account." + this.imAccount.id + ".options." + aPrefName; + return Services.prefs.getBoolPref(prefName); + } catch (x) { + return this._getDefault(aPrefName); + } + }, + setIntValue(aPrefName, aValue) { + this.imAccount.setInt(aPrefName, aValue); + }, + getIntValue(aPrefName) { + try { + let prefName = + "messenger.account." + this.imAccount.id + ".options." + aPrefName; + return Services.prefs.getIntPref(prefName); + } catch (x) { + return this._getDefault(aPrefName); + } + }, + _defaultOptionValues: null, + _getDefault(aPrefName) { + if (aPrefName == "otrVerifyNudge") { + return Services.prefs.getBoolPref("chat.otr.default.verifyNudge"); + } + if (aPrefName == "otrRequireEncryption") { + return Services.prefs.getBoolPref("chat.otr.default.requireEncryption"); + } + if (aPrefName == "otrAllowMsgLog") { + return Services.prefs.getBoolPref("chat.otr.default.allowMsgLog"); + } + if (this._defaultOptionValues) { + return this._defaultOptionValues[aPrefName]; + } + + this._defaultOptionValues = {}; + for (let opt of this.imAccount.protocol.getOptions()) { + let type = opt.type; + if (type == Ci.prplIPref.typeBool) { + this._defaultOptionValues[opt.name] = opt.getBool(); + } else if (type == Ci.prplIPref.typeInt) { + this._defaultOptionValues[opt.name] = opt.getInt(); + } else if (type == Ci.prplIPref.typeString) { + this._defaultOptionValues[opt.name] = opt.getString(); + } else if (type == Ci.prplIPref.typeList) { + this._defaultOptionValues[opt.name] = opt.getListDefault(); + } + } + return this._defaultOptionValues[aPrefName]; + }, + + // the "Char" type will be used only for "imAccount" and internally. + setCharValue(aPrefName, aValue) { + this._prefBranch.setCharPref(aPrefName, aValue); + }, + getCharValue(aPrefName) { + try { + return this._prefBranch.getCharPref(aPrefName); + } catch (x) { + return ""; + } + }, + + get type() { + return this._prefBranch.getCharPref("type"); + }, + set type(aType) { + this._prefBranch.setCharPref("type", aType); + }, + + get username() { + return this._prefBranch.getCharPref("userName"); + }, + set username(aUsername) { + if (!aUsername) { + // nsMsgAccountManager::GetIncomingServer expects the pref to + // be named userName but some early test versions with IM had + // the pref named username. + return; + } + this._prefBranch.setCharPref("userName", aUsername); + }, + + get hostName() { + return this._prefBranch.getCharPref("hostname"); + }, + set hostName(aHostName) { + this._prefBranch.setCharPref("hostname", aHostName); + }, + + writeToFolderCache() {}, + closeCachedConnections() {}, + + // Shutdown the server instance so at least disconnect from the server. + shutdown() { + // Ensure this account has not been destroyed already. + if (this.imAccount.prplAccount) { + this.imAccount.disconnect(); + } + }, + + setFilterList() {}, + + get canBeDefaultServer() { + return false; + }, + + // AccountManager.js verifies that spamSettings is non-null before + // using the initialize method, but we can't just use a null value + // because that would crash nsMsgPurgeService::PerformPurge which + // only verifies the nsresult return value of the spamSettings + // getter before accessing the level property. + get spamSettings() { + return { + level: 0, + initialize(aServer) {}, + QueryInterface: ChromeUtils.generateQI(["nsISpamSettings"]), + }; + }, + + // nsMsgDBFolder.cpp crashes in HandleAutoCompactEvent if this doesn't exist: + msgStore: { + supportsCompaction: false, + }, + + get serverURI() { + return "im://" + this.imAccount.protocol.id + "/" + this.imAccount.name; + }, + _rootFolder: null, + get rootMsgFolder() { + return this.rootFolder; + }, + get rootFolder() { + if (this._rootFolder) { + return this._rootFolder; + } + + return (this._rootFolder = { + isServer: true, + server: this, + get URI() { + return this.server.serverURI; + }, + get prettyName() { + return this.server.prettyName; + }, // used in the account manager tree + get name() { + return this.server.prettyName + " name"; + }, // never displayed? + // used in the folder pane tree, if we don't hide the IM accounts: + get abbreviatedName() { + return this.server.prettyName + "abbreviatedName"; + }, + AddFolderListener() {}, + RemoveFolderListener() {}, + descendants: [], + getFlag: () => false, + getFolderWithFlags: aFlags => null, + getFoldersWithFlags: aFlags => [], + get subFolders() { + return []; + }, + getStringProperty: aPropertyName => "", + getNumUnread: aDeep => 0, + Shutdown() {}, + QueryInterface: ChromeUtils.generateQI(["nsIMsgFolder"]), + }); + }, + + get sortOrder() { + return 300000000; + }, + + get protocolInfo() { + return Cc["@mozilla.org/messenger/protocol/info;1?type=im"].getService( + Ci.nsIMsgProtocolInfo + ); + }, + + QueryInterface: ChromeUtils.generateQI(["nsIMsgIncomingServer"]), +}; diff --git a/comm/mail/components/im/IMProtocolInfo.sys.mjs b/comm/mail/components/im/IMProtocolInfo.sys.mjs new file mode 100644 index 0000000000..975a3a4a0a --- /dev/null +++ b/comm/mail/components/im/IMProtocolInfo.sys.mjs @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export function IMProtocolInfo() {} + +IMProtocolInfo.prototype = { + defaultLocalPath: null, + get serverIID() { + return null; + }, + get requiresUsername() { + return true; + }, + get preflightPrettyNameWithEmailAddress() { + return false; + }, + get canDelete() { + return true; + }, + // Even though IM accounts can login at startup, canLoginAtStartUp + // should be false as it's used to decide if new messages should be + // fetched at startup and that concept of message doesn't apply to + // IM accounts. + get canLoginAtStartUp() { + return false; + }, + get canDuplicate() { + return false; + }, + getDefaultServerPort: () => 0, + get canGetMessages() { + return false; + }, + get canGetIncomingMessages() { + return false; + }, + get defaultDoBiff() { + return false; + }, + get showComposeMsgLink() { + return false; + }, + get foldersCreatedAsync() { + return false; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIMsgProtocolInfo"]), +}; diff --git a/comm/mail/components/im/all-im.js b/comm/mail/components/im/all-im.js new file mode 100644 index 0000000000..a2ca249f08 --- /dev/null +++ b/comm/mail/components/im/all-im.js @@ -0,0 +1,14 @@ +/* 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/. */ + +pref("messenger.options.messagesStyle.theme", "mail"); +pref("messenger.options.emoticonsTheme", "messenger-emoticons"); +pref("messenger.options.getAttentionOnNewMessages", false); +pref("messenger.conversations.textbox.autoResize", true); +pref("messenger.conversations.doubleClickToReply", true); +pref("messenger.conversations.showNicks", true); +pref("purple.debug.loglevel", 3); + +// Limit the number of gloda IM results +pref("mailnews.database.global.search.im.limit", 1000); diff --git a/comm/mail/components/im/components.conf b/comm/mail/components/im/components.conf new file mode 100644 index 0000000000..2d379db965 --- /dev/null +++ b/comm/mail/components/im/components.conf @@ -0,0 +1,20 @@ +# -*- 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': '{13118758-dad2-418c-a03d-1acbfed0cd01}', + 'contract_ids': ['@mozilla.org/messenger/protocol/info;1?type=im'], + 'esModule': 'resource:///modules/IMProtocolInfo.sys.mjs', + 'constructor': 'IMProtocolInfo', + }, + { + 'cid': '{9dd7f36b-5960-4f0a-8789-f5f516bd083d}', + 'contract_ids': ['@mozilla.org/messenger/server;1?type=im'], + 'esModule': 'resource:///modules/IMIncomingServer.sys.mjs', + 'constructor': 'IMIncomingServer', + }, +] diff --git a/comm/mail/components/im/content/.eslintrc.js b/comm/mail/components/im/content/.eslintrc.js new file mode 100644 index 0000000000..c862f88e3e --- /dev/null +++ b/comm/mail/components/im/content/.eslintrc.js @@ -0,0 +1,22 @@ +"use strict"; + +module.exports = { + overrides: [ + { + files: ["imconversation.xml"], + globals: { + AppConstants: true, + chatHandler: true, + gChatTab: true, + Services: true, + + // chat/modules/imStatusUtils.jsm + Status: true, + + // chat/modules/imTextboxUtils.jsm + MessageFormat: true, + TextboxSize: true, + }, + }, + ], +}; diff --git a/comm/mail/components/im/content/addbuddy.js b/comm/mail/components/im/content/addbuddy.js new file mode 100644 index 0000000000..f5b3eb7deb --- /dev/null +++ b/comm/mail/components/im/content/addbuddy.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var addBuddy = { + onload() { + let accountList = document.getElementById("accountlist"); + for (let acc of IMServices.accounts.getAccounts()) { + if (!acc.connected) { + continue; + } + let proto = acc.protocol; + let item = accountList.appendItem(acc.name, acc.id, proto.name); + item.setAttribute("image", ChatIcons.getProtocolIconURI(proto)); + item.setAttribute("class", "menuitem-iconic"); + } + if (!accountList.itemCount) { + document + .getElementById("addBuddyDialog") + .querySelector("dialog") + .cancelDialog(); + throw new Error("No connected account!"); + } + accountList.selectedIndex = 0; + }, + + oninput() { + document.querySelector("dialog").getButton("accept").disabled = + !addBuddy.getValue("name"); + }, + + getValue(aId) { + return document.getElementById(aId).value; + }, + + create() { + let account = IMServices.accounts.getAccountById( + this.getValue("accountlist") + ); + let group = Services.strings + .createBundle("chrome://messenger/locale/chat.properties") + .GetStringFromName("defaultGroup"); + account.addBuddy(IMServices.tags.createTag(group), this.getValue("name")); + }, +}; + +document.addEventListener("dialogaccept", addBuddy.create.bind(addBuddy)); + +window.addEventListener("load", event => { + addBuddy.onload(); +}); diff --git a/comm/mail/components/im/content/addbuddy.xhtml b/comm/mail/components/im/content/addbuddy.xhtml new file mode 100644 index 0000000000..5c4fbfbf94 --- /dev/null +++ b/comm/mail/components/im/content/addbuddy.xhtml @@ -0,0 +1,59 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/imMenulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/addbuddy.dtd"> + +<html + id="addBuddyDialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + scrolling="false" +> + <head> + <title>&addBuddyWindow.title;</title> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/addbuddy.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <dialog buttons="accept,cancel" buttondisabledaccept="true"> + <hbox> + <vbox id="nameBox"> + <hbox align="center" flex="1"> + <label value="&name.label;" control="name" /> + </hbox> + <hbox align="center" flex="1"> + <label value="&account.label;" control="accountlist" /> + </hbox> + </vbox> + <vbox id="accountBox"> + <html:input + id="name" + type="text" + class="input-inline" + oninput="addBuddy.oninput()" + /> + <menulist id="accountlist" /> + </vbox> + </hbox> + </dialog> + </html:body> +</html> diff --git a/comm/mail/components/im/content/am-im.js b/comm/mail/components/im/content/am-im.js new file mode 100644 index 0000000000..494e0aa1fd --- /dev/null +++ b/comm/mail/components/im/content/am-im.js @@ -0,0 +1,291 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// chat/content/imAccountOptionsHelper.js +/* globals accountOptionsHelper */ + +const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + OTR: "resource:///modules/OTR.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", +}); + +var autoJoinPref = "autoJoin"; + +function onPreInit(aAccount, aAccountValue) { + account.init(aAccount.incomingServer.wrappedJSObject.imAccount); +} + +function onBeforeUnload() { + if (account.encryptionObserver) { + Services.obs.removeObserver( + account.encryptionObserver, + "account-sessions-changed" + ); + Services.obs.removeObserver( + account.encryptionObserver, + "account-encryption-status-changed" + ); + } +} + +var account = { + async init(aAccount) { + let title = document.querySelector(".dialogheader .dialogheader-title"); + let defaultTitle = title.getAttribute("defaultTitle"); + let titleValue; + + if (aAccount.name) { + titleValue = defaultTitle + " - <" + aAccount.name + ">"; + } else { + titleValue = defaultTitle; + } + + title.setAttribute("value", titleValue); + document.title = titleValue; + + this.account = aAccount; + this.proto = this.account.protocol; + document.getElementById("accountName").value = this.account.name; + document.getElementById("protocolName").value = + this.proto.name || this.proto.id; + document.getElementById("protocolIcon").src = ChatIcons.getProtocolIconURI( + this.proto, + 48 + ); + + let password = document.getElementById("server.password"); + let passwordBox = document.getElementById("passwordBox"); + if (this.proto.noPassword) { + passwordBox.hidden = true; + password.removeAttribute("wsm_persist"); + } else { + passwordBox.hidden = false; + try { + // Should we force layout here to ensure password.value works? + // Will throw if we don't have a protocol plugin for the account. + password.value = this.account.password; + password.setAttribute("wsm_persist", "true"); + } catch (e) { + passwordBox.hidden = true; + password.removeAttribute("wsm_persist"); + } + } + + document.getElementById("server.alias").value = this.account.alias; + + if (ChatEncryption.canConfigureEncryption(this.account.protocol)) { + document.getElementById("imTabEncryption").hidden = false; + document.querySelector(".otr-settings").hidden = !OTRUI.enabled; + document.getElementById("server.otrAllowMsgLog").value = + this.account.otrAllowMsgLog; + if (OTRUI.enabled) { + document.getElementById("server.otrVerifyNudge").value = + this.account.otrVerifyNudge; + document.getElementById("server.otrRequireEncryption").value = + this.account.otrRequireEncryption; + + let fpa = this.account.normalizedName; + let fpp = this.account.protocol.normalizedName; + let fp = OTR.privateKeyFingerprint(fpa, fpp); + if (!fp) { + fp = await document.l10n.formatValue("otr-not-yet-available"); + } + document.getElementById("otrFingerprint").value = fp; + } + document.querySelector(".chat-encryption-settings").hidden = + !this.account.protocol.canEncrypt; + if (this.account.protocol.canEncrypt) { + document.l10n.setAttributes( + document.getElementById("chat-encryption-description"), + "chat-encryption-description", + { + protocol: this.proto.name, + } + ); + this.buildEncryptionStatus(); + this.buildAccountSessionsList(); + this.encryptionObserver = { + observe: (subject, topic) => { + if ( + topic === "account-sessions-changed" && + subject.id === this.account.id + ) { + this.buildAccountSessionsList(); + } else if ( + topic === "account-encryption-status-changed" && + subject.id === this.account.id + ) { + this.buildEncryptionStatus(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + Services.obs.addObserver( + this.encryptionObserver, + "account-sessions-changed", + true + ); + Services.obs.addObserver( + this.encryptionObserver, + "account-encryption-status-changed", + true + ); + } + } + + let protoId = this.proto.id; + let canAutoJoin = + protoId == "prpl-irc" || + protoId == "prpl-jabber" || + protoId == "prpl-gtalk"; + document.getElementById("autojoinBox").hidden = !canAutoJoin; + let autojoin = document.getElementById("server.autojoin"); + if (canAutoJoin) { + autojoin.setAttribute("wsm_persist", "true"); + } else { + autojoin.removeAttribute("wsm_persist"); + } + + this.prefs = Services.prefs.getBranch( + "messenger.account." + this.account.id + ".options." + ); + this.populateProtoSpecificBox(); + }, + + encryptionObserver: null, + buildEncryptionStatus() { + const encryptionStatus = document.querySelector(".chat-encryption-status"); + if (this.account.encryptionStatus.length) { + encryptionStatus.replaceChildren( + ...this.account.encryptionStatus.map(status => { + const item = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + item.textContent = status; + return item; + }) + ); + } else { + const placeholder = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + document.l10n.setAttributes(placeholder, "chat-encryption-placeholder"); + encryptionStatus.replaceChildren(placeholder); + } + }, + buildAccountSessionsList() { + const sessions = this.account.getSessions(); + document.querySelector(".chat-encryption-sessions-container").hidden = + sessions.length === 0; + const sessionList = document.querySelector(".chat-encryption-sessions"); + sessionList.replaceChildren( + ...sessions.map(session => { + const button = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "button" + ); + document.l10n.setAttributes( + button, + "chat-encryption-session-" + (session.trusted ? "trusted" : "verify") + ); + button.disabled = session.trusted; + if (!button.disabled) { + button.addEventListener("click", async () => { + try { + const sessionInfo = await session.verify(); + parent.gSubDialog.open( + "chrome://messenger/content/chat/verify.xhtml", + { features: "resizable=no" }, + sessionInfo + ); + } catch (error) { + // Verification was probably aborted by the other side. + this.account.prplAccount.wrappedJSObject.WARN(error); + } + }); + } + const sessionLabel = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + sessionLabel.textContent = session.id; + const row = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "li" + ); + row.append(sessionLabel, button); + row.classList.toggle("chat-current-session", session.currentSession); + return row; + }) + ); + }, + + populateProtoSpecificBox() { + let attributes = {}; + attributes[Ci.prplIPref.typeBool] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "bool" }, + { name: "genericattr", value: "true" }, + ]; + attributes[Ci.prplIPref.typeInt] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "int" }, + { name: "genericattr", value: "true" }, + ]; + attributes[Ci.prplIPref.typeString] = attributes[Ci.prplIPref.typeList] = [ + { name: "wsm_persist", value: "true" }, + { name: "preftype", value: "wstring" }, + { name: "genericattr", value: "true" }, + ]; + let haveOptions = accountOptionsHelper.addOptions( + "server.", + this.proto.getOptions(), + attributes + ); + let advanced = document.getElementById("advanced"); + if (advanced.hidden && haveOptions) { + advanced.hidden = false; + // Force textbox XBL binding attachment by forcing layout, + // otherwise setFormElementValue from AccountManager.js sets + // properties that don't exist when restoring values. + document.getElementById("protoSpecific").getBoundingClientRect(); + } else if (!haveOptions) { + advanced.hidden = true; + } + let inputElements = document.querySelectorAll( + "#protoSpecific :is(checkbox, input, menulist)" + ); + // Because the elements are added after the document loaded we have to + // notify the parent document that there are prefs to save. + for (let input of inputElements) { + if (input.localName == "input" || input.localName == "textarea") { + input.addEventListener("change", event => { + document.dispatchEvent(new CustomEvent("prefchange")); + }); + } else { + input.addEventListener("command", event => { + document.dispatchEvent(new CustomEvent("prefchange")); + }); + } + } + }, + + viewFingerprintKeys() { + let otrAccount = { account: this.account }; + parent.gSubDialog.open( + "chrome://chat/content/otr-finger.xhtml", + undefined, + otrAccount + ); + }, +}; diff --git a/comm/mail/components/im/content/am-im.xhtml b/comm/mail/components/im/content/am-im.xhtml new file mode 100644 index 0000000000..5455309da8 --- /dev/null +++ b/comm/mail/components/im/content/am-im.xhtml @@ -0,0 +1,235 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/accountManage.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> + +<!DOCTYPE window [ <!ENTITY % imDTD SYSTEM "chrome://messenger/locale/am-im.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%imDTD; %brandDTD; ]> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + id="account" + title="&accountWindow.title;" + buttons="accept,cancel" + onload="parent.onPanelLoaded('am-im.xhtml');" + onbeforeunload="onBeforeUnload();" +> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://chat/content/imAccountOptionsHelper.js" /> + <script src="chrome://messenger/content/am-im.js" /> + + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="messenger/preferences/am-im.ftl" /> + <html:link rel="localization" href="messenger/otr/am-im-otr.ftl" /> + </linkset> + + <vbox flex="1" style="overflow: auto; padding: 0" + ><vbox id="containerBox" flex="1"> + <hbox class="dialogheader"> + <label + class="dialogheader-title" + defaultTitle="&accountWindow.title;" + /> + </hbox> + + <hbox align="center"> + <html:img id="protocolIcon" alt="" /> + <vbox flex="1"> + <label id="accountName" crop="end" class="header" /> + <label id="protocolName" class="tip-caption" /> + </vbox> + </hbox> + + <tabbox id="imTabbox" flex="1"> + <tabs> + <tab id="imTabGeneral" label="&account.general;" /> + <tab + id="imTabEncryption" + data-l10n-id="account-encryption" + hidden="true" + /> + </tabs> + <tabpanels flex="1"> + <tabpanel orient="vertical"> + <label class="header" data-l10n-id="account-settings-title" /> + <hbox id="passwordBox" align="baseline" class="input-container"> + <label + value="&account.password;" + control="server.password" + class="label-inline" + /> + <html:input + id="server.password" + type="password" + preftype="wstring" + genericattr="true" + class="input-inline" + /> + </hbox> + <hbox id="aliasBox" align="baseline" class="input-container"> + <label + value="&account.alias;" + control="server.alias" + class="label-inline" + /> + <html:input + id="server.alias" + type="text" + preftype="wstring" + wsm_persist="true" + genericattr="true" + class="input-inline" + /> + </hbox> + <vbox id="autologinBox"> + <checkbox + id="server.autologin" + data-l10n-id="chat-autologin" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + <separator class="thin" /> + + <vbox id="autojoinBox" hidden="true"> + <label class="header" data-l10n-id="account-channel-title" /> + <hbox class="input-container"> + <label + class="label-inline" + value="&account.autojoin;" + control="server.autojoin" + /> + <html:input + id="server.autojoin" + type="text" + preftype="wstring" + genericattr="true" + class="input-inline" + /> + </hbox> + <separator class="thin" /> + </vbox> + <vbox id="advanced"> + <label class="header">&account.advanced;</label> + <html:div + id="protoSpecific" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + </vbox> + </tabpanel> + + <tabpanel orient="vertical"> + <html:div> + <html:h1 data-l10n-id="chat-encryption-generic" /> + <separator class="thin" /> + + <vbox> + <checkbox + id="server.otrAllowMsgLog" + data-l10n-id="chat-encryption-log" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + </html:div> + <separator /> + <html:div class="chat-encryption-settings"> + <html:h1 data-l10n-id="chat-encryption-label" /> + <description id="chat-encryption-description" /> + + <separator class="thin" /> + + <label class="header" data-l10n-id="chat-encryption-status" /> + <html:div class="indent"> + <html:ul class="chat-encryption-status"> + <html:li data-l10n-id="chat-encryption-placeholder" /> + </html:ul> + </html:div> + + <html:div class="chat-encryption-sessions-container"> + <separator class="thin" /> + <label class="header" data-l10n-id="chat-encryption-sessions" /> + <description + data-l10n-id="chat-encryption-sessions-description" + /> + <html:div class="indent"> + <html:ul class="chat-encryption-sessions"></html:ul> + </html:div> + </html:div> + <separator /> + </html:div> + <html:div class="otr-settings"> + <html:h1 data-l10n-id="account-otr-label" /> + <description data-l10n-id="account-otr-description2" /> + + <separator /> + + <vbox> + <label class="header" data-l10n-id="otr-settings-title" /> + <checkbox + id="server.otrRequireEncryption" + data-l10n-id="otr-require-encryption" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + <html:p + id="otrRequireEncryptionInfo" + class="option-description" + data-l10n-id="otr-require-encryption-info" + ></html:p> + <checkbox + id="server.otrVerifyNudge" + data-l10n-id="otr-verify-nudge" + crop="end" + wsm_persist="true" + preftype="bool" + genericattr="true" + /> + </vbox> + + <separator /> + + <vbox> + <label class="header" data-l10n-id="otr-encryption-title" /> + <label data-l10n-id="otr-encryption-caption" /> + <separator class="thin" /> + <hbox align="center"> + <label data-l10n-id="otr-fingerprint-label" /> + <hbox class="input-container" flex="1"> + <html:input + id="otrFingerprint" + type="text" + class="input-inline" + readonly="readonly" + /> + </hbox> + </hbox> + <separator class="thin" /> + <hbox pack="end"> + <button + id="viewFingerprintButton" + data-l10n-id="view-fingerprint-button" + oncommand="account.viewFingerprintKeys();" + /> + </hbox> + </vbox> + </html:div> + </tabpanel> + </tabpanels> + </tabbox> </vbox + ></vbox> +</window> diff --git a/comm/mail/components/im/content/chat-contact.js b/comm/mail/components/im/content/chat-contact.js new file mode 100644 index 0000000000..d3e9baf974 --- /dev/null +++ b/comm/mail/components/im/content/chat-contact.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozXULElement, MozElements, Status, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" + ); + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + /** + * The MozChatContactRichlistitem widget displays contact information about user under + * chat-groups, online contacts and offline contacts: i.e. icon and username. + * On double clicking the element, it gets moved into the conversations. + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatContactRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + ".box-line": "selected", + ".contactDisplayName": "value=displayname", + ".contactDisplayNameInput": "value=displayname", + ".contactStatusText": "value=statusTextWithDash", + }; + } + + static get markup() { + return ` + <vbox class="box-line"></vbox> + <stack class="prplBuddyIcon"> + <html:img class="protoIcon" alt="" /> + <html:img class="smallStatusIcon" /> + </stack> + <hbox flex="1" class="contact-hbox"> + <stack> + <label crop="end" + class="contactDisplayName blistDisplayName"> + </label> + <html:input type="text" + class="contactDisplayNameInput" + hidden="hidden"/> + </stack> + <label crop="end" + style="flex: 100000 100000;" + class="contactStatusText"> + </label> + <button class="startChatBubble" + tooltiptext="&openConversationButton.tooltip;"> + </button> + </hbox> + `; + } + + static get entities() { + return ["chrome://messenger/locale/chat.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-contact-richlistitem"); + + this.addEventListener("blur", event => { + if (!this.hasAttribute("aliasing")) { + return; + } + + if (Services.focus.activeWindow == document.defaultView) { + this.finishAliasing(true); + } + }); + + this.addEventListener("mousedown", event => { + if ( + !this.hasAttribute("aliasing") && + this.canOpenConversation() && + event.target.classList.contains("startChatBubble") + ) { + this.openConversation(); + event.preventDefault(); + } + }); + + this.addEventListener("click", event => { + if ( + !this.hasAttribute("aliasing") && + this.canOpenConversation() && + event.detail == 2 + ) { + this.openConversation(); + } + }); + + this.parentNode.addEventListener("mousedown", event => { + event.preventDefault(); + }); + + // @implements {nsIObserver} + this.observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe: function (subject, topic, data) { + if ( + topic == "contact-preferred-buddy-changed" || + topic == "contact-display-name-changed" || + topic == "contact-status-changed" + ) { + this.update(); + } + if ( + topic == "contact-availability-changed" || + topic == "contact-display-name-changed" + ) { + this.group.updateContactPosition(subject); + } + }.bind(this), + }; + + this.appendChild(this.constructor.fragment); + + this.initializeAttributeInheritance(); + } + + get displayName() { + return this.contact.displayName; + } + + update() { + this.setAttribute("displayname", this.contact.displayName); + + let statusText = this.contact.statusText; + if (statusText) { + statusText = " - " + statusText; + } + this.setAttribute("statusTextWithDash", statusText); + let statusType = this.contact.statusType; + + let statusIcon = this.querySelector(".smallStatusIcon"); + let statusName = Status.toAttribute(statusType); + statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName)); + statusIcon.setAttribute("alt", Status.toLabel(statusType)); + + if (this.contact.canSendMessage) { + this.setAttribute("cansend", "true"); + } else { + this.removeAttribute("cansend"); + } + + let protoIcon = this.querySelector(".protoIcon"); + protoIcon.setAttribute( + "src", + ChatIcons.getProtocolIconURI(this.contact.preferredBuddy.protocol) + ); + ChatIcons.setProtocolIconOpacity(protoIcon, statusName); + } + + build(contact) { + this.contact = contact; + this.contact.addObserver(this.observer); + this.update(); + } + + destroy() { + this.contact.removeObserver(this.observer); + delete this.contact; + this.remove(); + } + + startAliasing() { + if (this.hasAttribute("aliasing")) { + return; // prevent re-entry. + } + + this.setAttribute("aliasing", "true"); + let input = this.querySelector(".contactDisplayNameInput"); + let label = this.querySelector(".contactDisplayName"); + input.removeAttribute("hidden"); + label.setAttribute("hidden", "true"); + input.focus(); + + this._inputBlurListener = function (event) { + this.finishAliasing(true); + }.bind(this); + input.addEventListener("blur", this._inputBlurListener); + + // Some keys (home/end for example) can make the selected item + // of the richlistbox change without producing a blur event on + // our textbox. Make sure we watch richlistbox selection changes. + this._parentSelectListener = function (event) { + if (event.target == this.parentNode) { + this.finishAliasing(true); + } + }.bind(this); + this.parentNode.addEventListener("select", this._parentSelectListener); + } + + finishAliasing(save) { + // Cache the parentNode because when we change the contact alias, we + // trigger a re-order (and a removeContact call), which sets + // this.parentNode to undefined. + let listbox = this.parentNode; + let input = this.querySelector(".contactDisplayNameInput"); + let label = this.querySelector(".contactDisplayName"); + input.setAttribute("hidden", "hidden"); + label.removeAttribute("hidden"); + if (save) { + this.contact.alias = input.value; + } + this.removeAttribute("aliasing"); + listbox.removeEventListener("select", this._parentSelectListener); + input.removeEventListener("blur", this._inputBlurListener); + delete this._parentSelectListener; + listbox.focus(); + } + + deleteContact() { + this.contact.remove(); + } + + canOpenConversation() { + return this.contact.canSendMessage; + } + + openConversation() { + let prplConv = this.contact.createConversation(); + let uiConv = IMServices.conversations.getUIConversation(prplConv); + chatHandler.focusConversation(uiConv); + } + + keyPress(event) { + switch (event.keyCode) { + // If Enter or Return is pressed, open a new conversation + case event.DOM_VK_RETURN: + if (this.hasAttribute("aliasing")) { + this.finishAliasing(true); + } else if (this.canOpenConversation()) { + this.openConversation(); + } + break; + + case event.DOM_VK_F2: + if (!this.hasAttribute("aliasing")) { + this.startAliasing(); + } + break; + + case event.DOM_VK_ESCAPE: + if (this.hasAttribute("aliasing")) { + this.finishAliasing(false); + } + break; + } + } + disconnectedCallback() { + if (this.contact) { + this.contact.removeObserver(this.observer); + delete this.contact; + } + } + } + + MozXULElement.implementCustomInterface(MozChatContactRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define( + "chat-contact-richlistitem", + MozChatContactRichlistitem, + { + extends: "richlistitem", + } + ); +} diff --git a/comm/mail/components/im/content/chat-conversation-info.js b/comm/mail/components/im/content/chat-conversation-info.js new file mode 100644 index 0000000000..a8004a4c3f --- /dev/null +++ b/comm/mail/components/im/content/chat-conversation-info.js @@ -0,0 +1,353 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozElements MozXULElement chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + ChromeUtils.defineESModuleGetters(this, { + OTR: "resource:///modules/OTR.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", + }); + + /** + * The MozChatConversationInfo widget displays information about a chat: + * e.g. the channel name and topic of an IRC channel, or nick, user image and + * status of a conversation partner. + * It is typically shown at the top right of the chat UI. + * + * @augments {MozXULElement} + */ + class MozChatConversationInfo extends MozXULElement { + static get inheritedAttributes() { + return { ".displayName": "value=displayName" }; + } + + static get markup() { + return ` + <linkset> + <html:link rel="localization" href="messenger/otr/chat.ftl"/> + </linkset> + + <html:div class="displayUserAccount"> + <stack> + <html:img class="userIcon" alt="" /> + <html:img class="statusTypeIcon" alt="" /> + </stack> + <html:div class="nameAndStatusGrid"> + <description class="displayName" crop="end"></description> + <html:img class="protoIcon" alt="" /> + <html:hr /> + <description class="statusMessage" crop="end"></description> + <!-- FIXME: A keyboard user cannot focus the hidden input, nor + - click the above description box in order to reveal it. --> + <html:input class="statusMessageInput input-inline" + hidden="hidden"/> + </html:div> + </html:div> + <hbox class="encryption-container themeable-brighttext" + align="center" + hidden="true"> + <label class="encryption-label" + crop="end" + data-l10n-id="state-label" + flex="1"/> + <toolbarbutton id="chatEncryptionButton" + mode="dialog" + class="encryption-button" + type="menu" + wantdropmarker="true" + label="Insecure" + data-l10n-id="start-tooltip"> + <menupopup class="encryption-menu-popup"> + <menuitem class="otr-start" data-l10n-id="start-label" + oncommand='this.closest("chat-conversation-info").onOtrStartClicked();'/> + <menuitem class="otr-end" data-l10n-id="end-label" + oncommand='this.closest("chat-conversation-info").onOtrEndClicked();'/> + <menuitem class="otr-auth" data-l10n-id="auth-label" + oncommand='this.closest("chat-conversation-info").onOtrAuthClicked();'/> + <menuitem class="protocol-encrypt" data-l10n-id="start-label"/> + </menupopup> + </toolbarbutton> + </hbox> + `; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + this.setAttribute("orient", "vertical"); + + this.appendChild(this.constructor.fragment); + + this.topicEditable = false; + this.editingTopic = false; + this.noTopic = false; + + this.topic.addEventListener("click", this.startEditTopic.bind(this)); + + this.querySelector(".protocol-encrypt").addEventListener("click", () => + this.initializeEncryption() + ); + + let encryptionButton = this.querySelector(".encryption-button"); + encryptionButton.addEventListener( + "command", + this.encryptionButtonClicked + ); + if (Services.prefs.getBoolPref("chat.otr.enable")) { + OTRUI.setNotificationBox(chatHandler.msgNotificationBar); + } + this.initializeAttributeInheritance(); + } + + get topic() { + return this.querySelector(".statusMessage"); + } + + get topicInput() { + return this.querySelector(".statusMessageInput"); + } + + finishEditTopic(save) { + if (!this.editingTopic) { + return; + } + + let panel = this.getSelectedPanel(); + let topic = this.topic; + let topicInput = this.topicInput; + topic.removeAttribute("hidden"); + topicInput.hidden = true; + if (save) { + // apply the new topic only if it is different from the current one + if (topicInput.value != topicInput.getAttribute("value")) { + panel._conv.topic = topicInput.value; + } + } + this.editingTopic = false; + + topicInput.removeEventListener("keypress", this._topicKeyPress, true); + delete this._topicKeyPress; + topicInput.removeEventListener("blur", this._topicBlur); + delete this._topicBlur; + + // After hiding the input, the focus is on an element that can't receive + // keyboard events, so move it to somewhere else. + // FIXME: jumping focus should be removed once editing the topic input + // becomes accessible to keyboard users. + panel.editor.focus(); + } + + topicKeyPress(event) { + switch (event.keyCode) { + case event.DOM_VK_RETURN: + this.finishEditTopic(true); + break; + + case event.DOM_VK_ESCAPE: + this.finishEditTopic(false); + event.stopPropagation(); + event.preventDefault(); + break; + } + } + + topicBlur(event) { + if (event.target == this.topicInput) { + this.finishEditTopic(true); + } + } + + startEditTopic() { + let topic = this.topic; + let topicInput = this.topicInput; + if (!this.topicEditable || this.editingTopic) { + return; + } + + this.editingTopic = true; + + topicInput.hidden = false; + topic.setAttribute("hidden", "true"); + this._topicKeyPress = this.topicKeyPress.bind(this); + topicInput.addEventListener("keypress", this._topicKeyPress); + this._topicBlur = this.topicBlur.bind(this); + topicInput.addEventListener("blur", this._topicBlur); + topicInput.getBoundingClientRect(); + if (this.noTopic) { + topicInput.value = ""; + } else { + topicInput.value = topic.value; + } + topicInput.select(); + } + + encryptionButtonClicked(aEvent) { + aEvent.preventDefault(); + let encryptionMenu = this.querySelector(".encryption-menu-popup"); + encryptionMenu.openPopup(encryptionMenu.parentNode, "after_start"); + } + + onOtrStartClicked() { + // check if start-menu-command is disabled, if yes exit + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + let context = OTR.getContext(conv); + let bundleId = + "alert-" + + (context.msgstate === OTR.getMessageState().OTRL_MSGSTATE_ENCRYPTED + ? "refresh" + : "start"); + OTRUI.sendSystemAlert(uiConv, conv, bundleId); + OTR.sendQueryMsg(conv); + } + + onOtrEndClicked() { + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + OTR.disconnect(conv, false); + let bundleId = "alert-gone-insecure"; + OTRUI.sendSystemAlert(uiConv, conv, bundleId); + } + + onOtrAuthClicked() { + let convBinding = this.getSelectedPanel(); + let uiConv = convBinding._conv; + let conv = uiConv.target; + OTRUI.openAuth(window, conv.normalizedName, "start", uiConv); + } + + initializeEncryption() { + const convBinding = this.getSelectedPanel(); + const uiConv = convBinding._conv; + uiConv.initializeEncryption(); + } + + getSelectedPanel() { + for (let element of document.getElementById("conversationsBox") + .children) { + if (!element.hidden) { + return element; + } + } + return null; + } + + /** + * Sets the shown protocol icon. + * + * @param {prplIProtocol} protocol - The protocol to show. + */ + setProtocol(protocol) { + this.querySelector(".protoIcon").setAttribute( + "src", + ChatIcons.getProtocolIconURI(protocol) + ); + } + + /** + * Sets the shown user icon. + * + * @param {string|null} iconURI - The image uri to show, or "" to use the + * fallback, or null to hide the icon. + * @param {boolean} useFallback - True if the "fallback" icon should be shown + * if iconUri isn't provided. + */ + setUserIcon(iconURI, useFallback) { + ChatIcons.setUserIconSrc( + this.querySelector(".userIcon"), + iconURI, + useFallback + ); + } + + /** + * Sets the shown status icon. + * + * @param {string} statusName - The name of the status. + */ + setStatusIcon(statusName) { + let statusIcon = this.querySelector(".statusTypeIcon"); + if (statusName === null) { + statusIcon.hidden = true; + statusIcon.removeAttribute("src"); + } else { + statusIcon.hidden = false; + let src = ChatIcons.getStatusIconURI(statusName); + if (src) { + statusIcon.setAttribute("src", src); + } else { + /* Unexpected missing icon. */ + statusIcon.removeAttribute("src"); + } + } + } + + /** + * Sets the text for the status of a user, or the topic of a chat. + * + * @param {string} text - The text to display. + * @param {boolean} [noTopic=false] - Whether to stylize the status to + * indicate the status is some fallback text. + */ + setStatusText(text, noTopic = false) { + let statusEl = this.topic; + + statusEl.setAttribute("value", text); + statusEl.setAttribute("tooltiptext", text); + statusEl.toggleAttribute("noTopic", noTopic); + } + + /** + * Sets the element to display a user status. The user icon needs to be set + * separately with setUserIcon. + * + * @param {string} statusName - The internal name for the status. + * @param {string} statusText - The text to display as the status. + */ + setStatus(statusName, statusText) { + this.setStatusIcon(statusName); + this.setStatusText(statusText); + this.topicEditable = false; + } + + /** + * Sets the element to display a chat status. + * + * @param {string} topicText - The topic text for the chat, or some fallback + * text used if the chat has no topic. + * @param {boolean} noTopic - Whether the chat has no topic. + * @param {boolean} topicEditable - Whether the topic can be set by the + * user. + */ + setAsChat(topicText, noTopic, topicEditable) { + this.noTopic = noTopic; + this.topicEditable = topicEditable; + this.setStatusText(topicText, noTopic); + this.setStatusIcon("chat"); + } + + /** + * Empty the element's display. + */ + clear() { + this.querySelector(".protoIcon").removeAttribute("src"); + this.setStatusText(""); + this.setStatusIcon(null); + this.setUserIcon("", false); + this.topicEditable = false; + } + } + customElements.define("chat-conversation-info", MozChatConversationInfo); +} diff --git a/comm/mail/components/im/content/chat-conversation.js b/comm/mail/components/im/content/chat-conversation.js new file mode 100644 index 0000000000..9d0068ac6f --- /dev/null +++ b/comm/mail/components/im/content/chat-conversation.js @@ -0,0 +1,1760 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozElements, MozXULElement, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" + ); + const { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" + ); + const { TextboxSize } = ChromeUtils.importESModule( + "resource:///modules/imTextboxUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { InlineSpellChecker } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" + ); + + /** + * The MozChatConversation widget displays the entire chat conversation + * including status notifications + * + * @augments {MozXULElement} + */ + class MozChatConversation extends MozXULElement { + static get inheritedAttributes() { + return { + browser: "autoscrollpopup", + }; + } + + constructor() { + super(); + + ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + }); + + this.observer = { + // @see {nsIObserver} + observe: (subject, topic, data) => { + if (topic == "conversation-loaded") { + if (subject != this.convBrowser) { + return; + } + + this.convBrowser.progressBar = this.progressBar; + + // Display all queued messages. Use a timeout so that message text + // modifiers can be added with observers for this notification. + if (!this.loaded) { + setTimeout(this._showFirstMessages.bind(this), 0); + } + + Services.obs.removeObserver(this.observer, "conversation-loaded"); + + // Report the active chat message theme via telemetry. This is not + // inside the conv browser itself, since the browser is also used + // for the theme preview in the settings. + Services.telemetry.scalarSet( + "tb.chat.active_message_theme", + `${this.convBrowser.theme.name}:${this.convBrowser.theme.variant}` + ); + + return; + } + + switch (topic) { + case "new-text": + if (this.loaded && this.addMsg(subject)) { + // This will mark the conv as read, but also update the conv title + // with the new unread count etc. + this.tab.update(); + } + break; + + case "update-text": + if (this.loaded) { + this.updateMsg(subject); + } + break; + + case "remove-text": + if (this.loaded) { + this.removeMsg(data); + } + break; + + case "status-text-changed": + this._statusText = data || ""; + this.displayStatusText(); + break; + + case "replying-to-prompt": + this.addPrompt(data); + break; + + case "target-prpl-conversation-changed": + case "update-conv-title": + if (this.tab && this.conv) { + this.tab.setAttribute("label", this.conv.title); + } + break; + + // Update the status too. + case "update-buddy-status": + case "update-buddy-icon": + case "update-conv-icon": + case "update-conv-chatleft": + if (this.tab && this._isConversationSelected) { + this.updateConvStatus(); + } + break; + + case "update-typing": + if (this.tab && this._isConversationSelected) { + this._currentTypingName = data; + this.updateConvStatus(); + } + break; + + case "chat-buddy-add": + if (!this._isConversationSelected) { + break; + } + for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) { + this.insertBuddy(this.createBuddy(nick)); + } + this.updateParticipantCount(); + break; + + case "chat-buddy-remove": + if (!this._isConversationSelected) { + for (let nick of subject.QueryInterface( + Ci.nsISimpleEnumerator + )) { + let name = nick.toString(); + if (this._isBuddyActive(name)) { + delete this._activeBuddies[name]; + } + } + break; + } + for (let nick of subject.QueryInterface(Ci.nsISimpleEnumerator)) { + this.removeBuddy(nick.toString()); + } + this.updateParticipantCount(); + break; + + case "chat-buddy-update": + this.updateBuddy(subject, data); + break; + + case "chat-update-topic": + if (this._isConversationSelected) { + this.updateTopic(); + } + break; + case "update-conv-encryption": + if (this._isConversationSelected) { + this.ChatEncryption.updateEncryptionButton(document, this.conv); + } + break; + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + + this.loaded = false; + this._readCount = 0; + this._statusText = ""; + this._pendingValueChangedCall = false; + this._nickEscape = /[[\]{}()*+?.\\^$|]/g; + this._currentTypingName = ""; + + // This value represents the difference between the deck's height and the + // textbox's content height (borders, margins, paddings). + // Differ according to the Operating System native theme. + this._TEXTBOX_VERTICAL_OVERHEAD = 0; + + // Ratio textbox height / conversation height. + // 0.1 means that the textbox's height is 10% of the conversation's height. + this._TEXTBOX_RATIO = 0.1; + + this.setAttribute("orient", "vertical"); + this.setAttribute("flex", "1"); + this.classList.add("convBox"); + + this.convTop = document.createXULElement("vbox"); + this.convTop.setAttribute("flex", "1"); + this.convTop.classList.add("conv-top"); + + this.notification = document.createXULElement("vbox"); + + this.convBrowser = document.createXULElement("browser", { + is: "conversation-browser", + }); + this.convBrowser.setAttribute("flex", "1"); + this.convBrowser.setAttribute("type", "content"); + this.convBrowser.setAttribute("messagemanagergroup", "browsers"); + + this.progressBar = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "progress" + ); + this.progressBar.setAttribute("hidden", "hidden"); + + this.findbar = document.createXULElement("findbar"); + this.findbar.setAttribute("reversed", "true"); + + this.convTop.appendChild(this.notification); + this.convTop.appendChild(this.convBrowser); + this.convTop.appendChild(this.progressBar); + this.convTop.appendChild(this.findbar); + + this.splitter = document.createXULElement("splitter"); + this.splitter.setAttribute("orient", "vertical"); + this.splitter.classList.add("splitter"); + + this.convStatusContainer = document.createXULElement("hbox"); + this.convStatusContainer.setAttribute("hidden", "true"); + this.convStatusContainer.classList.add("conv-status-container"); + + this.convStatus = document.createXULElement("description"); + this.convStatus.classList.add("plain"); + this.convStatus.classList.add("conv-status"); + this.convStatus.setAttribute("crop", "end"); + + this.convStatusContainer.appendChild(this.convStatus); + + this.convBottom = document.createXULElement("stack"); + this.convBottom.classList.add("conv-bottom"); + + this.inputBox = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "textarea" + ); + this.inputBox.classList.add("conv-textbox"); + + this.charCounter = document.createXULElement("description"); + this.charCounter.classList.add("conv-counter"); + this.convBottom.appendChild(this.inputBox); + this.convBottom.appendChild(this.charCounter); + + this.appendChild(this.convTop); + this.appendChild(this.splitter); + this.appendChild(this.convStatusContainer); + this.appendChild(this.convBottom); + + this.inputBox.addEventListener("keypress", this.inputKeyPress.bind(this)); + this.inputBox.addEventListener( + "input", + this.inputValueChanged.bind(this) + ); + this.inputBox.addEventListener( + "overflow", + this.inputExpand.bind(this), + true + ); + this.inputBox.addEventListener( + "underflow", + this._onTextboxUnderflow, + true + ); + + new MutationObserver( + function (aMutations) { + for (let mutation of aMutations) { + if (mutation.oldValue == "dragging") { + this._onSplitterChange(); + break; + } + } + }.bind(this) + ).observe(this.splitter, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["state"], + }); + + this.convBrowser.addEventListener( + "keypress", + this.browserKeyPress.bind(this) + ); + this.convBrowser.addEventListener( + "dblclick", + this.browserDblClick.bind(this) + ); + Services.obs.addObserver(this.observer, "conversation-loaded"); + + // @implements {nsIObserver} + this.prefObserver = (subject, topic, data) => { + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + this.inputBox.setAttribute("spellcheck", "true"); + this.spellchecker.enabled = true; + } else { + this.inputBox.removeAttribute("spellcheck"); + this.spellchecker.enabled = false; + } + }; + Services.prefs.addObserver("mail.spellcheck.inline", this.prefObserver); + + this.initializeAttributeInheritance(); + } + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + this.notification.prepend(element); + }); + } + return this._notificationBox; + } + + destroy() { + if (this._conv) { + this._forgetConv(); + } + + Services.prefs.removeObserver( + "mail.spellcheck.inline", + this.prefObserver + ); + } + + _forgetConv(shouldClose) { + this._conv.removeObserver(this.observer); + delete this._conv; + this.convBrowser.destroy(); + this.findbar.destroy(); + } + + close() { + this._forgetConv(true); + } + + _showFirstMessages() { + this.loaded = true; + let messages = this._conv.getMessages(); + this._readCount = messages.length - this._conv.unreadMessageCount; + if (this._readCount) { + this._writingContextMessages = true; + } + messages.forEach(this.addMsg.bind(this)); + delete this._writingContextMessages; + + if (this.tab && this.tab.selected && document.hasFocus()) { + // This will mark the conv as read, but also update the conv title + // with the new unread count etc. + this.tab.update(); + } + } + + displayStatusText() { + this.convStatus.value = this._statusText; + if (this._statusText) { + this.convStatusContainer.removeAttribute("hidden"); + } else { + this.convStatusContainer.setAttribute("hidden", "true"); + } + } + + addMsg(aMsg) { + if (!this.loaded) { + throw new Error("Calling addMsg before the browser is ready?"); + } + + var conv = aMsg.conversation; + if (!conv) { + // The conversation has already been destroyed, + // probably because the window was closed. + // Return without doing anything. + return false; + } + + // Ugly hack... :( + if (!aMsg.system && conv.isChat) { + let name = aMsg.who; + let color; + if (this.buddies.has(name)) { + let buddy = this.buddies.get(name); + color = buddy.color; + buddy.removeAttribute("inactive"); + this._activeBuddies[name] = true; + } else { + // Buddy no longer in the room + color = this._computeColor(name); + } + aMsg.color = "color: hsl(" + color + ", 100%, 40%);"; + } + + // Porting note: In TB, this.tab points at the imconv richlistitem element. + let read = this._readCount > 0; + let isUnreadMessage = !read && aMsg.incoming && !aMsg.system; + let isTabFocused = this.tab && this.tab.selected && document.hasFocus(); + let shouldSetUnreadFlag = this.tab && isUnreadMessage && !isTabFocused; + let firstUnread = + this.tab && + !this.tab.hasAttribute("unread") && + isUnreadMessage && + this._isAfterFirstRealMessage && + (!isTabFocused || this._writingContextMessages); + + // Since the unread flag won't be set if the tab is focused, + // we need the following when showing the first messages to stop + // firstUnread being set for subsequent messages. + if (firstUnread) { + delete this._writingContextMessages; + } + + this.convBrowser.appendMessage(aMsg, read, firstUnread); + if (!aMsg.system) { + this._isAfterFirstRealMessage = true; + } + + if (read) { + --this._readCount; + if (!this._readCount && !this._isAfterFirstRealMessage) { + // If all the context messages were system messages, we don't want + // an unread ruler after the context messages, so we forget that + // we had context messages. + delete this._writingContextMessages; + } + return false; + } + + if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) { + this._lastPing = aMsg.who; + this._lastPingTime = aMsg.time; + } + + if (shouldSetUnreadFlag) { + if (conv.isChat && aMsg.containsNick) { + this.tab.setAttribute("attention", "true"); + } + this.tab.setAttribute("unread", "true"); + } + + return isTabFocused; + } + + /** + * Updates an existing message with the matching remote ID. + * + * @param {imIMessage} aMsg - Message to update. + */ + updateMsg(aMsg) { + if (!this.loaded) { + throw new Error("Calling updateMsg before the browser is ready?"); + } + + var conv = aMsg.conversation; + if (!conv) { + // The conversation has already been destroyed, + // probably because the window was closed. + // Return without doing anything. + return; + } + + // Update buddy color. + // Ugly hack... :( + if (!aMsg.system && conv.isChat) { + let name = aMsg.who; + let color; + if (this.buddies.has(name)) { + let buddy = this.buddies.get(name); + color = buddy.color; + buddy.removeAttribute("inactive"); + this._activeBuddies[name] = true; + } else { + // Buddy no longer in the room + color = this._computeColor(name); + } + aMsg.color = "color: hsl(" + color + ", 100%, 40%);"; + } + + this.convBrowser.replaceMessage(aMsg); + } + + /** + * Removes an existing message with matching remote ID. + * + * @param {string} remoteId - Remote ID of the message to remove. + */ + removeMsg(remoteId) { + if (!this.loaded) { + throw new Error("Calling removeMsg before the browser is ready?"); + } + + this.convBrowser.removeMessage(remoteId); + } + + sendMsg(aMsg) { + if (!aMsg) { + return; + } + + let account = this._conv.account; + + if (aMsg.startsWith("/")) { + let convToFocus = {}; + + // The /say command is used to bypass command processing + // (/say can be shortened to just /). + // "/say" or "/say " should be ignored, as should "/" and "/ ". + if (aMsg.match(/^\/(?:say)? ?$/)) { + this.resetInput(); + return; + } + + if (aMsg.match(/^\/(?:say)? .*/)) { + aMsg = aMsg.slice(aMsg.indexOf(" ") + 1); + } else if ( + IMServices.cmd.executeCommand(aMsg, this._conv.target, convToFocus) + ) { + this._conv.sendTyping(""); + this.resetInput(); + if (convToFocus.value) { + chatHandler.focusConversation(convToFocus.value); + } + return; + } + + if (account.protocol.slashCommandsNative && account.connected) { + let cmd = aMsg.match(/^\/[^ ]+/); + if (cmd && cmd != "/me") { + this._conv.systemMessage( + this.bundle.formatStringFromName("unknownCommand", [cmd], 1), + true + ); + return; + } + } + } + + this._conv.sendMsg(aMsg, false, false); + + // reset the textbox to its original size + this.resetInput(); + } + + _onSplitterChange() { + // set the default height as the deck height (modified by the splitter) + this.inputBox.defaultHeight = + parseInt(this.inputBox.parentNode.getBoundingClientRect().height) - + this._TEXTBOX_VERTICAL_OVERHEAD; + } + + calculateTextboxDefaultHeight() { + let totalSpace = parseInt( + window.getComputedStyle(this).getPropertyValue("height") + ); + let textboxStyle = window.getComputedStyle(this.inputBox); + let lineHeight = textboxStyle.lineHeight; + if (lineHeight == "normal") { + lineHeight = parseFloat(textboxStyle.fontSize) * 1.2; + } else { + lineHeight = parseFloat(lineHeight); + } + + // Compute the overhead size. + let textboxHeight = this.inputBox.clientHeight; + let deckHeight = this.inputBox.parentNode.getBoundingClientRect().height; + this._TEXTBOX_VERTICAL_OVERHEAD = deckHeight - textboxHeight; + + // Calculate the number of lines to display. + let numberOfLines = Math.round( + (totalSpace * this._TEXTBOX_RATIO) / lineHeight + ); + if (numberOfLines <= 0) { + numberOfLines = 1; + } + if (!this._maxEmptyLines) { + this._maxEmptyLines = Services.prefs.getIntPref( + "messenger.conversations.textbox.defaultMaxLines" + ); + } + + if (numberOfLines > this._maxEmptyLines) { + numberOfLines = this._maxEmptyLines; + } + this.inputBox.defaultHeight = numberOfLines * lineHeight; + + // set minimum height (in case the user moves the splitter) + this.inputBox.parentNode.style.minHeight = + lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + } + + initTextboxFormat() { + // Init the textbox size + this.calculateTextboxDefaultHeight(); + this.inputBox.parentNode.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + this.inputBox.style.overflowY = "hidden"; + + this.spellchecker = new InlineSpellChecker(this.inputBox); + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + this.inputBox.setAttribute("spellcheck", "true"); + this.spellchecker.enabled = true; + } else { + this.inputBox.removeAttribute("spellcheck"); + this.spellchecker.enabled = false; + } + } + + // eslint-disable-next-line complexity + inputKeyPress(event) { + let text = this.inputBox.value; + + const navKeyCodes = [ + KeyEvent.DOM_VK_PAGE_UP, + KeyEvent.DOM_VK_PAGE_DOWN, + KeyEvent.DOM_VK_HOME, + KeyEvent.DOM_VK_END, + KeyEvent.DOM_VK_UP, + KeyEvent.DOM_VK_DOWN, + ]; + + // Pass navigation keys to the browser if + // 1) the textbox is empty or 2) it's an IB-specific key combination + if ( + (!text && navKeyCodes.includes(event.keyCode)) || + ((event.shiftKey || event.altKey) && + (event.keyCode == KeyEvent.DOM_VK_PAGE_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN)) + ) { + let newEvent = new KeyboardEvent("keypress", event); + event.preventDefault(); + event.stopPropagation(); + // Keyboard events must be sent to the focused element for bubbling to work. + this.convBrowser.focus(); + this.convBrowser.dispatchEvent(newEvent); + this.inputBox.focus(); + return; + } + + // When attempting to copy an empty selection, copy the + // browser selection instead (see bug 693). + // The 'C' won't be lowercase if caps lock is enabled. + if ( + (event.charCode == 99 /* 'c' */ || + (event.charCode == 67 /* 'C' */ && !event.shiftKey)) && + (navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey) && + this.inputBox.selectionStart == this.inputBox.selectionEnd + ) { + this.convBrowser.doCommand(); + return; + } + + // We don't want to enable tab completion if the user has selected + // some text, as it's not clear what the user would expect + // to happen in that case. + let noSelection = !( + this.inputBox.selectionEnd - this.inputBox.selectionStart + ); + + // Undo tab complete. + if ( + noSelection && + this._completions && + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + if (text == this._beforeTabComplete) { + // Nothing to undo, so let backspace act normally. + delete this._completions; + } else { + event.preventDefault(); + + // First undo the comma separating multiple nicks or the suffix. + // More than one nick: + // "nick1, nick2: " -> "nick1: nick2" + // Single nick: remove the suffix + // "nick1: " -> "nick1" + let pos = this.inputBox.selectionStart; + const suffix = ": "; + if ( + pos > suffix.length && + text.substring(pos - suffix.length, pos) == suffix + ) { + let completions = Array.from(this.buddies.keys()); + // Check if the preceding words are a sequence of nick completions. + let preceding = text.substring(0, pos - suffix.length).split(", "); + if (preceding.every(n => completions.includes(n))) { + let s = preceding.pop(); + if (preceding.length) { + s = suffix + s; + } + this.inputBox.selectionStart -= s.length + suffix.length; + this.addString(s); + if (this._completions[0].slice(-suffix.length) == suffix) { + this._completions = this._completions.map(c => + c.slice(0, -suffix.length) + ); + } + if ( + this._completions.length == 1 && + this.inputBox.value == this._beforeTabComplete + ) { + // Nothing left to undo or to cycle through. + delete this._completions; + } + return; + } + } + + // Full undo. + this.inputBox.selectionStart = 0; + this.addString(this._beforeTabComplete); + delete this._completions; + return; + } + } + + // Tab complete. + // Keep the default behavior of the tab key if the input box + // is empty or a modifier is used. + if ( + event.keyCode == KeyEvent.DOM_VK_TAB && + text.length != 0 && + noSelection && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (!event.shiftKey || this._completions) + ) { + event.preventDefault(); + + if (this._completions) { + // Tab has been pressed more than once. + if (this._completions.length == 1) { + return; + } + if (this._shouldListCompletionsLater) { + this._conv.systemMessage(this._shouldListCompletionsLater); + delete this._shouldListCompletionsLater; + } + + this.inputBox.selectionStart = this._completionsStart; + if (event.shiftKey) { + // Reverse cycle completions. + this._completionsIndex -= 2; + if (this._completionsIndex < 0) { + this._completionsIndex += this._completions.length; + } + } + this.addString(this._completions[this._completionsIndex++]); + this._completionsIndex %= this._completions.length; + return; + } + + let completions = []; + let firstWordSuffix = " "; + let secondNick = false; + + // Second regex result will contain word without leading special characters. + this._beforeTabComplete = text.substring( + 0, + this.inputBox.selectionStart + ); + let words = this._beforeTabComplete.match(/\S*?([\w-]+)?$/); + let word = words[0]; + if (!word) { + return; + } + let isFirstWord = this.inputBox.selectionStart == word.length; + + // Check if we are completing a command. + let completingCommand = isFirstWord && word[0] == "/"; + if (completingCommand) { + for (let cmd of IMServices.cmd.listCommandsForConversation( + this._conv + )) { + // It's possible to have a global and a protocol specific command + // with the same name. Avoid duplicates in the |completions| array. + let name = "/" + cmd.name; + if (!completions.includes(name)) { + completions.push(name); + } + } + } else { + // If it's not a command, the only thing we can complete is a nick. + if (!this._conv.isChat) { + return; + } + + firstWordSuffix = ": "; + completions = Array.from(this.buddies.keys()); + + let outgoingNick = this._conv.nick; + completions = completions.filter(c => c != outgoingNick); + + // Check if the preceding words are a sequence of nick completions. + let wordStart = this.inputBox.selectionStart - word.length; + if (wordStart > 2) { + let separator = text.substring(wordStart - 2, wordStart); + if (separator == ": " || separator == ", ") { + let preceding = text.substring(0, wordStart - 2).split(", "); + if (preceding.every(n => completions.includes(n))) { + secondNick = true; + isFirstWord = true; + // Remove preceding completions from possible completions. + completions = completions.filter(c => !preceding.includes(c)); + } + } + } + } + + // Keep only the completions that share |word| as a prefix. + // Be case insensitive only if |word| is entirely lower case. + let condition; + if (word.toLocaleLowerCase() == word) { + condition = c => c.toLocaleLowerCase().startsWith(word); + } else { + condition = c => c.startsWith(word); + } + let matchingCompletions = completions.filter(condition); + if (!matchingCompletions.length && words[1]) { + word = words[1]; + firstWordSuffix = " "; + matchingCompletions = completions.filter(condition); + } + if (!matchingCompletions.length) { + return; + } + + // If the cursor is in the middle of a word, and the word is a nick, + // there is no need to complete - just jump to the end of the nick. + let wholeWord = text.substring( + this.inputBox.selectionStart - word.length + ); + for (let completion of matchingCompletions) { + if (wholeWord.lastIndexOf(completion, 0) == 0) { + let moveCursor = completion.length - word.length; + this.inputBox.selectionStart += moveCursor; + let separator = text.substring( + this.inputBox.selectionStart, + this.inputBox.selectionStart + 2 + ); + if (separator == ": " || separator == ", ") { + this.inputBox.selectionStart += 2; + } else if (!moveCursor) { + // If we're already at the end of a nick, carry on to display + // a list of possible alternatives and/or apply punctuation. + break; + } + return; + } + } + + // We have possible completions! + this._completions = matchingCompletions.sort(); + this._completionsIndex = 0; + // Save now the first and last completions in alphabetical order, + // as we will need them to find a common prefix. However they may + // not be the first and last completions in the list of completions + // actually exposed to the user, as if there are active nicks + // they will be moved to the beginning of the list. + let firstCompletion = this._completions[0]; + let lastCompletion = this._completions.slice(-1)[0]; + + let preferredNick = false; + if (this._conv.isChat && !completingCommand) { + // If there are active nicks, prefer those. + let activeCompletions = this._completions.filter( + c => + this.buddies.has(c) && + !this.buddies.get(c).hasAttribute("inactive") + ); + if (activeCompletions.length == 1) { + preferredNick = true; + } + if (activeCompletions.length) { + // Move active nicks to the front of the queue. + activeCompletions.reverse(); + activeCompletions.forEach(function (c) { + this._completions.splice(this._completions.indexOf(c), 1); + this._completions.unshift(c); + }, this); + } + + // If one of the completions is the sender of the last ping, + // take it, if it was less than an hour ago. + if ( + this._lastPing && + this.buddies.has(this._lastPing) && + this._completions.includes(this._lastPing) && + Date.now() / 1000 - this._lastPingTime < 3600 + ) { + preferredNick = true; + this._completionsIndex = this._completions.indexOf(this._lastPing); + } + } + + // Display the possible completions in a system message. + delete this._shouldListCompletionsLater; + if (this._completions.length > 1) { + let completionsList = this._completions.join(" "); + if (preferredNick) { + // If we have a preferred nick (which is completed as a whole + // even if there are alternatives), only show the list of + // completions on the next <tab> press. + this._shouldListCompletionsLater = completionsList; + } else { + this._conv.systemMessage(completionsList); + } + } + + let suffix = isFirstWord ? firstWordSuffix : ""; + this._completions = this._completions.map(c => c + suffix); + + let completion; + if (this._completions.length == 1 || preferredNick) { + // Only one possible completion? Apply it! :-) + completion = this._completions[this._completionsIndex++]; + this._completionsIndex %= this._completions.length; + } else { + // We have several possible completions, attempt to find a common prefix. + let maxLength = Math.min( + firstCompletion.length, + lastCompletion.length + ); + let i = 0; + while (i < maxLength && firstCompletion[i] == lastCompletion[i]) { + ++i; + } + + if (i) { + completion = firstCompletion.substring(0, i); + } else { + // Include this case so that secondNick is applied anyway, + // in case a completion is added by another tab press. + completion = word; + } + } + + // Always replace what the user typed as its upper/lowercase may + // not be correct. + this.inputBox.selectionStart -= word.length; + this._completionsStart = this.inputBox.selectionStart; + + if (secondNick) { + // Replace the trailing colon with a comma before the completed nick. + this.inputBox.selectionStart -= 2; + completion = ", " + completion; + } + + this.addString(completion); + } else if (this._completions) { + delete this._completions; + } + + if (event.keyCode != 13) { + return; + } + + if (!event.ctrlKey && !event.shiftKey && !event.altKey) { + // Prevent the default action before calling sendMsg to avoid having + // a line break inserted in the textbox if sendMsg throws. + event.preventDefault(); + this.sendMsg(text); + } else if (!event.shiftKey) { + this.addString("\n"); + } + } + + inputValueChanged() { + // Delaying typing notifications will avoid sending several updates in + // a row if the user is on a slow or overloaded machine that has + // trouble to handle keystrokes in a timely fashion. + // Make sure only one typing notification call can be pending. + if (this._pendingValueChangedCall) { + return; + } + + this._pendingValueChangedCall = true; + Services.tm.mainThread.dispatch( + this.delayedInputValueChanged.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + } + + delayedInputValueChanged() { + this._pendingValueChangedCall = false; + + // By the time this function is executed, the conversation may have + // been closed. + if (!this._conv) { + return; + } + + let text = this.inputBox.value; + + // Try to avoid sending typing notifications when the user is + // typing a command in the conversation. + // These checks are not perfect (especially if non-existing + // commands are sent as regular messages on the in-use prpl). + let left = Ci.prplIConversation.NO_TYPING_LIMIT; + if (!text.startsWith("/")) { + left = this._conv.sendTyping(text); + } else if (/^\/me /.test(text)) { + left = this._conv.sendTyping(text.slice(4)); + } + + // When the input box is cleared or there is no character limit, + // don't show the character limit. + if (left == Ci.prplIConversation.NO_TYPING_LIMIT || !text.length) { + this.charCounter.setAttribute("value", ""); + this.inputBox.removeAttribute("invalidInput"); + } else { + // 200 is a 'magic' constant to avoid showing big numbers. + this.charCounter.setAttribute("value", left < 200 ? left : ""); + + if (left >= 0) { + this.inputBox.removeAttribute("invalidInput"); + } else if (left < 0) { + this.inputBox.setAttribute("invalidInput", "true"); + } + } + } + + resetInput() { + this.inputBox.value = ""; + this.charCounter.setAttribute("value", ""); + this.inputBox.removeAttribute("invalidInput"); + + this._statusText = ""; + this.displayStatusText(); + + if (TextboxSize.autoResize) { + let currHeight = Math.round( + this.inputBox.parentNode.getBoundingClientRect().height + ); + if ( + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD > + currHeight + ) { + this.inputBox.defaultHeight = + currHeight - this._TEXTBOX_VERTICAL_OVERHEAD; + } + this.convBottom.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + this.inputBox.style.overflowY = "hidden"; + } + } + + inputExpand(event) { + // This feature has been disabled, or the user is currently dragging + // the splitter and the textbox has received an overflow event + if ( + !TextboxSize.autoResize || + this.splitter.getAttribute("state") == "dragging" + ) { + this.inputBox.style.overflowY = ""; + return; + } + + // Check whether we can increase the height without hiding the status bar + // (ensure the min-height property on the top part of this dialog) + let topBoxStyle = window.getComputedStyle(this.convTop); + let topMinSize = parseInt(topBoxStyle.getPropertyValue("min-height")); + let topSize = parseInt(topBoxStyle.getPropertyValue("height")); + let deck = this.inputBox.parentNode; + let oldDeckHeight = Math.round(deck.getBoundingClientRect().height); + let newDeckHeight = + parseInt(this.inputBox.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD; + + if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) { + // Hide a possible vertical scrollbar. + this.inputBox.style.overflowY = "hidden"; + deck.style.height = newDeckHeight + "px"; + } else { + this.inputBox.style.overflowY = ""; + // Set it to the maximum possible value. + deck.style.height = oldDeckHeight + (topSize - topMinSize) + "px"; + } + } + + onConvResize() { + if (!this.splitter.hasAttribute("state")) { + this.calculateTextboxDefaultHeight(); + this.inputBox.parentNode.style.height = + this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD + "px"; + } else { + // Used in case the browser is already on its min-height, resize the + // textbox to avoid hiding the status bar. + let convTopStyle = window.getComputedStyle(this.convTop); + let convTopHeight = parseInt(convTopStyle.getPropertyValue("height")); + let convTopMinHeight = parseInt( + convTopStyle.getPropertyValue("min-height") + ); + + if (convTopHeight == convTopMinHeight) { + this.inputBox.parentNode.style.height = + this.inputBox.parentNode.style.minHeight; + convTopHeight = parseInt(convTopStyle.getPropertyValue("height")); + this.inputBox.parentNode.style.height = + parseInt(this.inputBox.parentNode.style.minHeight) + + (convTopHeight - convTopMinHeight) + + "px"; + } + } + if (TextboxSize.autoResize) { + this.inputExpand(); + } + } + + _onTextboxUnderflow(event) { + if (TextboxSize.autoResize) { + this.style.overflowY = "hidden"; + } + } + + browserKeyPress(event) { + let accelKeyPressed = + AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey; + + // 118 is the decimal code for "v" character, 13 keyCode for "return" key + if ( + ((accelKeyPressed && event.charCode != 118) || event.altKey) && + event.keyCode != 13 + ) { + return; + } + + if ( + event.charCode == 0 && // it's not a character, it's a command key + event.keyCode != 13 && // Return + event.keyCode != 8 && // Backspace + event.keyCode != 46 + ) { + // Delete + return; + } + + if ( + accelKeyPressed || + !Services.prefs.getBoolPref("accessibility.typeaheadfind") + ) { + this.inputBox.focus(); + + // A common use case is to click somewhere in the conversation and + // start typing a command (often /me). If quick find is enabled, it + // will pick up the "/" keypress and open the findbar. + if (event.charCode == "/".charCodeAt(0)) { + event.preventDefault(); + } + } + + // Returns for Ctrl+V + if (accelKeyPressed) { + return; + } + + // resend the event + let clonedEvent = new KeyboardEvent("keypress", event); + this.inputBox.dispatchEvent(clonedEvent); + } + + browserDblClick(event) { + if ( + !Services.prefs.getBoolPref( + "messenger.conversations.doubleClickToReply" + ) + ) { + return; + } + + for (let node = event.target; node; node = node.parentNode) { + if (node._originalMsg) { + let msg = node._originalMsg; + if ( + msg.system || + msg.outgoing || + !msg.incoming || + msg.error || + !this._conv.isChat + ) { + return; + } + this.addPrompt(msg.who + ": "); + return; + } + } + } + + /** + * Replace the current selection in the inputBox by the given string + * + * @param {string} aString + */ + addString(aString) { + let cursorPosition = this.inputBox.selectionStart + aString.length; + + this.inputBox.value = + this.inputBox.value.substr(0, this.inputBox.selectionStart) + + aString + + this.inputBox.value.substr(this.inputBox.selectionEnd); + this.inputBox.selectionStart = this.inputBox.selectionEnd = + cursorPosition; + this.inputValueChanged(); + } + + addPrompt(aPrompt) { + let currentEditorValue = this.inputBox.value; + if (!currentEditorValue.startsWith(aPrompt)) { + this.inputBox.value = aPrompt + currentEditorValue; + } + + this.inputBox.focus(); + this.inputValueChanged(); + } + + /** + * Update the participant count of a chat conversation + */ + updateParticipantCount() { + document.getElementById("participantCount").value = this.buddies.size; + } + + /** + * Set the attributes (flags) of a chat buddy + * + * @param {object} aItem + */ + setBuddyAttributes(aItem) { + let buddy = aItem.chatBuddy; + let src; + let l10nId; + if (buddy.founder) { + src = "chrome://messenger/skin/icons/founder.png"; + l10nId = "chat-participant-owner-role-icon2"; + } else if (buddy.admin) { + src = "chrome://messenger/skin/icons/operator.png"; + l10nId = "chat-participant-administrator-role-icon2"; + } else if (buddy.moderator) { + src = "chrome://messenger/skin/icons/half-operator.png"; + l10nId = "chat-participant-moderator-role-icon2"; + } else if (buddy.voiced) { + src = "chrome://messenger/skin/icons/voice.png"; + l10nId = "chat-participant-voiced-role-icon2"; + } + let imageEl = aItem.querySelector(".conv-nicklist-image"); + if (src) { + imageEl.setAttribute("src", src); + document.l10n.setAttributes(imageEl, l10nId); + } else { + imageEl.removeAttribute("src"); + imageEl.removeAttribute("data-l10n-id"); + imageEl.removeAttribute("alt"); + } + } + + /** + * Compute color for a nick + * + * @param {string} aName + */ + _computeColor(aName) { + // Compute the color based on the nick + let nick = aName.match(/[a-zA-Z0-9]+/); + nick = nick ? nick[0].toLowerCase() : (nick = aName); + // We compute a hue value (between 0 and 359) based on the + // characters of the nick. + // The first character weights kInitialWeight, each following + // character weights kWeightReductionPerChar * the weight of the + // previous character. + const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters. + const kWeightReductionPerChar = 0.52; // arbitrary value + let weight = kInitialWeight; + let res = 0; + for (let i = 0; i < nick.length; ++i) { + let char = nick.charCodeAt(i) - 47; + if (char > 10) { + char -= 39; + } + // now char contains a value between 1 and 36 + res += char * weight; + weight *= kWeightReductionPerChar; + } + return Math.round(res) % 360; + } + + _isBuddyActive(aBuddyName) { + return Object.prototype.hasOwnProperty.call( + this._activeBuddies, + aBuddyName + ); + } + + /** + * Create a buddy item to add in the visible list of participants + * + * @param {object} aBuddy + */ + createBuddy(aBuddy) { + let name = aBuddy.name; + if (!name) { + throw new Error("The empty string isn't a valid nick."); + } + if (this.buddies.has(name)) { + throw new Error("Adding chat buddy " + name + " twice?!"); + } + + this.trackNick(name); + + let image = document.createElement("img"); + image.classList.add("conv-nicklist-image"); + let label = document.createXULElement("label"); + label.classList.add("conv-nicklist-label"); + label.setAttribute("value", name); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + + // Fix insertBuddy below if you change the DOM makeup! + let item = document.createXULElement("richlistitem"); + item.chatBuddy = aBuddy; + item.appendChild(image); + item.appendChild(label); + this.setBuddyAttributes(item); + + let color = this._computeColor(name); + let style = "color: hsl(" + color + ", 100%, 40%);"; + item.colorStyle = style; + item.setAttribute("style", style); + item.setAttribute("align", "center"); + if (!this._isBuddyActive(name)) { + item.setAttribute("inactive", "true"); + } + item.color = color; + this.buddies.set(name, item); + + return item; + } + + /** + * Insert item at the right position + * + * @param {Node} aListItem + */ + insertBuddy(aListItem) { + let nicklist = document.getElementById("nicklist"); + let nick = aListItem.querySelector("label").value.toLowerCase(); + + // Look for the place of the nick in the list + let start = 0; + let end = nicklist.itemCount; + while (start < end) { + let middle = start + Math.floor((end - start) / 2); + if ( + nick < + nicklist + .getItemAtIndex(middle) + .firstElementChild.nextElementSibling.getAttribute("value") + .toLowerCase() + ) { + end = middle; + } else { + start = middle + 1; + } + } + + // Now insert the element + if (end == nicklist.itemCount) { + nicklist.appendChild(aListItem); + } else { + nicklist.insertBefore(aListItem, nicklist.getItemAtIndex(end)); + } + } + + /** + * Update a buddy in the visible list of participants + * + * @param {object} aBuddy + * @param {string} aOldName + */ + updateBuddy(aBuddy, aOldName) { + let name = aBuddy.name; + if (!name) { + throw new Error("The empty string isn't a valid nick."); + } + + if (!aOldName) { + if (!this._isConversationSelected) { + return; + } + // If aOldName is null, we are changing the flags of the buddy + let item = this.buddies.get(name); + item.chatBuddy = aBuddy; + this.setBuddyAttributes(item); + return; + } + + if (this._isBuddyActive(aOldName)) { + delete this._activeBuddies[aOldName]; + this._activeBuddies[aBuddy.name] = true; + } + + this.trackNick(name); + + if (!this._isConversationSelected) { + return; + } + + // Is aOldName is not null, then we are renaming the buddy + if (!this.buddies.has(aOldName)) { + throw new Error( + "Updating a chat buddy that does not exist: " + aOldName + ); + } + + if (this.buddies.has(name)) { + throw new Error( + "Updating a chat buddy to an already existing one: " + name + ); + } + + let item = this.buddies.get(aOldName); + item.chatBuddy = aBuddy; + this.buddies.delete(aOldName); + this.buddies.set(name, item); + item.querySelector("label").value = name; + + // Move this item to the right position if its name changed + item.remove(); + this.insertBuddy(item); + } + + removeBuddy(aName) { + if (!this.buddies.has(aName)) { + throw new Error("Cannot remove a buddy that was not in the room"); + } + this.buddies.get(aName).remove(); + this.buddies.delete(aName); + if (this._isBuddyActive(aName)) { + delete this._activeBuddies[aName]; + } + } + + trackNick(aNick) { + if ("_showNickList" in this) { + this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true; + delete this._showNickRegExp; + } + } + + getShowNickModifier() { + return function (aNode) { + if (!("_showNickRegExp" in this)) { + if (!("_showNickList" in this)) { + this._showNickList = {}; + for (let n of this.buddies.keys()) { + this._showNickList[n.replace(this._nickEscape, "\\$&")] = true; + } + } + + // The reverse sort ensures that if we have "foo" and "foobar", + // "foobar" will be matched first by the regexp. + let nicks = Object.keys(this._showNickList) + .sort() + .reverse() + .join("|"); + if (nicks) { + // We use \W to match for word-boundaries, as \b will not match the + // nick if it starts/ends with \W characters. + // XXX Ideally we would use unicode word boundaries: + // http://www.unicode.org/reports/tr29/#Word_Boundaries + this._showNickRegExp = new RegExp("\\W(?:" + nicks + ")\\W"); + } else { + // nobody, disable... + this._showNickRegExp = { exec: () => null }; + return 0; + } + } + let exp = this._showNickRegExp; + let result = 0; + let match; + // Add leading/trailing spaces to match at beginning and end of + // the string as well. (If we used regex ^ and $, match.index would + // not be reliable.) + while ((match = exp.exec(" " + aNode.data + " "))) { + // \W is not zero-length, but this is cancelled by the + // extra leading space here. + let nickNode = aNode.splitText(match.index); + // subtract the 2 \W's to get the length of the nick. + aNode = nickNode.splitText(match[0].length - 2); + // at this point, nickNode is a text node with only the text + // of the nick and aNode is a text node with the text after + // the nick. The text in aNode hasn't been processed yet. + let nick = nickNode.data; + let elt = aNode.ownerDocument.createElement("span"); + elt.setAttribute("class", "ib-nick"); + if (this.buddies.has(nick)) { + let buddy = this.buddies.get(nick); + elt.setAttribute("style", buddy.colorStyle); + elt.setAttribute("data-nickColor", buddy.color); + } else { + elt.setAttribute("data-left", "true"); + } + nickNode.parentNode.replaceChild(elt, nickNode); + elt.textContent = nick; + result += 2; + } + return result; + }.bind(this); + } + + /** + * Display the topic and topic editable flag for the current MUC in the + * conversation header. + */ + updateTopic() { + let cti = document.getElementById("conv-top-info"); + let editable = !!this._conv.topicSettable; + + let topicText = this._conv.topic; + let noTopic = !topicText; + cti.setAsChat(topicText || this._conv.noTopicString, noTopic, editable); + } + + focus() { + this.inputBox.focus(); + + if (!this.loaded) { + return; + } + + if (this.tab) { + this.tab.removeAttribute("unread"); + this.tab.removeAttribute("attention"); + } + this._conv.markAsRead(); + } + + switchingToPanel() { + if (this._visibleTimer) { + return; + } + + // Start a timer to detect if the tab has been visible to the + // user for long enough to actually be seen (as opposed to the + // tab only being visible "accidentally in passing"). + delete this._wasVisible; + this._visibleTimer = setTimeout(() => { + this._wasVisible = true; + delete this._visibleTimer; + + // Porting note: For TB, we also need to update the conv title + // and reset the unread flag. In IB, this is done by tabbrowser. + this.tab.update(); + }, 1000); + this.convBrowser.isActive = true; + } + + switchingAwayFromPanel(aHidden) { + if (this._visibleTimer) { + clearTimeout(this._visibleTimer); + delete this._visibleTimer; + } + // Remove the unread ruler if the tab has been visible without + // interruptions for sufficiently long. + if (this._wasVisible) { + this.convBrowser.removeUnreadRuler(); + } + + if (aHidden) { + this.convBrowser.isActive = false; + } + } + + updateConvStatus() { + let cti = document.getElementById("conv-top-info"); + cti.setProtocol(this._conv.account.protocol); + + // Set the icon, potentially showing a fallback icon if this is an IM. + cti.setUserIcon(this._conv.convIconFilename, !this._conv.isChat); + + if (this._conv.isChat) { + this.updateTopic(); + cti.setAttribute("displayName", this._conv.title); + } else { + let displayName = this._conv.title; + let statusText = ""; + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + + let buddy = this._conv.buddy; + if (buddy?.account.connected) { + displayName = buddy.displayName; + statusText = buddy.statusText; + statusType = buddy.statusType; + } + cti.setAttribute("displayName", displayName); + + let statusName; + + let typingState = this._conv.typingState; + let typingName = this._currentTypingName || this._conv.title; + + switch (typingState) { + case Ci.prplIConvIM.TYPING: + statusName = "active-typing"; + statusText = this.bundle.formatStringFromName( + "chat.contactIsTyping", + [typingName], + 1 + ); + break; + case Ci.prplIConvIM.TYPED: + statusName = "paused-typing"; + statusText = this.bundle.formatStringFromName( + "chat.contactHasStoppedTyping", + [typingName], + 1 + ); + break; + default: + statusName = Status.toAttribute(statusType); + statusText = Status.toLabel(statusType, statusText); + break; + } + cti.setStatus(statusName, statusText); + } + } + + showParticipants() { + if (this._conv.isChat) { + let nicklist = document.getElementById("nicklist"); + while (nicklist.hasChildNodes()) { + nicklist.lastChild.remove(); + } + // Populate the nicklist + this.buddies = new Map(); + for (let n of this.conv.getParticipants()) { + this.createBuddy(n); + } + nicklist.append( + ...Array.from(this.buddies.keys()) + .sort((a, b) => a.localeCompare(b)) + .map(nick => this.buddies.get(nick)) + ); + this.updateParticipantCount(); + } + } + + /** + * Set up the shared conversation specific components (conversation browser + * references, status header, participants list, text input) for this + * conversation. + */ + initConversationUI() { + this._activeBuddies = {}; + if (this._conv.isChat) { + let cti = document.getElementById("conv-top-info"); + cti.setAttribute("displayName", this._conv.title); + + this.showParticipants(); + + if (Services.prefs.getBoolPref("messenger.conversations.showNicks")) { + this.convBrowser.addTextModifier(this.getShowNickModifier()); + } + } + + if (this.tab) { + this.tab.setAttribute("label", this._conv.title); + } + + this.findbar.browser = this.convBrowser; + + this.updateConvStatus(); + this.initTextboxFormat(); + } + + /** + * Change the UI Conversation attached to this component and its browser. + * Does not clear any existing messages in the conversation browser. + * + * @param {imIConversation} conv + */ + changeConversation(conv) { + this._conv.removeObserver(this.observer); + this._conv = conv; + this._conv.addObserver(this.observer); + this.convBrowser._conv = conv; + this.initConversationUI(); + } + + get editor() { + return this.inputBox; + } + + get _isConversationSelected() { + // TB-only: returns true if the chat conversation element is the currently + // selected one, i.e if it has to maintain the participant list. + // The JS property this.tab.selected is always false when the chat tab + // is inactive, so we need to double-check to be sure. + return this.tab.selected || this.tab.hasAttribute("selected"); + } + + get convId() { + return this._conv.id; + } + + get conv() { + return this._conv; + } + + set conv(val) { + if (this._conv && val) { + throw new Error("chat-conversation already initialized"); + } + if (!val) { + // this conversation has probably been moved to another + // tab. Forget the prplConversation so that it isn't + // closed when destroying this binding. + this._forgetConv(); + return; + } + this._conv = val; + this._conv.addObserver(this.observer); + this.convBrowser.init(this._conv); + this.initConversationUI(); + } + + get contentWindow() { + return this.convBrowser.contentWindow; + } + + get bundle() { + if (!this._bundle) { + this._bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + } + return this._bundle; + } + } + + customElements.define("chat-conversation", MozChatConversation); +} diff --git a/comm/mail/components/im/content/chat-group.js b/comm/mail/components/im/content/chat-group.js new file mode 100644 index 0000000000..80bf25159c --- /dev/null +++ b/comm/mail/components/im/content/chat-group.js @@ -0,0 +1,255 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozXULElement, MozElements */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozChatGroupRichlistitem widget displays chat group name and behave as a + * expansion twisty for groups such as "Conversations", + * "Online Contacts" and "Offline Contacts". + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatGroupRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + label: "value=name", + }; + } + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-group-richlistitem"); + this.setAttribute("collapsed", "true"); + + /* Here we use a div, rather than the usual img because the icon image + * relies on CSS -moz-locale-dir(rtl). The corresponding icon + * twisty-collapsed-rtl icon is not a simple mirror transformation of + * twisty-collapsed. + * Currently, CSS sets the background-image based on the "closed" state. + * The element is a visual decoration and does not require any alt text + * since the aria-expanded attribute describes its state. + */ + this._image = document.createElement("div"); + this._image.classList.add("twisty"); + + this._label = document.createXULElement("label"); + this._label.setAttribute("flex", "1"); + this._label.setAttribute("crop", "end"); + + this.appendChild(this._image); + this.appendChild(this._label); + + this.contacts = []; + + this.contactsById = {}; + + this.displayName = ""; + + this.addEventListener("click", event => { + // Check if there was 1 click on the image or 2 clicks on the label + if ( + (event.detail == 1 && event.target.classList.contains("twisty")) || + (event.detail == 2 && event.target.localName == "label") + ) { + this.toggleClosed(); + } else if (event.target.localName == "button") { + this.hide(); + } + }); + + this.addEventListener("contextmenu", event => { + event.preventDefault(); + }); + + if (this.classList.contains("closed")) { + this.setAttribute("aria-expanded", "true"); + } else { + this.setAttribute("aria-expanded", "false"); + } + + this.initializeAttributeInheritance(); + } + + /** + * Takes as input two contact elements (imIContact type) and compares + * their nicknames alphabetically (case insensitive). This method + * behaves as a callback that Array.prototype.sort accepts as a + * parameter. + */ + sortComparator(contactA, contactB) { + if (contactA.statusType != contactB.statusType) { + return contactB.statusType - contactA.statusType; + } + let a = contactA.displayName.toLowerCase(); + let b = contactB.displayName.toLowerCase(); + return a.localeCompare(b); + } + + addContact(contact, tagName) { + if (this.contactsById.hasOwnProperty(contact.id)) { + return null; + } + + let contactElt; + if (tagName) { + contactElt = document.createXULElement("richlistitem", { + is: "chat-imconv-richlistitem", + }); + } else { + contactElt = document.createXULElement("richlistitem", { + is: "chat-contact-richlistitem", + }); + } + if (this.classList.contains("closed")) { + contactElt.setAttribute("collapsed", "true"); + } + + let end = this.contacts.length; + // Avoid the binary search loop if the contacts were already sorted. + if ( + end != 0 && + this.sortComparator(contact, this.contacts[end - 1].contact) < 0 + ) { + let start = 0; + while (start < end) { + let middle = start + Math.floor((end - start) / 2); + if (this.sortComparator(contact, this.contacts[middle].contact) < 0) { + end = middle; + } else { + start = middle + 1; + } + } + } + let last = end == 0 ? this : this.contacts[end - 1]; + this.parentNode.insertBefore(contactElt, last.nextElementSibling); + contactElt.build(contact); + contactElt.group = this; + this.contacts.splice(end, 0, contactElt); + this.contactsById[contact.id] = contactElt; + this.removeAttribute("collapsed"); + this._updateGroupLabel(); + return contactElt; + } + + updateContactPosition(subject, tagName) { + let contactElt = this.contactsById[subject.id]; + let index = this.contacts.indexOf(contactElt); + if (index == -1) { + // Sometimes we get a display-name-changed notification for + // an offline contact, if it's not in the list, just ignore it. + return; + } + // See if the position of the contact should be changed. + if ( + (index != 0 && + this.sortComparator( + contactElt.contact, + this.contacts[index - 1].contact + ) < 0) || + (index != this.contacts.length - 1 && + this.sortComparator( + contactElt.contact, + this.contacts[index + 1].contact + ) > 0) + ) { + let list = this.parentNode; + let selectedItem = list.selectedItem; + let oldItem = this.removeContact(subject); + let newItem = this.addContact(subject, tagName); + if (selectedItem == oldItem) { + list.selectedItem = newItem; + } + } + } + + removeContact(contactForID) { + let contact = this.contactsById[contactForID.id]; + if (!contact) { + throw new Error("Can't remove contact for id=" + contactForID.id); + } + + // create a new array to remove without breaking for each loops. + this.contacts = this.contacts.filter(c => c !== contact); + delete this.contactsById[contact.contact.id]; + + contact.destroy(); + + // Check if some contacts remain in the group, if empty hide it. + if (!this.contacts.length) { + this.setAttribute("collapsed", "true"); + } else { + this._updateGroupLabel(); + } + + return contact; + } + + _updateClosedState(closed) { + for (let contact of this.contacts) { + contact.collapsed = closed; + } + } + + toggleClosed() { + if (this.classList.contains("closed")) { + this.classList.remove("closed"); + this.setAttribute("aria-expanded", "true"); + this._updateClosedState(false); + } else { + this.classList.add("closed"); + this.setAttribute("aria-expanded", "false"); + this._updateClosedState(true); + } + + this._updateGroupLabel(); + } + + _updateGroupLabel() { + if (!this.displayName) { + this.displayName = this.getAttribute("name"); + } + let name = this.displayName; + if (this.classList.contains("closed")) { + name += " (" + this.contacts.length + ")"; + } + + this.setAttribute("name", name); + } + + keyPress(event) { + switch (event.keyCode) { + case event.DOM_VK_RETURN: + this.toggleClosed(); + break; + + case event.DOM_VK_LEFT: + if (!this.classList.contains("closed")) { + this.toggleClosed(); + } + break; + + case event.DOM_VK_RIGHT: + if (this.classList.contains("closed")) { + this.toggleClosed(); + } + break; + } + } + } + + MozXULElement.implementCustomInterface(MozChatGroupRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("chat-group-richlistitem", MozChatGroupRichlistitem, { + extends: "richlistitem", + }); +} diff --git a/comm/mail/components/im/content/chat-imconv.js b/comm/mail/components/im/content/chat-imconv.js new file mode 100644 index 0000000000..759a3ce78a --- /dev/null +++ b/comm/mail/components/im/content/chat-imconv.js @@ -0,0 +1,366 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozElements, MozXULElement, gChatTab, chatHandler */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" + ); + + /** + * The MozChatConvRichlistitem widget displays opened conversation information from the + * contacts: i.e name and icon. It gets displayed under conversation expansion + * twisty in the contactlist richlistbox. + * + * @augments {MozElements.MozRichlistitem} + */ + class MozChatConvRichlistitem extends MozElements.MozRichlistitem { + static get inheritedAttributes() { + return { + ".box-line": "selected", + ".convDisplayName": "value=displayname,status", + ".convUnreadTargetedCount": "value=unreadTargetedCount", + ".convUnreadCount": "value=unreadCount", + ".convUnreadTargetedCountLabel": "value=unreadTargetedCount", + }; + } + + static get markup() { + return ` + <vbox class="box-line"></vbox> + <button class="closeConversationButton close-icon" + tooltiptext="&closeConversationButton.tooltip;"></button> + <stack class="prplBuddyIcon"> + <html:img class="protoIcon" alt="" /> + <html:img class="smallStatusIcon" /> + </stack> + <hbox flex="1" class="conv-hbox"> + <label crop="end" class="convDisplayName blistDisplayName"> + </label> + <label class="convUnreadCount" crop="end"></label> + <box class="convUnreadTargetedCount"> + <label class="convUnreadTargetedCountLabel" crop="end"></label> + </box> + <spacer style="flex: 1000000 1000000;"></spacer> + </hbox> + `; + } + + static get entities() { + return ["chrome://messenger/locale/chat.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "chat-imconv-richlistitem"); + + this.addEventListener( + "mousedown", + event => { + if (event.target.classList.contains("closeConversationButton")) { + this.closeConversation(); + event.stopPropagation(); + event.preventDefault(); + } + }, + true + ); + + this.appendChild(this.constructor.fragment); + + this.convView = null; + + this.directedUnreadCount = 0; + + new MutationObserver(mutations => { + if (!this.convView || !this.convView.loaded) { + return; + } + if (this.hasAttribute("selected")) { + this.convView.switchingToPanel(); + } else { + this.convView.switchingAwayFromPanel(true); + } + }).observe(this, { attributes: true, attributeFilter: ["selected"] }); + + // @implements {nsIObserver} + this.observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe: function (subject, topic, data) { + if ( + topic == "target-prpl-conversation-changed" || + topic == "unread-message-count-changed" || + topic == "update-conv-title" || + topic == "update-buddy-status" || + topic == "update-buddy-status" || + topic == "update-conv-chatleft" || + topic == "update-conv-chatjoining" || + topic == "chat-update-topic" + ) { + this.update(); + } + if (topic == "update-conv-title") { + this.group.updateContactPosition( + this.conv, + "chat-imconv-richlistitem" + ); + } + }.bind(this), + }; + + if (this.hasAttribute("is-search-result")) { + let icon = this.querySelector(".protoIcon"); + icon.classList.add("searchProtoIcon"); + icon.setAttribute("src", "chrome://global/skin/icons/search-glass.svg"); + let statusIcon = this.querySelector(".smallStatusIcon"); + statusIcon.hidden = true; + this.setAttribute("unreadCount", "0"); + this.setAttribute("unreadTargetedCount", "0"); + } + + this.initializeAttributeInheritance(); + } + + get displayName() { + return this.conv.title; + } + + /** + * This getter exists to provide compatibility with the imgroup sortComparator. + */ + get contact() { + return this.conv; + } + + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + } + + get selected() { + return ( + gChatTab && + gChatTab.tabNode.selected && + this.getAttribute("selected") == "true" + ); + } + + /** + * Set the conversation this item should represent. Updates appearance and + * adds observers to keep it up to date. + * + * @param {imIConversation} conv - Conversation this item represents. + */ + build(conv) { + this.conv = conv; + this.conv.addObserver(this.observer); + this.update(); + } + + update() { + this.setAttribute("displayname", this.displayName); + if (this.selected && document.hasFocus()) { + if (this.convView && this.convView.loaded) { + this.conv.markAsRead(); + this.directedUnreadCount = 0; + chatHandler.updateTitle(); + chatHandler.updateChatButtonState(); + } + this.setAttribute("unreadCount", "0"); + this.setAttribute("unreadTargetedCount", "0"); + this.removeAttribute("unread"); + this.removeAttribute("attention"); + } else { + let unreadCount = + this.conv.unreadIncomingMessageCount + + this.conv.unreadOTRNotificationCount; + let directedMessages = unreadCount; + if (unreadCount) { + this.setAttribute("unread", "true"); + if (this.conv.isChat) { + directedMessages = this.conv.unreadTargetedMessageCount; + if (directedMessages) { + this.setAttribute("attention", "true"); + } + } + unreadCount -= directedMessages; + if (directedMessages > this.directedUnreadCount) { + this.directedUnreadCount = directedMessages; + } + } + if (unreadCount) { + unreadCount = "(" + unreadCount + ")"; + } + this.setAttribute("unreadCount", unreadCount); + if ( + Services.prefs.getBoolPref( + "messenger.options.getAttentionOnNewMessages" + ) && + directedMessages > parseInt(this.getAttribute("unreadTargetedCount")) + ) { + window.getAttention(); + } + this.setAttribute("unreadTargetedCount", directedMessages); + chatHandler.updateTitle(); + } + + let statusIcon = this.querySelector(".smallStatusIcon"); + let statusName; + statusIcon.hidden = false; + if (this.conv.isChat) { + if (this.conv.joining) { + statusName = "joining"; + } else if (!this.conv.account.connected || this.conv.left) { + statusName = "left"; + } + if (statusName) { + statusIcon.setAttribute( + "src", + ChatIcons.getStatusIconURI(statusName) + ); + // Set alt using messenger/chat.ftl. + document.l10n.setAttributes( + statusIcon, + `chat-${statusName}-chat-icon2` + ); + } else { + statusIcon.removeAttribute("src"); + statusIcon.removeAttribute("data-l10n-id"); + statusIcon.removeAttribute("alt"); + statusIcon.hidden = true; + // Treat protoIcon as if connected. + statusName = "connected"; + } + } else { + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + let buddy = this.conv.buddy; + if (buddy && buddy.account.connected) { + statusType = buddy.statusType; + } + statusName = Status.toAttribute(statusType); + statusIcon.setAttribute("src", ChatIcons.getStatusIconURI(statusName)); + statusIcon.removeAttribute("data-l10n-id"); + statusIcon.setAttribute("alt", Status.toLabel(statusType)); + } + + if (!this.hasAttribute("is-search-result")) { + let protoIcon = this.querySelector(".protoIcon"); + protoIcon.setAttribute( + "src", + ChatIcons.getProtocolIconURI(this.conv.account.protocol) + ); + ChatIcons.setProtocolIconOpacity(protoIcon, statusName); + } + } + + destroy() { + if (this.conv) { + this.conv.removeObserver(this.observer); + } + if (this.convView) { + this.convView.destroy(); + this.convView.remove(); + } + + // If the conversation we are destroying was selected, we should + // select something else, but the 'select' event handler of + // the listbox will choke while updating the Chat tab title if + // there are conversation nodes associated with a conversation + // that no longer exists from the chat core's point of view, so + // we do the actual selection change only after this conversation + // item is fully destroyed and removed from the list. + let newSelectedItem; + let list = this.parentNode; + if (list.selectedItem == this) { + newSelectedItem = this.previousElementSibling; + } + + if (this.log) { + this.hidden = true; + delete this.log; + } else { + this.remove(); + delete this.conv; + } + if (newSelectedItem) { + list.selectedItem = newSelectedItem; + } + } + + closeConversation() { + if (this.conv) { + this.conv.close(); + } else { + this.destroy(); + } + } + + keyPress(event) { + // If Enter or Return is pressed, focus the input box. + if (event.keyCode == event.DOM_VK_RETURN) { + this.convView.focus(); + return; + } + + let accelKeyPressed = + AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey; + // If a character was typed or the accel+v copy shortcut was used, + // focus the input box and resend the key event. + if ( + event.charCode != 0 && + !event.altKey && + ((accelKeyPressed && event.charCode == "v".charCodeAt(0)) || + (!event.ctrlKey && !event.metaKey)) + ) { + this.convView.focus(); + + let clonedEvent = new KeyboardEvent("keypress", event); + this.convView.editor.dispatchEvent(clonedEvent); + event.preventDefault(); + } + } + + /** + * Replace the conversation that this item represents. + * + * @param {imIConversation} conv - Updated conversation this should + * represent. + */ + changeConversation(conv) { + this.conv?.removeObserver(this.observer); + this.build(conv); + } + + disconnectedCallback() { + if (this.conv) { + this.conv.removeObserver(this.observer); + delete this.conv; + } + } + } + + MozXULElement.implementCustomInterface(MozChatConvRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("chat-imconv-richlistitem", MozChatConvRichlistitem, { + extends: "richlistitem", + }); +} diff --git a/comm/mail/components/im/content/chat-menu.inc.xhtml b/comm/mail/components/im/content/chat-menu.inc.xhtml new file mode 100644 index 0000000000..8ded5e0edb --- /dev/null +++ b/comm/mail/components/im/content/chat-menu.inc.xhtml @@ -0,0 +1,109 @@ +# 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/. + + <tooltip is="chat-tooltip" id="imTooltip"/> + + <menupopup id="buddyListContextMenu" + onpopupshowing="if (event.target != this) { return true; } gBuddyListContextMenu = new buddyListContextMenu(this); return gBuddyListContextMenu.shouldDisplay;" + onpopuphiding="if (event.target == this) { gBuddyListContextMenu = null; }"> + <menuitem id="context-openconversation" + label="&openConversationCmd.label;" + accesskey="&openConversationCmd.accesskey;" + oncommand="gBuddyListContextMenu.openConversation();"/> + <menuitem id="context-close-conversation" + label="&closeConversationCmd.label;" + accesskey="&closeConversationCmd.accesskey;" + oncommand="gBuddyListContextMenu.closeConversation();"/> + <menuitem id="context-verifyBuddy" + data-l10n-id="chat-verify-identity" + oncommand="gBuddyListContextMenu.verifyIdentity();"/> + <menuseparator id="context-edit-buddy-separator"/> + <menuitem id="context-alias" + label="&aliasCmd.label;" + accesskey="&aliasCmd.accesskey;" + oncommand="gBuddyListContextMenu.alias();"/> + <menuitem id="context-delete" + data-l10n-id="text-action-delete" + oncommand="gBuddyListContextMenu.delete();"/> + </menupopup> + + <menupopup id="chatConversationContextMenu" + onpopupshowing="if (event.target != this) { return true; } gChatContextMenu = new imContextMenu(this); return gChatContextMenu.shouldDisplay;" + onpopuphiding="if (event.target == this && gChatContextMenu) { gChatContextMenu.cleanup(); gChatContextMenu = null; }"> + <menuitem id="context-openlink" + label="&openLinkCmd.label;" + accesskey="&openLinkCmd.accesskey;" + oncommand="gChatContextMenu.openLink();"/> + <menuitem id="context-copyemail" + label="©EmailCmd.label;" + accesskey="©EmailCmd.accesskey;" + oncommand="gChatContextMenu.copyEmail();"/> + <menuitem id="context-copylink" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + oncommand="goDoCommand('cmd_copyLink');"/> + <menuseparator id="context-sep-copylink"/> + + <menuitem id="context-copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuitem id="context-selectall" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + <menuseparator id="context-sep-messageactions"/> + </menupopup> + + <menupopup id="chat-toolbar-context-menu"> + <menuitem id="CustomizeChatToolbar" + oncommand="CustomizeMailToolbar('chat-view-toolbox', 'CustomizeChatToolbar')" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;"/> + </menupopup> + + <menupopup id="chatContextMenu" + onpopupshowing="if (event.target != this) { return true; } openChatContextMenu(this);" + onpopuphiding="if (event.target == this) { clearChatContextMenu(this); }"> + + <!-- Spellchecking menu items --> + <menuitem id="spellCheckNoSuggestions" + data-l10n-id="text-action-spell-no-suggestions" + disabled="true"/> + <menuseparator id="spellCheckAddSep" /> + <menuitem id="spellCheckAddToDictionary" + data-l10n-id="text-action-spell-add-to-dictionary" + oncommand="gChatSpellChecker.addToDictionary();"/> + <menuseparator id="spellCheckSuggestionsSeparator"/> + + <menuitem data-l10n-id="text-action-undo" command="cmd_undo"/> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut"/> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy"/> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste"/> + <menuseparator/> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"/> + + <!-- Spellchecking general menu items (enable, add dictionaries...) --> + <menuseparator id="spellCheckSeparator"/> + <menuitem id="spellCheckEnable" + data-l10n-id="text-action-spell-check-toggle" + type="checkbox" + oncommand="enableInlineSpellCheck(!gChatSpellChecker.enabled);"/> + <menu id="spellCheckDictionaries" + data-l10n-id="text-action-spell-dictionaries"> + <menupopup id="spellCheckDictionariesMenu"> + <menuseparator id="spellCheckLanguageSeparator"/> + <menuitem id="spellCheckAddDictionaries" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="openDictionaryList();"/> + </menupopup> + </menu> + + </menupopup> + + <menupopup id="participantListContextMenu" + onpopupshowing="return showParticipantMenu(this);"> + <menuitem id="context-verifyParticipant" + data-l10n-id="chat-verify-identity" + oncommand="verifyChatParticipant();"/> + </menupopup> diff --git a/comm/mail/components/im/content/chat-messenger.inc.xhtml b/comm/mail/components/im/content/chat-messenger.inc.xhtml new file mode 100644 index 0000000000..6b1fbb9f8f --- /dev/null +++ b/comm/mail/components/im/content/chat-messenger.inc.xhtml @@ -0,0 +1,192 @@ +# 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/. + + <vbox id="chatTabPanel"> + <toolbox id="chat-view-toolbox" class="mail-toolbox" + mode="full" defaultmode="full" + labelalign="end" defaultlabelalign="end"> + <toolbar is="customizable-toolbar" id="chat-toolbar" + class="inline-toolbar chromeclass-toolbar themeable-full" + fullscreentoolbar="true" + customizable="true" + context="chat-toolbar-context-menu" + mode="full" +#ifdef XP_MACOSX + iconsize="small" +#endif + defaultset="button-add-buddy,button-join-chat,spacer,chat-status-selector,button-chat-accounts,spacer,gloda-im-search"/> + + <toolbarpalette id="ChatToolbarPalette"> + <toolbarbutton id="button-add-buddy" + class="toolbarbutton-1" + label="&addBuddyButton.label;" + oncommand="chatHandler.addBuddy()"/> + <toolbarbutton id="button-join-chat" + class="toolbarbutton-1" + label="&joinChatButton.label;" + oncommand="chatHandler.joinChat()"/> + <toolbaritem id="chat-status-selector" + orient="horizontal" + align="center" flex="1"> + <toolbarbutton id="statusTypeIcon" + type="menu" + wantdropmarker="true" + class="toolbarbutton-1" + status="available"> + <menupopup id="setStatusTypeMenupopup" + oncommand="statusSelector.editStatus(event);"> + <menuitem id="statusTypeAvailable" label="&status.available;" + status="available" class="menuitem-iconic"/> + <menuitem id="statusTypeUnavailable" label="&status.unavailable;" + status="unavailable" class="menuitem-iconic"/> + <menuseparator id="statusTypeOfflineSeparator"/> + <menuitem id="statusTypeOffline" label="&status.offline;" + status="offline" class="menuitem-iconic"/> + </menupopup> + </toolbarbutton> + <vbox flex="1" + orient="horizontal" + align="center" + class="input-container status-container"> + <label id="statusMessageLabel" + flex="1" + value="" + class="statusMessageToolbarItem label-inline" + onclick="statusSelector.statusMessageClick();"/> + <html:input id="statusMessageInput" + value="" + class="statusMessageInput statusMessageToolbarItem status-message-input" + hidden="hidden"/> + </vbox> + </toolbaritem> + <toolbarbutton id="button-chat-accounts" + class="toolbarbutton-1" + label="&chatAccountsButton.label;" + oncommand="openIMAccountMgr()"/> + </toolbarpalette> + </toolbox> + + <vbox flex="1"> + <hbox id="chatPanel" flex="1"> + <vbox id="listPaneBox" style="min-width:125px;" width="200" persist="width"> + <richlistbox id="contactlistbox" + context="buddyListContextMenu" + tooltip="imTooltip" flex="1"> + <richlistitem is="chat-group-richlistitem" id="conversationsGroup" + name="&conversationsHeader.label;"/> + <richlistitem is="chat-imconv-richlistitem" + id="searchResultConv" + displayname="&searchResultConversation.label;" + is-search-result="" + hidden="true"/> + <richlistitem is="chat-group-richlistitem" id="onlinecontactsGroup" + name="&onlineContactsHeader.label;"/> + <richlistitem is="chat-group-richlistitem" id="offlinecontactsGroup" + name="&offlineContactsHeader.label;" + class="closed"/> + </richlistbox> + </vbox> + <splitter id="listSplitter" collapse="before"/> + <vbox id="chat-notification-top" flex="1"> + <!-- notificationbox will be added here lazily. --> + <vbox id="conversationsBox" flex="1"> + + <vbox flex="1" id="noConvScreen" class="im-placeholder-screen" align="center" pack="center"> + <hbox id="noConvBox" class="im-placeholder-box" align="start"> + <vbox id="noConvInnerBox" class="im-placeholder-innerbox" flex="1"> + <label id="noConvTitle" class="im-placeholder-title">&chat.noConv.title;</label> + <description id="noConvDesc" + class="im-placeholder-desc">&chat.noConv.description;</description> + </vbox> + <vbox id="noAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true"> + <label id="noAccountTitle" class="im-placeholder-title">&chat.noAccount.title;</label> + <description id="noAccountDesc" + class="im-placeholder-desc">&chat.noAccount.description;</description> + <hbox class="im-placeholder-button-box" flex="1"> + <spacer flex="1"/> + <button id="openIMAccountWizardButton" label="&chat.accountWizard.button;" + oncommand="openIMAccountWizard();"/> + </hbox> + </vbox> + <vbox id="noConnectedAccountInnerBox" class="im-placeholder-innerbox" flex="1" hidden="true"> + <label id="noConnectedAccountTitle" + class="im-placeholder-title">&chat.noConnectedAccount.title;</label> + <description id="noConnectedAccountDesc" + class="im-placeholder-desc">&chat.noConnectedAccount.description;</description> + <hbox class="im-placeholder-button-box" flex="1"> + <spacer flex="1"/> + <button id="openIMAccountManagerButton" label="&chat.showAccountManager.button;" + oncommand="openIMAccountMgr();"/> + </hbox> + </vbox> + </hbox> + </vbox> + + <vbox id="logDisplay" flex="1" hidden="true"> + <vbox flex="1"> + <vbox flex="1" id="noPreviousConvScreen" class="im-placeholder-screen" align="center" pack="center"> + <hbox id="noPreviousConvBox" class="im-placeholder-box" align="start"> + <vbox id="noPreviousConvInnerBox" class="im-placeholder-innerbox" flex="1"> + <description id="noPreviousConvDesc" + class="im-placeholder-desc">&chat.noPreviousConv.description;</description> + </vbox> + </hbox> + </vbox> + <vbox flex="1" id="logDisplayBrowserBox"> + <browser id="conv-log-browser" is="conversation-browser" type="content" + contextmenu="chatConversationContextMenu" flex="1" + tooltip="imTooltip" + messagemanagergroup="browsers"/> + <html:progress id="log-browserProgress" max="100" hidden="true"/> + <findbar id="log-findbar" browserid="conv-log-browser"/> + </vbox> + </vbox> + <button id="goToConversation" hidden="true" + oncommand="chatHandler.showCurrentConversation();"/> + </vbox> + + </vbox> + </vbox> + <splitter id="contextSplitter" hidden="true" collapse="after"/> + <vbox id="contextPane" hidden="true" width="250" persist="width"> + <chat-conversation-info id="conv-top-info" class="conv-top-info"/> + <vbox id="contextPaneFlexibleBox" flex="1"> + <vbox class="conv-chat" width="150"> + <hbox align="baseline" class="conv-nicklist-header input-container"> + <label class="conv-nicklist-header-label conv-header-label" + control="participantCount" + value="&chat.participants;" + crop="end"/> + <html:input id="participantCount" readonly="readonly" class="plain"/> + </hbox> + <richlistbox id="nicklist" class="conv-nicklist" + flex="1" seltype="multiple" + tooltip="imTooltip" + context="participantListContextMenu" + onclick="chatHandler.onNickClick(event);" + onkeypress="chatHandler.onNicklistKeyPress(event);"/> + </vbox> + <splitter id="logsSplitter" class="conv-chat" collapse="after" orient="vertical"/> + <vbox id="previousConversations" style="min-height: 200px;"> + <label class="conv-logs-header-label conv-header-label" + crop="end" + value="&chat.previousConversations;"/> + <tree id="logTree" flex="1" hidecolumnpicker="true" seltype="single" + context="logTreeContext" onselect="chatHandler.onLogSelect();"> + <treecols> + <treecol id="logCol" + style="flex: 1 auto" + primary="true" + hideheader="true" + crop="center" + ignoreincolumnpicker="true"/> + </treecols> + <treechildren/> + </tree> + </vbox> + </vbox> + </vbox> + </hbox> + </vbox> + </vbox> diff --git a/comm/mail/components/im/content/chat-messenger.js b/comm/mail/components/im/content/chat-messenger.js new file mode 100644 index 0000000000..b3030bf9df --- /dev/null +++ b/comm/mail/components/im/content/chat-messenger.js @@ -0,0 +1,2162 @@ +/* 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/. */ + +/* global MozElements MozXULElement */ +/* import-globals-from ../../../base/content/globalOverlay.js */ + +// This file is loaded in messenger.xhtml. +/* globals MailToolboxCustomizeDone, openIMAccountMgr, + PROTO_TREE_VIEW, statusSelector, ZoomManager, gSpacesToolbar */ + +var { Notifications } = ChromeUtils.importESModule( + "resource:///modules/chatNotifications.sys.mjs" +); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ChatEncryption: "resource:///modules/ChatEncryption.sys.mjs", + OTRUI: "resource:///modules/OTRUI.sys.mjs", +}); + +var gChatSpellChecker; +var gRangeParent; +var gRangeOffset; + +var gBuddyListContextMenu = null; +var gChatBundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" +); + +function openChatContextMenu(popup) { + let conv = chatHandler._getActiveConvView(); + let spellchecker = conv.spellchecker; + let textbox = conv.editor; + + // The context menu uses gChatSpellChecker, so set it here for the duration of the menu. + gChatSpellChecker = spellchecker; + + spellchecker.init(textbox.editor); + spellchecker.initFromEvent(gRangeParent, gRangeOffset); + let onMisspelling = spellchecker.overMisspelling; + document.getElementById("spellCheckSuggestionsSeparator").hidden = + !onMisspelling; + document.getElementById("spellCheckAddToDictionary").hidden = !onMisspelling; + let separator = document.getElementById("spellCheckAddSep"); + separator.hidden = !onMisspelling; + document.getElementById("spellCheckNoSuggestions").hidden = + !onMisspelling || spellchecker.addSuggestionsToMenu(popup, separator, 5); + + let dictMenu = document.getElementById("spellCheckDictionariesMenu"); + let dictSep = document.getElementById("spellCheckLanguageSeparator"); + spellchecker.addDictionaryListToMenu(dictMenu, dictSep); + + document + .getElementById("spellCheckEnable") + .setAttribute("checked", spellchecker.enabled); + document + .getElementById("spellCheckDictionaries") + .setAttribute("hidden", !spellchecker.enabled); + + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_selectAll"); +} + +function clearChatContextMenu(popup) { + let conv = chatHandler._getActiveConvView(); + let spellchecker = conv.spellchecker; + spellchecker.clearDictionaryListFromMenu(); + spellchecker.clearSuggestionsFromMenu(); +} + +function getSelectedPanel() { + for (let element of document.getElementById("conversationsBox").children) { + if (!element.hidden) { + return element; + } + } + return null; +} + +/** + * Hide all the child elements in the conversations box. After hiding all the + * child elements, one element will be from chat conversation, chat log or + * no conversation screen. + */ +function hideConversationsBoxPanels() { + for (let element of document.getElementById("conversationsBox").children) { + element.hidden = true; + } +} + +// This function modifies gChatSpellChecker and updates the UI accordingly. It's +// called when the user clicks on context menu to toggle the spellcheck feature. +function enableInlineSpellCheck(aEnableInlineSpellCheck) { + gChatSpellChecker.enabled = aEnableInlineSpellCheck; + document + .getElementById("spellCheckEnable") + .setAttribute("checked", aEnableInlineSpellCheck); + document + .getElementById("spellCheckDictionaries") + .setAttribute("hidden", !aEnableInlineSpellCheck); +} + +function buddyListContextMenu(aXulMenu) { + // Clear the context menu from OTR related entries. + OTRUI.removeBuddyContextMenu(document); + + this.target = aXulMenu.triggerNode.closest("richlistitem"); + if (!this.target) { + this.shouldDisplay = false; + return; + } + + this.menu = aXulMenu; + let localName = this.target.localName; + this.onContact = + localName == "richlistitem" && + this.target.getAttribute("is") == "chat-contact-richlistitem"; + this.onConv = + localName == "richlistitem" && + this.target.getAttribute("is") == "chat-imconv-richlistitem"; + this.shouldDisplay = this.onContact || this.onConv; + + let hide = !this.onContact; + [ + "context-openconversation", + "context-edit-buddy-separator", + "context-alias", + "context-delete", + ].forEach(function (aId) { + document.getElementById(aId).hidden = hide; + }); + + document.getElementById("context-close-conversation").hidden = !this.onConv; + document.getElementById("context-openconversation").disabled = + !hide && !this.target.canOpenConversation(); + + // Show OTR related context menu items if: + // - The OTR feature is currently enabled. + // - The target's status is not currently offline or unknown. + // - The target can send messages. + if ( + ChatEncryption.otrEnabled && + this.target.contact && + this.target.contact.statusType != Ci.imIStatusInfo.STATUS_UNKNOWN && + this.target.contact.statusType != Ci.imIStatusInfo.STATUS_OFFLINE && + this.target.contact.canSendMessage + ) { + OTRUI.addBuddyContextMenu(this.menu, document, this.target.contact); + } + + const accountBuddy = this._getAccountBuddy(); + const canVerifyBuddy = accountBuddy?.canVerifyIdentity; + const verifyMenuItem = document.getElementById("context-verifyBuddy"); + verifyMenuItem.hidden = !canVerifyBuddy; + if (canVerifyBuddy) { + const identityVerified = accountBuddy.identityVerified; + verifyMenuItem.disabled = identityVerified; + document.l10n.setAttributes( + verifyMenuItem, + identityVerified ? "chat-identity-verified" : "chat-verify-identity" + ); + } +} + +buddyListContextMenu.prototype = { + /** + * Get the prplIAccountBuddy instance that is related to the current context. + * + * @returns {prplIAccountBuddy?} + */ + _getAccountBuddy() { + if (this.onConv && this.target.conv?.buddy) { + return this.target.conv.buddy; + } + return this.target.contact?.preferredBuddy?.preferredAccountBuddy; + }, + openConversation() { + if (this.onContact || this.onConv) { + this.target.openConversation(); + } + }, + closeConversation() { + if (this.onConv) { + this.target.closeConversation(); + } + }, + alias() { + if (this.onContact) { + this.target.startAliasing(); + } + }, + delete() { + if (!this.onContact) { + return; + } + + let buddy = this.target.contact.preferredBuddy; + let displayName = this.target.displayName; + let promptTitle = gChatBundle.formatStringFromName( + "buddy.deletePrompt.title", + [displayName] + ); + let userName = buddy.userName; + if (displayName != userName) { + displayName = gChatBundle.formatStringFromName( + "buddy.deletePrompt.displayName", + [displayName, userName] + ); + } + let proto = buddy.protocol.name; // FIXME build a list + let promptMessage = gChatBundle.formatStringFromName( + "buddy.deletePrompt.message", + [displayName, proto] + ); + let deleteButton = gChatBundle.GetStringFromName( + "buddy.deletePrompt.button" + ); + let prompts = Services.prompt; + let flags = + prompts.BUTTON_TITLE_IS_STRING * prompts.BUTTON_POS_0 + + prompts.BUTTON_TITLE_CANCEL * prompts.BUTTON_POS_1 + + prompts.BUTTON_POS_1_DEFAULT; + if ( + prompts.confirmEx( + window, + promptTitle, + promptMessage, + flags, + deleteButton, + null, + null, + null, + {} + ) + ) { + return; + } + + this.target.deleteContact(); + }, + /** + * Command event handler to verify the identity of the buddy the context menu + * is currently opened for. + */ + verifyIdentity() { + const accountBuddy = this._getAccountBuddy(); + if (!accountBuddy) { + return; + } + ChatEncryption.verifyIdentity(window, accountBuddy); + }, +}; + +var gChatTab = null; + +var chatTabType = { + name: "chat", + panelId: "chatTabPanel", + hasBeenOpened: false, + modes: { + chat: { + type: "chat", + }, + }, + + tabMonitor: { + monitorName: "chattab", + + // Unused, but needed functions + onTabTitleChanged() {}, + onTabOpened(aTab) {}, + onTabPersist() {}, + onTabRestored() {}, + + onTabClosing() { + chatHandler._onTabDeactivated(true); + }, + onTabSwitched(aNewTab, aOldTab) { + // aNewTab == chat is handled earlier by showTab() below. + if (aOldTab?.mode.name == "chat") { + chatHandler._onTabDeactivated(true); + } + }, + }, + + _handleArgs(aArgs) { + if ( + !aArgs || + !("convType" in aArgs) || + (aArgs.convType != "log" && aArgs.convType != "focus") + ) { + return; + } + + if (aArgs.convType == "focus") { + chatHandler.focusConversation(aArgs.conv); + return; + } + + let item = document.getElementById("searchResultConv"); + item.log = aArgs.conv; + if (aArgs.searchTerm) { + item.searchTerm = aArgs.searchTerm; + } else { + delete item.searchTerm; + } + item.hidden = false; + if (item.getAttribute("selected")) { + chatHandler.onListItemSelected(); + } else { + document.getElementById("contactlistbox").selectedItem = item; + } + }, + _onWindowActivated() { + let tabmail = document.getElementById("tabmail"); + if (tabmail.currentTabInfo.mode.name == "chat") { + chatHandler._onTabActivated(); + } + }, + _onWindowDeactivated() { + let tabmail = document.getElementById("tabmail"); + if (tabmail.currentTabInfo.mode.name == "chat") { + chatHandler._onTabDeactivated(false); + } + }, + openTab(aTab, aArgs) { + aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/chat.svg"); + if (!this.hasBeenOpened) { + if (chatHandler.ChatCore && chatHandler.ChatCore.initialized) { + let convs = IMServices.conversations.getUIConversations(); + if (convs.length != 0) { + convs.sort((a, b) => + a.title.toLowerCase().localeCompare(b.title.toLowerCase()) + ); + for (let conv of convs) { + chatHandler._addConversation(conv); + } + } + } + this.hasBeenOpened = true; + } + + // The tab monitor will inform us when a different tab is selected. + let tabmail = document.getElementById("tabmail"); + tabmail.registerTabMonitor(this.tabMonitor); + window.addEventListener("deactivate", chatTabType._onWindowDeactivated); + window.addEventListener("activate", chatTabType._onWindowActivated); + + gChatTab = aTab; + this._handleArgs(aArgs); + this.showTab(aTab); + chatHandler.updateTitle(); + }, + shouldSwitchTo(aArgs) { + if (!gChatTab) { + return -1; + } + this._handleArgs(aArgs); + return document.getElementById("tabmail").tabInfo.indexOf(gChatTab); + }, + showTab(aTab) { + gChatTab = aTab; + chatHandler._onTabActivated(); + // The next call may change the selected conversation, but that + // will be handled by the selected mutation observer of the chat-imconv-richlistitem. + chatHandler._updateSelectedConversation(); + chatHandler._updateFocus(); + }, + closeTab(aTab) { + gChatTab = null; + let tabmail = document.getElementById("tabmail"); + tabmail.unregisterTabMonitor(this.tabMonitor); + window.removeEventListener("deactivate", chatTabType._onWindowDeactivated); + window.removeEventListener("activate", chatTabType._onWindowActivated); + }, + persistTab(aTab) { + return {}; + }, + restoreTab(aTabmail, aPersistedState) { + aTabmail.openTab("chat", {}); + }, + + supportsCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + return true; + default: + return false; + } + }, + isCommandEnabled(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + return !!this.getBrowser(); + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + return !!this.getFindbar(); + default: + return false; + } + }, + doCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + ZoomManager.reduce(); + break; + case "cmd_fullZoomEnlarge": + ZoomManager.enlarge(); + break; + case "cmd_fullZoomReset": + ZoomManager.reset(); + break; + case "cmd_fullZoomToggle": + ZoomManager.toggleZoom(); + break; + case "cmd_find": + this.getFindbar().onFindCommand(); + break; + case "cmd_findAgain": + this.getFindbar().onFindAgainCommand(false); + break; + case "cmd_findPrevious": + this.getFindbar().onFindAgainCommand(true); + break; + } + }, + onEvent(aEvent, aTab) {}, + getBrowser(aTab) { + let panel = getSelectedPanel(); + if (panel == document.getElementById("logDisplay")) { + if (!document.getElementById("logDisplayBrowserBox").hidden) { + return document.getElementById("conv-log-browser"); + } + } else if (panel && panel.localName == "chat-conversation") { + return panel.convBrowser; + } + return null; + }, + getFindbar(aTab) { + let panel = getSelectedPanel(); + if (panel == document.getElementById("logDisplay")) { + if (!document.getElementById("logDisplayBrowserBox").hidden) { + return document.getElementById("log-findbar"); + } + } else if (panel && panel.localName == "chat-conversation") { + return panel.findbar; + } + return null; + }, + + saveTabState(aTab) {}, +}; + +var chatHandler = { + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document.getElementById("chat-notification-top").prepend(element); + }); + } + return this._notificationBox; + }, + + _addConversation(aConv) { + let list = document.getElementById("contactlistbox"); + let convs = document.getElementById("conversationsGroup"); + let selectedItem = list.selectedItem; + let shouldSelect = + gChatTab && + gChatTab.tabNode.selected && + (!selectedItem || + (selectedItem == convs && + convs.nextElementSibling.localName != "richlistitem" && + convs.nextSibling.getAttribute("is") != "chat-imconv-richlistitem")); + let elt = convs.addContact(aConv, "imconv"); + if (shouldSelect) { + list.selectedItem = elt; + } + + if (aConv.isChat || !aConv.buddy) { + return; + } + + let contact = aConv.buddy.buddy.contact; + elt.imContact = contact; + let groupName = (contact.online ? "on" : "off") + "linecontactsGroup"; + let item = document.getElementById(groupName).removeContact(contact); + if (list.selectedItem == item) { + list.selectedItem = elt; + } + }, + + _hasConversationForContact(aContact) { + let convs = document.getElementById("conversationsGroup").contacts; + return convs.some( + aConversation => + aConversation.hasOwnProperty("imContact") && + aConversation.imContact.id == aContact.id + ); + }, + + _chatButtonUpdatePending: false, + updateChatButtonState() { + if (this._chatButtonUpdatePending) { + return; + } + this._chatButtonUpdatePending = true; + Services.tm.mainThread.dispatch( + this._updateChatButtonState.bind(this), + Ci.nsIEventTarget.DISPATCH_NORMAL + ); + }, + // This is the unread count that was part of the latest + // unread-im-count-changed notification. + _notifiedUnreadCount: 0, + _updateChatButtonState() { + delete this._chatButtonUpdatePending; + + let [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount] = + this.countUnreadMessages(); + let unreadCount = unreadTargetedCount + unreadOTRNotificationCount; + + let chatButton = document.getElementById("button-chat"); + if (chatButton) { + chatButton.badgeCount = unreadCount; + if (unreadTotalCount || unreadOTRNotificationCount) { + chatButton.setAttribute("unreadMessages", "true"); + } else { + chatButton.removeAttribute("unreadMessages"); + } + } + + let spacesChatButton = document.getElementById("chatButton"); + if (spacesChatButton) { + spacesChatButton.classList.toggle("has-badge", unreadCount); + document.l10n.setAttributes( + spacesChatButton.querySelector(".spaces-badge-container"), + "chat-button-unread-messages", + { + count: unreadCount, + } + ); + } + let spacesPopupButtonChat = document.getElementById( + "spacesPopupButtonChat" + ); + if (spacesPopupButtonChat) { + spacesPopupButtonChat.classList.toggle("has-badge", unreadCount); + gSpacesToolbar.updatePinnedBadgeState(); + } + + let unifiedToolbarButtons = document.querySelectorAll( + "#unifiedToolbarContent .chat .unified-toolbar-button" + ); + for (const button of unifiedToolbarButtons) { + if (unreadCount) { + button.badge = unreadCount; + continue; + } + button.badge = null; + } + + if (unreadCount != this._notifiedUnreadCount) { + let unreadInt = Cc["@mozilla.org/supports-PRInt32;1"].createInstance( + Ci.nsISupportsPRInt32 + ); + unreadInt.data = unreadCount; + Services.obs.notifyObservers( + unreadInt, + "unread-im-count-changed", + unreadCount + ); + this._notifiedUnreadCount = unreadCount; + } + }, + + countUnreadMessages() { + let convs = IMServices.conversations.getUIConversations(); + let unreadTargetedCount = 0; + let unreadTotalCount = 0; + let unreadOTRNotificationCount = 0; + for (let conv of convs) { + unreadTargetedCount += conv.unreadTargetedMessageCount; + unreadTotalCount += conv.unreadIncomingMessageCount; + unreadOTRNotificationCount += conv.unreadOTRNotificationCount; + } + return [unreadTargetedCount, unreadTotalCount, unreadOTRNotificationCount]; + }, + + updateTitle() { + if (!gChatTab) { + return; + } + + let title = gChatBundle.GetStringFromName("chatTabTitle"); + let [unreadTargetedCount] = this.countUnreadMessages(); + if (unreadTargetedCount) { + title += " (" + unreadTargetedCount + ")"; + } else { + let selectedItem = document.getElementById("contactlistbox").selectedItem; + if ( + selectedItem && + selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-imconv-richlistitem" && + !selectedItem.hidden + ) { + title += " - " + selectedItem.getAttribute("displayname"); + } + } + gChatTab.title = title; + document.getElementById("tabmail").setTabTitle(gChatTab); + }, + + onConvResize() { + let panel = getSelectedPanel(); + if (panel && panel.localName == "chat-conversation") { + panel.onConvResize(); + } + }, + + setStatusMenupopupCommand(aEvent) { + let target = aEvent.target; + if (target.getAttribute("id") == "imStatusShowAccounts") { + openIMAccountMgr(); + return; + } + + let status = target.getAttribute("status"); + if (!status) { + // Can status really be null? Maybe because of an add-on... + return; + } + + let us = IMServices.core.globalUserStatus; + us.setStatus(Status.toFlag(status), us.statusText); + }, + + _pendingLogBrowserLoad: false, + _showLogPanel() { + hideConversationsBoxPanels(); + document.getElementById("logDisplay").hidden = false; + document.getElementById("logDisplayBrowserBox").hidden = false; + document.getElementById("noPreviousConvScreen").hidden = true; + }, + _showLog(aConversation, aSearchTerm) { + if (!aConversation) { + return; + } + this._showLogPanel(); + let browser = document.getElementById("conv-log-browser"); + browser._convScrollEnabled = false; + if (this._pendingLogBrowserLoad) { + browser._conv = aConversation; + return; + } + browser.init(aConversation); + this._pendingLogBrowserLoad = true; + if (aSearchTerm) { + this._pendingSearchTerm = aSearchTerm; + } + Services.obs.addObserver(this, "conversation-loaded"); + + // Conversation title may not be set yet if this is a search result. + let cti = document.getElementById("conv-top-info"); + cti.setAttribute("displayName", aConversation.title); + + // Find and display the contact for this log. + for (let account of IMServices.accounts.getAccounts()) { + if ( + account.normalizedName == aConversation.account.normalizedName && + account.protocol.normalizedName == aConversation.account.protocol.name + ) { + if (aConversation.isChat) { + // Display information for MUCs. + cti.setAsChat("", false, false); + cti.setProtocol(account.protocol); + return; + } + // Display information for contacts. + let accountBuddy = IMServices.contacts.getAccountBuddyByNameAndAccount( + aConversation.normalizedName, + account + ); + if (!accountBuddy) { + return; + } + let contact = accountBuddy.buddy.contact; + if (!contact) { + return; + } + if (this.observedContact && this.observedContact.id == contact.id) { + return; + } + this.showContactInfo(contact); + this.observedContact = contact; + return; + } + } + }, + + /** + * Display a list of logs into a tree, and optionally handle a default selection. + * + * @param {imILog} aLogs - An array of imILog. + * @param {boolean|imILog} aShouldSelect - Either a boolean (true means select the first log + * of the list, false or undefined means don't mess with the selection) or a log + * item that needs to be selected. + * @returns {boolean} True if there's at least one log in the list, false if empty. + */ + _showLogList(aLogs, aShouldSelect) { + let logTree = document.getElementById("logTree"); + let treeView = (this._treeView = new chatLogTreeView(logTree, aLogs)); + if (!treeView._rowMap.length) { + return false; + } + if (!aShouldSelect) { + return true; + } + if (aShouldSelect === true) { + // Select the first line. + let selectIndex = 0; + if (treeView.isContainer(selectIndex)) { + // If the first line is a group, open it and select the + // next line instead. + treeView.toggleOpenState(selectIndex++); + } + logTree.view.selection.select(selectIndex); + return true; + } + // Find the aShouldSelect log and select it. + let logTime = aShouldSelect.time; + for (let index = 0; index < treeView._rowMap.length; ++index) { + if ( + !treeView.isContainer(index) && + treeView._rowMap[index].log.time == logTime + ) { + logTree.view.selection.select(index); + logTree.ensureRowIsVisible(index); + return true; + } + if (!treeView._rowMap[index].children.some(i => i.log.time == logTime)) { + continue; + } + treeView.toggleOpenState(index); + ++index; + while ( + index < treeView._rowMap.length && + treeView._rowMap[index].log.time != logTime + ) { + ++index; + } + if (treeView._rowMap[index].log.time == logTime) { + logTree.view.selection.select(index); + logTree.ensureRowIsVisible(index); + } + return true; + } + throw new Error( + "Couldn't find the log to select among the set of logs passed." + ); + }, + + onLogSelect() { + let selection = this._treeView.selection; + let currentIndex = selection.currentIndex; + // The current (focused) row may not be actually selected... + if (!selection.isSelected(currentIndex)) { + return; + } + + let log = this._treeView._rowMap[currentIndex].log; + if (!log) { + return; + } + + let list = document.getElementById("contactlistbox"); + if (list.selectedItem.getAttribute("id") != "searchResultConv") { + document.getElementById("goToConversation").hidden = false; + } + log.getConversation().then(aLogConv => { + this._showLog(aLogConv); + }); + }, + + _contactObserver: { + observe(aSubject, aTopic, aData) { + if ( + aTopic == "contact-status-changed" || + aTopic == "contact-display-name-changed" || + aTopic == "contact-icon-changed" + ) { + chatHandler.showContactInfo(aSubject); + } + }, + }, + _observedContact: null, + get observedContact() { + return this._observedContact; + }, + set observedContact(aContact) { + if (aContact == this._observedContact) { + return; + } + if (this._observedContact) { + this._observedContact.removeObserver(this._contactObserver); + delete this._observedContact; + } + this._observedContact = aContact; + if (aContact) { + aContact.addObserver(this._contactObserver); + } + }, + /** + * Callback for the button that closes the log view. Resets the shared UI + * elements to match the state of the active conversation. Hides the log + * browser. + */ + showCurrentConversation() { + let item = document.getElementById("contactlistbox").selectedItem; + if (!item) { + return; + } + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" + ) { + hideConversationsBoxPanels(); + item.convView.hidden = false; + item.convView.querySelector(".conv-bottom").setAttribute("height", 90); + document.getElementById("logTree").view.selection.clearSelection(); + if (item.conv.isChat) { + item.convView.updateTopic(); + } + ChatEncryption.updateEncryptionButton(document, item.conv); + item.convView.focus(); + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-contact-richlistitem" + ) { + item.openConversation(); + } + }, + focusConversation(aUIConv) { + let conv = + document.getElementById("conversationsGroup").contactsById[aUIConv.id]; + document.getElementById("contactlistbox").selectedItem = conv; + if (conv.convView) { + conv.convView.focus(); + } + }, + showContactInfo(aContact) { + let cti = document.getElementById("conv-top-info"); + cti.setUserIcon(aContact.buddyIconFilename, true); + cti.setAttribute("displayName", aContact.displayName); + cti.setProtocol(aContact.preferredBuddy.protocol); + + let statusText = aContact.statusText; + let statusType = aContact.statusType; + cti.setStatus( + Status.toAttribute(statusType), + Status.toLabel(statusType, statusText) + ); + + let button = document.getElementById("goToConversation"); + button.label = gChatBundle.formatStringFromName( + "startAConversationWith.button", + [aContact.displayName] + ); + button.disabled = !aContact.canSendMessage; + }, + _hideContextPane(aHide) { + document.getElementById("contextSplitter").hidden = aHide; + document.getElementById("contextPane").hidden = aHide; + }, + onListItemClick(aEvent) { + // We only care about single clicks of the left button. + if (aEvent.button != 0 || aEvent.detail != 1) { + return; + } + let item = document.getElementById("contactlistbox").selectedItem; + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" && + item.convView + ) { + item.convView.focus(); + } + }, + onListItemSelected() { + let contactlistbox = document.getElementById("contactlistbox"); + let item = contactlistbox.selectedItem; + if ( + !item || + item.hidden || + (item.localName == "richlistitem" && + item.getAttribute("is") == "chat-group-richlistitem") + ) { + this._hideContextPane(true); + hideConversationsBoxPanels(); + document.getElementById("noConvScreen").hidden = false; + this.updateTitle(); + this.observedContact = null; + ChatEncryption.hideEncryptionButton(document); + return; + } + + this._hideContextPane(false); + + if (item.getAttribute("id") == "searchResultConv") { + document.getElementById("goToConversation").hidden = true; + document.getElementById("contextPane").removeAttribute("chat"); + let cti = document.getElementById("conv-top-info"); + cti.clear(); + this.observedContact = null; + // Always hide encryption options for search conv + ChatEncryption.hideEncryptionButton(document); + + let path = "logs/" + item.log.path; + path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + ...path.split("/") + ); + IMServices.logs.getLogFromFile(path, true).then(aLog => { + IMServices.logs.getSimilarLogs(aLog).then(aSimilarLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + this._pendingSearchTerm = item.searchTerm || undefined; + this._showLogList(aSimilarLogs, aLog); + }); + }); + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" + ) { + if (!item.convView) { + let convBox = document.getElementById("conversationsBox"); + let conv = document.createXULElement("chat-conversation"); + convBox.appendChild(conv); + conv.conv = item.conv; + conv.tab = item; + conv.convBrowser.setAttribute("context", "chatConversationContextMenu"); + conv.setAttribute("tooltip", "imTooltip"); + item.convView = conv; + document.getElementById("contextSplitter").hidden = false; + document.getElementById("contextPane").hidden = false; + conv.editor.addEventListener("contextmenu", e => { + // Stash away the original event's parent and range for later use. + gRangeParent = e.rangeParent; + gRangeOffset = e.rangeOffset; + let popup = document.getElementById("chatContextMenu"); + popup.openPopupAtScreen(e.screenX, e.screenY, true); + e.preventDefault(); + }); + + // Set "mail editor mask" so changing the language doesn't + // affect the global preference and multiple chats can have + // individual languages. + conv.editor.editor.flags |= Ci.nsIEditor.eEditorMailMask; + + let preferredLanguages = + Services.prefs.getStringPref("spellchecker.dictionary")?.split(",") ?? + []; + let initialLanguage = ""; + if (preferredLanguages.length === 1) { + initialLanguage = preferredLanguages[0]; + } + // Initialise language to the default. + conv.editor.setAttribute("lang", initialLanguage); + + // Attach listener so we hear about language changes. + document.addEventListener("spellcheck-changed", e => { + let conv = chatHandler._getActiveConvView(); + let activeLanguages = e.detail.dictionaries ?? []; + let languageToSet = ""; + if (activeLanguages.length === 1) { + languageToSet = activeLanguages[0]; + } + conv.editor.setAttribute("lang", languageToSet); + }); + } else { + item.convView.onConvResize(); + } + + hideConversationsBoxPanels(); + item.convView.hidden = false; + item.convView.querySelector(".conv-bottom").setAttribute("height", 90); + item.convView.updateConvStatus(); + item.update(); + + ChatEncryption.updateEncryptionButton(document, item.conv); + + IMServices.logs.getLogsForConversation(item.conv).then(aLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + this._showLogList(aLogs); + }); + + document + .querySelectorAll("#contextPaneFlexibleBox .conv-chat") + .forEach(e => { + e.setAttribute("hidden", !item.conv.isChat); + }); + if (item.conv.isChat) { + item.convView.showParticipants(); + } + + let button = document.getElementById("goToConversation"); + button.label = gChatBundle.GetStringFromName( + "goBackToCurrentConversation.button" + ); + button.disabled = false; + this.observedContact = null; + } else if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-contact-richlistitem" + ) { + ChatEncryption.hideEncryptionButton(document); + let contact = item.contact; + if ( + this.observedContact && + contact && + this.observedContact.id == contact.id + ) { + return; // onselect has just been fired again because a status + // change caused the chat-contact-richlistitem to move. + // Return early to avoid flickering and changing the selected log. + } + + this.showContactInfo(contact); + this.observedContact = contact; + + document + .querySelectorAll("#contextPaneFlexibleBox .conv-chat") + .forEach(e => { + e.setAttribute("hidden", "true"); + }); + + IMServices.logs.getLogsForContact(contact).then(aLogs => { + if (contactlistbox.selectedItem != item) { + return; + } + if (!this._showLogList(aLogs, true)) { + hideConversationsBoxPanels(); + document.getElementById("logDisplay").hidden = false; + document.getElementById("logDisplayBrowserBox").hidden = false; + document.getElementById("noPreviousConvScreen").hidden = true; + } + }); + } + this.updateTitle(); + }, + + onNickClick(aEvent) { + // Open a private conversation only for a middle or double click. + if (aEvent.button != 1 && (aEvent.button != 0 || aEvent.detail != 2)) { + return; + } + + let conv = document.getElementById("contactlistbox").selectedItem.conv; + let nick = aEvent.target.chatBuddy.name; + let name = conv.target.getNormalizedChatBuddyName(nick); + try { + let newconv = conv.account.createConversation(name); + this.focusConversation(newconv); + } catch (e) {} + }, + + onNicklistKeyPress(aEvent) { + if (aEvent.keyCode != aEvent.DOM_VK_RETURN) { + return; + } + + let listbox = aEvent.target; + if (listbox.selectedCount == 0) { + return; + } + + let conv = document.getElementById("contactlistbox").selectedItem.conv; + let newconv; + for (let i = 0; i < listbox.selectedCount; ++i) { + let nick = listbox.getSelectedItem(i).chatBuddy.name; + let name = conv.target.getNormalizedChatBuddyName(nick); + try { + newconv = conv.account.createConversation(name); + } catch (e) {} + } + // Only focus last of the opened conversations. + if (newconv) { + this.focusConversation(newconv); + } + }, + + addBuddy() { + window.openDialog( + "chrome://messenger/content/chat/addbuddy.xhtml", + "", + "chrome,modal,titlebar,centerscreen" + ); + }, + + joinChat() { + window.openDialog( + "chrome://messenger/content/chat/joinchat.xhtml", + "", + "chrome,modal,titlebar,centerscreen" + ); + }, + + _colorCache: {}, + // Duplicated code from chat-conversation.js :-( + _computeColor(aName) { + if (Object.prototype.hasOwnProperty.call(this._colorCache, aName)) { + return this._colorCache[aName]; + } + + // Compute the color based on the nick + var nick = aName.match(/[a-zA-Z0-9]+/); + nick = nick ? nick[0].toLowerCase() : (nick = aName); + // We compute a hue value (between 0 and 359) based on the + // characters of the nick. + // The first character weights kInitialWeight, each following + // character weights kWeightReductionPerChar * the weight of the + // previous character. + const kInitialWeight = 10; // 10 = 360 hue values / 36 possible characters. + const kWeightReductionPerChar = 0.52; // arbitrary value + var weight = kInitialWeight; + var res = 0; + for (var i = 0; i < nick.length; ++i) { + var char = nick.charCodeAt(i) - 47; + if (char > 10) { + char -= 39; + } + // now char contains a value between 1 and 36 + res += char * weight; + weight *= kWeightReductionPerChar; + } + return (this._colorCache[aName] = Math.round(res) % 360); + }, + + _placeHolderButtonId: "", + _updateNoConvPlaceHolder() { + let connected = false; + let hasAccount = false; + let canJoinChat = false; + for (let account of IMServices.accounts.getAccounts()) { + hasAccount = true; + if (account.connected) { + connected = true; + if (account.canJoinChat) { + canJoinChat = true; + break; + } + } + } + document.getElementById("noConvInnerBox").hidden = !connected; + document.getElementById("noAccountInnerBox").hidden = hasAccount; + document.getElementById("noConnectedAccountInnerBox").hidden = + connected || !hasAccount; + if (connected) { + delete this._placeHolderButtonId; + } else { + this._placeHolderButtonId = hasAccount + ? "openIMAccountManagerButton" + : "openIMAccountWizardButton"; + } + + for (let id of [ + "statusTypeIcon", + "statusMessage", + "button-chat-accounts", + ]) { + let elt = document.getElementById(id); + if (elt) { + elt.disabled = !hasAccount; + } + } + + let chatStatusCmd = document.getElementById("cmd_chatStatus"); + if (chatStatusCmd) { + if (hasAccount) { + chatStatusCmd.removeAttribute("disabled"); + } else { + chatStatusCmd.setAttribute("disabled", true); + } + } + + let addBuddyButton = document.getElementById("button-add-buddy"); + if (addBuddyButton) { + addBuddyButton.disabled = !connected; + } + + let addBuddyCmd = document.getElementById("cmd_addChatBuddy"); + if (addBuddyCmd) { + if (connected) { + addBuddyCmd.removeAttribute("disabled"); + } else { + addBuddyCmd.setAttribute("disabled", true); + } + } + + let joinChatButton = document.getElementById("button-join-chat"); + if (joinChatButton) { + joinChatButton.disabled = !canJoinChat; + } + + let joinChatCmd = document.getElementById("cmd_joinChat"); + if (joinChatCmd) { + if (canJoinChat) { + joinChatCmd.removeAttribute("disabled"); + } else { + joinChatCmd.setAttribute("disabled", true); + } + } + + let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"]; + let contactlist = document.getElementById("contactlistbox"); + if ( + !hasAccount || + (!connected && + groupIds.every( + id => document.getElementById(id + "Group").contacts.length + )) + ) { + contactlist.disabled = true; + } else { + contactlist.disabled = false; + this._updateSelectedConversation(); + } + }, + _updateSelectedConversation() { + let list = document.getElementById("contactlistbox"); + // We can't select anything if there's no account. + if (list.disabled) { + return; + } + + // If the selection is already a conversation with unread messages, keep it. + let selectedItem = list.selectedItem; + if ( + selectedItem && + selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-imconv-richlistitem" && + selectedItem.directedUnreadCount + ) { + selectedItem.update(); + return; + } + + let firstConv; + let convs = document.getElementById("conversationsGroup"); + let conv = convs.nextElementSibling; + while (conv.id != "searchResultConv") { + if (!firstConv) { + firstConv = conv; + } + // If there is a conversation with unread messages, select it. + if (conv.directedUnreadCount) { + list.selectedItem = conv; + return; + } + conv = conv.nextElementSibling; + } + + // No unread messages, select the first conversation, but only if + // the existing selection is uninteresting (a section header). + if (firstConv) { + if ( + !selectedItem || + (selectedItem.localName == "richlistitem" && + selectedItem.getAttribute("is") == "chat-group-richlistitem") + ) { + list.selectedItem = firstConv; + } + return; + } + + // No conversation, if a visible item is selected, keep it. + if (selectedItem && !selectedItem.collapsed) { + return; + } + + // Select the first visible group header. + let groupIds = ["conversations", "onlinecontacts", "offlinecontacts"]; + for (let id of groupIds) { + let item = document.getElementById(id + "Group"); + if (item.collapsed) { + continue; + } + list.selectedItem = item; + return; + } + }, + _updateFocus() { + let focusId = this._placeHolderButtonId || "contactlistbox"; + document.getElementById(focusId).focus(); + }, + _getActiveConvView() { + let list = document.getElementById("contactlistbox"); + if (list.disabled) { + return null; + } + let selectedItem = list.selectedItem; + if ( + !selectedItem || + (selectedItem.localName != "richlistitem" && + selectedItem.getAttribute("is") != "chat-imconv-richlistitem") + ) { + return null; + } + let convView = selectedItem.convView; + if (!convView || !convView.loaded) { + return null; + } + return convView; + }, + _onTabActivated() { + let convView = chatHandler._getActiveConvView(); + if (convView) { + convView.switchingToPanel(); + } + }, + _onTabDeactivated(aHidden) { + let convView = chatHandler._getActiveConvView(); + if (convView) { + convView.switchingAwayFromPanel(aHidden); + } + }, + observe(aSubject, aTopic, aData) { + if (aTopic == "chat-core-initialized") { + this.initAfterChatCore(); + return; + } + + if (aTopic == "conversation-loaded") { + let browser = document.getElementById("conv-log-browser"); + if (aSubject != browser) { + return; + } + + for (let msg of browser._conv.getMessages()) { + if (!msg.system) { + msg.color = + "color: hsl(" + this._computeColor(msg.who) + ", 100%, 40%);"; + } + browser.appendMessage(msg); + } + + if (this._pendingSearchTerm) { + let findbar = document.getElementById("log-findbar"); + findbar._findField.value = this._pendingSearchTerm; + findbar.open(); + browser.focus(); + delete this._pendingSearchTerm; + let eventListener = function () { + findbar.onFindAgainCommand(); + if (findbar._findFailedString && browser._messageDisplayPending) { + return; + } + // Search result found or all messages added, we're done. + browser.removeEventListener("MessagesDisplayed", eventListener); + }; + browser.addEventListener("MessagesDisplayed", eventListener); + } + this._pendingLogBrowserLoad = false; + Services.obs.removeObserver(this, "conversation-loaded"); + return; + } + + if ( + aTopic == "account-connected" || + aTopic == "account-disconnected" || + aTopic == "account-added" || + aTopic == "account-removed" + ) { + this._updateNoConvPlaceHolder(); + return; + } + + if (aTopic == "contact-signed-on") { + if (!this._hasConversationForContact(aSubject)) { + document.getElementById("onlinecontactsGroup").addContact(aSubject); + document.getElementById("offlinecontactsGroup").removeContact(aSubject); + } + return; + } + if (aTopic == "contact-signed-off") { + if (!this._hasConversationForContact(aSubject)) { + document.getElementById("offlinecontactsGroup").addContact(aSubject); + document.getElementById("onlinecontactsGroup").removeContact(aSubject); + } + return; + } + if (aTopic == "contact-added") { + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).addContact(aSubject); + return; + } + if (aTopic == "contact-removed") { + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).removeContact(aSubject); + return; + } + if (aTopic == "contact-no-longer-dummy") { + let oldId = parseInt(aData); + let groupName = (aSubject.online ? "on" : "off") + "linecontactsGroup"; + let group = document.getElementById(groupName); + if (group.contactsById.hasOwnProperty(oldId)) { + let contact = group.contactsById[oldId]; + delete group.contactsById[oldId]; + group.contactsById[contact.contact.id] = contact; + } + return; + } + if (aTopic == "new-text") { + this.updateChatButtonState(); + return; + } + if (aTopic == "new-ui-conversation") { + if (chatTabType.hasBeenOpened) { + chatHandler._addConversation(aSubject); + } + return; + } + if (aTopic == "ui-conversation-closed") { + this.updateChatButtonState(); + if (!chatTabType.hasBeenOpened) { + return; + } + let conv = document + .getElementById("conversationsGroup") + .removeContact(aSubject); + if (conv.imContact) { + let contact = conv.imContact; + let groupName = (contact.online ? "on" : "off") + "linecontactsGroup"; + document.getElementById(groupName).addContact(contact); + } + return; + } + + if (aTopic == "buddy-authorization-request") { + aSubject.QueryInterface(Ci.prplIBuddyRequest); + let authLabel = gChatBundle.formatStringFromName( + "buddy.authRequest.label", + [aSubject.userName] + ); + let value = + "buddy-auth-request-" + aSubject.account.id + aSubject.userName; + let acceptButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.authRequest.allow.accesskey" + ), + label: gChatBundle.GetStringFromName("buddy.authRequest.allow.label"), + callback() { + aSubject.grant(); + }, + }; + let denyButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.authRequest.deny.accesskey" + ), + label: gChatBundle.GetStringFromName("buddy.authRequest.deny.label"), + callback() { + aSubject.deny(); + }, + }; + let box = this.msgNotificationBar; + let notification = box.appendNotification( + value, + { + label: authLabel, + priority: box.PRIORITY_INFO_HIGH, + }, + [acceptButton, denyButton] + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "buddy-authorization-request-canceled") { + aSubject.QueryInterface(Ci.prplIBuddyRequest); + let value = + "buddy-auth-request-" + aSubject.account.id + aSubject.userName; + let box = this.msgNotificationBar; + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + return; + } + if (aTopic == "buddy-verification-request") { + aSubject.QueryInterface(Ci.imIIncomingSessionVerification); + let barLabel = gChatBundle.formatStringFromName( + "buddy.verificationRequest.label", + [aSubject.subject] + ); + let value = + "buddy-verification-request-" + + aSubject.account.id + + "-" + + aSubject.subject; + let acceptButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.verificationRequest.allow.accesskey" + ), + label: gChatBundle.GetStringFromName( + "buddy.verificationRequest.allow.label" + ), + callback() { + aSubject + .verify() + .then(() => { + window.openDialog( + "chrome://messenger/content/chat/verify.xhtml", + "", + "chrome,modal,titlebar,centerscreen", + aSubject + ); + }) + .catch(error => { + aSubject.account.ERROR(error); + aSubject.cancel(); + }); + }, + }; + let denyButton = { + accessKey: gChatBundle.GetStringFromName( + "buddy.verificationRequest.deny.accesskey" + ), + label: gChatBundle.GetStringFromName( + "buddy.verificationRequest.deny.label" + ), + callback() { + aSubject.cancel(); + }, + }; + let box = this.msgNotificationBar; + let notification = box.appendNotification( + value, + { + label: barLabel, + priority: box.PRIORITY_INFO_HIGH, + }, + [acceptButton, denyButton] + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "buddy-verification-request-canceled") { + aSubject.QueryInterface(Ci.imIIncomingSessionVerification); + let value = + "buddy-verification-request-" + + aSubject.account.id + + "-" + + aSubject.subject; + let box = this.msgNotificationBar; + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + return; + } + if (aTopic == "conv-authorization-request") { + aSubject.QueryInterface(Ci.prplIChatRequest); + let value = + "conv-auth-request-" + aSubject.account.id + aSubject.conversationName; + let buttons = [ + { + "l10n-id": "chat-conv-invite-accept", + callback() { + aSubject.grant(); + }, + }, + ]; + if (aSubject.canDeny) { + buttons.push({ + "l10n-id": "chat-conv-invite-deny", + callback() { + aSubject.deny(); + }, + }); + } + let box = this.msgNotificationBar; + // Remove the notification when the request is cancelled. + aSubject.completePromise.catch(() => { + let notification = box.getNotificationWithValue(value); + if (notification) { + notification.close(); + } + }); + let notification = box.appendNotification( + value, + { + label: "", + priority: box.PRIORITY_INFO_HIGH, + }, + buttons + ); + document.l10n.setAttributes( + notification.messageText, + "chat-conv-invite-label", + { + conversation: aSubject.conversationName, + } + ); + notification.removeAttribute("dismissable"); + if (!gChatTab) { + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("chat", { background: true }); + } + return; + } + if (aTopic == "conversation-update-type") { + // Find conversation in conversation list. + let contactlistbox = document.getElementById("contactlistbox"); + let convs = document.getElementById("conversationsGroup"); + let convItem = convs.nextElementSibling; + while ( + convItem.conv.target.id !== aSubject.target.id && + convItem.id != "searchResultConv" + ) { + convItem = convItem.nextElementSibling; + } + if (convItem.conv.target.id !== aSubject.target.id) { + // Could not find a matching conversation in the front end. + return; + } + // Update UI conversation associated with components + if (convItem.convView && convItem.convView.conv !== aSubject) { + convItem.convView.changeConversation(aSubject); + } + if (convItem.conv !== aSubject) { + convItem.changeConversation(aSubject); + } else { + convItem.update(); + } + // If the changed conversation is the selected item, make sure + // we update the UI elements to match the conversation type. + let selectedItem = contactlistbox.selectedItem; + if (selectedItem === convItem && selectedItem.convView) { + this.onListItemSelected(); + } + } + }, + initAfterChatCore() { + let onGroup = document.getElementById("onlinecontactsGroup"); + let offGroup = document.getElementById("offlinecontactsGroup"); + + for (let name in chatHandler.allContacts) { + let contact = chatHandler.allContacts[name]; + let group = contact.online ? onGroup : offGroup; + group.addContact(contact); + } + + onGroup._updateGroupLabel(); + offGroup._updateGroupLabel(); + + [ + "new-text", + "new-ui-conversation", + "ui-conversation-closed", + "contact-signed-on", + "contact-signed-off", + "contact-added", + "contact-removed", + "contact-no-longer-dummy", + "account-connected", + "account-disconnected", + "account-added", + "account-removed", + "conversation-update-type", + ].forEach(chatHandler._addObserver); + + chatHandler._updateNoConvPlaceHolder(); + statusSelector.init(); + }, + _observedTopics: [], + _addObserver(aTopic) { + Services.obs.addObserver(chatHandler, aTopic); + chatHandler._observedTopics.push(aTopic); + }, + _removeObservers() { + for (let topic of this._observedTopics) { + Services.obs.removeObserver(this, topic); + } + }, + // TODO move this function away from here and test it. + _getNextUnreadConversation(aConversations, aCurrent, aReverse) { + let convCount = aConversations.length; + if (!convCount) { + return -1; + } + + let direction = aReverse ? -1 : 1; + let next = i => { + i += direction; + if (i < 0) { + return i + convCount; + } + if (i >= convCount) { + return i - convCount; + } + return i; + }; + + // Find starting point + let start = 0; + if (Number.isInteger(aCurrent)) { + start = next(aCurrent); + } else if (aReverse) { + start = convCount - 1; + } + + // Cycle through all conversations until we are at the start again. + let i = start; + do { + // If there is a conversation with unread messages, select it. + if (aConversations[i].unreadIncomingMessageCount) { + return i; + } + i = next(i); + } while (i !== start && i !== aCurrent); + return -1; + }, + _selectNextUnreadConversation(aReverse, aList) { + let conversations = document.getElementById("conversationsGroup").contacts; + if (!conversations.length) { + return; + } + + let rawConversations = conversations.map(c => c.conv); + let current; + if ( + aList.selectedItem.localName == "richlistitem" && + aList.selectedItem.getAttribute("is") == "chat-imconv-richlistitem" + ) { + current = aList.selectedIndex - aList.getIndexOfItem(conversations[0]); + } + let newIndex = this._getNextUnreadConversation( + rawConversations, + current, + aReverse + ); + if (newIndex !== -1) { + aList.selectedItem = conversations[newIndex]; + } + }, + /** + * Restores the width in pixels stored on the width attribute of an element as + * CSS width, so it is used for flex layout calculations. Useful for restoring + * elements that were sized by a XUL splitter. + * + * @param {Element} element - Element to transfer the width attribute to CSS for. + */ + _restoreWidth: element => + (element.style.width = `${element.getAttribute("width")}px`), + async init() { + Notifications.init(); + if (!Services.prefs.getBoolPref("mail.chat.enabled")) { + [ + "chatButton", + "spacesPopupButtonChat", + "button-chat", + "menu_goChat", + "goChatSeparator", + "imAccountsStatus", + "joinChatMenuItem", + "newIMAccountMenuItem", + "newIMContactMenuItem", + "appmenu_newIMAccountMenuItem", + "appmenu_newIMContactMenuItem", + ].forEach(function (aId) { + let elt = document.getElementById(aId); + if (elt) { + elt.hidden = true; + } + }); + return; + } + + window.addEventListener("unload", this._removeObservers.bind(this)); + + // initialize the customizeDone method on the customizeable toolbar + var toolbox = document.getElementById("chat-view-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeChatToolbar"); + }; + + let tabmail = document.getElementById("tabmail"); + tabmail.registerTabType(chatTabType); + this._addObserver("buddy-authorization-request"); + this._addObserver("buddy-authorization-request-canceled"); + this._addObserver("buddy-verification-request"); + this._addObserver("buddy-verification-request-canceled"); + this._addObserver("conv-authorization-request"); + let listbox = document.getElementById("contactlistbox"); + listbox.addEventListener("keypress", function (aEvent) { + let item = listbox.selectedItem; + if (!item || !item.parentNode) { + // empty list or item no longer in the list + return; + } + item.keyPress(aEvent); + }); + listbox.addEventListener("select", this.onListItemSelected.bind(this)); + listbox.addEventListener("click", this.onListItemClick.bind(this)); + document + .getElementById("chatTabPanel") + .addEventListener("keypress", function (aEvent) { + let accelKeyPressed = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + !accelKeyPressed || + (aEvent.keyCode != aEvent.DOM_VK_DOWN && + aEvent.keyCode != aEvent.DOM_VK_UP) + ) { + return; + } + listbox._userSelecting = true; + let reverse = aEvent.keyCode != aEvent.DOM_VK_DOWN; + if (aEvent.shiftKey) { + chatHandler._selectNextUnreadConversation(reverse, listbox); + } else { + listbox.moveByOffset(reverse ? -1 : 1, true, false); + } + listbox._userSelecting = false; + let item = listbox.selectedItem; + if ( + item.localName == "richlistitem" && + item.getAttribute("is") == "chat-imconv-richlistitem" && + item.convView + ) { + item.convView.focus(); + } else { + listbox.focus(); + } + }); + window.addEventListener("resize", this.onConvResize.bind(this)); + document.getElementById("conversationsGroup").sortComparator = (a, b) => + a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + + const { allContacts, onlineContacts, ChatCore } = + ChromeUtils.importESModule("resource:///modules/chatHandler.sys.mjs"); + this.allContacts = allContacts; + this.onlineContacts = onlineContacts; + this.ChatCore = ChatCore; + if (this.ChatCore.initialized) { + this.initAfterChatCore(); + } else { + this.ChatCore.init(); + this._addObserver("chat-core-initialized"); + } + + if (ChatEncryption.otrEnabled) { + this._initOTR(); + } + + this._restoreWidth(document.getElementById("listPaneBox")); + this._restoreWidth(document.getElementById("contextPane")); + }, + + async _initOTR() { + if (!IMServices.core.initialized) { + await new Promise(resolve => { + function initObserver() { + Services.obs.removeObserver(initObserver, "prpl-init"); + resolve(); + } + Services.obs.addObserver(initObserver, "prpl-init"); + }); + } + // Avoid loading OTR until we have an im account set up. + if (IMServices.accounts.getAccounts().length === 0) { + await new Promise(resolve => { + function accountsObserver() { + if (IMServices.accounts.getAccounts().length > 0) { + Services.obs.removeObserver(accountsObserver, "account-added"); + resolve(); + } + } + Services.obs.addObserver(accountsObserver, "account-added"); + }); + } + await OTRUI.init(); + }, +}; + +function chatLogTreeGroupItem(aTitle, aLogItems) { + this._title = aTitle; + this._children = aLogItems; + for (let child of this._children) { + child._parent = this; + } + this._open = false; +} +chatLogTreeGroupItem.prototype = { + getText() { + return this._title; + }, + get id() { + return this._title; + }, + get open() { + return this._open; + }, + get level() { + return 0; + }, + get _parent() { + return null; + }, + get children() { + return this._children; + }, + getProperties() { + return ""; + }, +}; + +function chatLogTreeLogItem(aLog, aText, aLevel) { + this.log = aLog; + this._text = aText; + this._level = aLevel; +} +chatLogTreeLogItem.prototype = { + getText() { + return this._text; + }, + get id() { + return this.log.title; + }, + get open() { + return false; + }, + get level() { + return this._level; + }, + get children() { + return []; + }, + getProperties() { + return ""; + }, +}; + +function chatLogTreeView(aTree, aLogs) { + this._tree = aTree; + this._logs = aLogs; + this._tree.view = this; + this._rebuild(); +} +chatLogTreeView.prototype = { + __proto__: new PROTO_TREE_VIEW(), + + _rebuild() { + // Some date helpers... + const kDayInMsecs = 24 * 60 * 60 * 1000; + const kWeekInMsecs = 7 * kDayInMsecs; + const kTwoWeeksInMsecs = 2 * kWeekInMsecs; + + // Drop the old rowMap. + if (this._tree) { + this._tree.rowCountChanged(0, -this._rowMap.length); + } + this._rowMap = []; + + let placesBundle = Services.strings.createBundle( + "chrome://places/locale/places.properties" + ); + let dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "short" }); + let monthYearFormat = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + }); + let monthFormat = new Intl.DateTimeFormat(undefined, { month: "long" }); + let weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "long" }); + let nowDate = new Date(); + let todayDate = new Date( + nowDate.getFullYear(), + nowDate.getMonth(), + nowDate.getDate() + ); + + // The keys used in the 'firstgroups' object should match string ids. + // The order is the reverse of that in which they will appear + // in the logTree. + let firstgroups = { + previousWeek: [], + currentWeek: [], + }; + + // today and yesterday are treated differently, because for JSON logs they + // represent individual logs, and are not "groups". + let today = null, + yesterday = null; + + // Build a chatLogTreeLogItem for each log, and put it in the right group. + let groups = {}; + for (let log of this._logs) { + let logDate = new Date(log.time * 1000); + // Calculate elapsed time between the log and 00:00:00 today. + let timeFromToday = todayDate - logDate; + let title = dateFormat.format(logDate); + let group; + if (timeFromToday <= 0) { + today = new chatLogTreeLogItem( + log, + gChatBundle.GetStringFromName("log.today"), + 0 + ); + continue; + } else if (timeFromToday <= kDayInMsecs) { + yesterday = new chatLogTreeLogItem( + log, + gChatBundle.GetStringFromName("log.yesterday"), + 0 + ); + continue; + } else if (timeFromToday <= kWeekInMsecs - kDayInMsecs) { + // Note that the 7 days of the current week include today. + group = firstgroups.currentWeek; + title = weekdayFormat.format(logDate); + } else if (timeFromToday <= kTwoWeeksInMsecs - kDayInMsecs) { + group = firstgroups.previousWeek; + } else { + logDate.setHours(0); + logDate.setMinutes(0); + logDate.setSeconds(0); + logDate.setDate(1); + let groupID = logDate.toISOString(); + if (!(groupID in groups)) { + let groupname; + if (logDate.getFullYear() == nowDate.getFullYear()) { + if (logDate.getMonth() == nowDate.getMonth()) { + groupname = placesBundle.GetStringFromName( + "finduri-AgeInMonths-is-0" + ); + } else { + groupname = monthFormat.format(logDate); + } + } else { + groupname = monthYearFormat.format(logDate); + } + groups[groupID] = { + entries: [], + name: groupname, + }; + } + group = groups[groupID].entries; + } + group.push(new chatLogTreeLogItem(log, title, 1)); + } + + let groupIDs = Object.keys(groups).sort().reverse(); + + // Add firstgroups to groups and groupIDs. + for (let groupID in firstgroups) { + let group = firstgroups[groupID]; + if (!group.length) { + continue; + } + groupIDs.unshift(groupID); + groups[groupID] = { + entries: firstgroups[groupID], + name: gChatBundle.GetStringFromName("log." + groupID), + }; + } + + // Build tree. + if (today) { + this._rowMap.push(today); + } + if (yesterday) { + this._rowMap.push(yesterday); + } + groupIDs.forEach(function (aGroupID) { + let group = groups[aGroupID]; + group.entries.sort((l1, l2) => l2.log.time - l1.log.time); + this._rowMap.push(new chatLogTreeGroupItem(group.name, group.entries)); + }, this); + + // Finally, notify the tree. + if (this._tree) { + this._tree.rowCountChanged(0, this._rowMap.length); + } + }, +}; + +/** + * Handler for onpopupshowing event of the participantListContextMenu. Decides + * if the menu should be shown at all and manages the disabled state of its + * items. + * + * @param {XULMenuPopupElement} menu + * @returns {boolean} If the menu should be shown, currently decided based on + * if its only item has an action to perform. + */ +function showParticipantMenu(menu) { + const target = menu.triggerNode.closest("richlistitem"); + if (!target?.chatBuddy?.canVerifyIdentity) { + return false; + } + const identityVerified = target.chatBuddy.identityVerified; + const verifyMenuItem = document.getElementById("context-verifyParticipant"); + verifyMenuItem.disabled = identityVerified; + document.l10n.setAttributes( + verifyMenuItem, + identityVerified ? "chat-identity-verified" : "chat-verify-identity" + ); + return true; +} + +/** + * Command handler for the verify identity context menu item of the participant + * context menu. Initiates the verification for the participant the menu was + * opened on. + * + * @returns {undefined} + */ +function verifyChatParticipant() { + const target = document + .getElementById("participantListContextMenu") + .triggerNode.closest("richlistitem"); + const buddy = target.chatBuddy; + if (!buddy) { + return; + } + ChatEncryption.verifyIdentity(window, buddy); +} + +window.addEventListener("load", () => chatHandler.init()); diff --git a/comm/mail/components/im/content/imAccountWizard.js b/comm/mail/components/im/content/imAccountWizard.js new file mode 100644 index 0000000000..128412aa5b --- /dev/null +++ b/comm/mail/components/im/content/imAccountWizard.js @@ -0,0 +1,526 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// chat/content/imAccountOptionsHelper.js +/* globals accountOptionsHelper */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var PREF_EXTENSIONS_GETMOREPROTOCOLSURL = "extensions.getMoreProtocolsURL"; + +var accountWizard = { + onload() { + document + .querySelector("wizard") + .addEventListener("wizardfinish", this.createAccount.bind(this)); + let accountProtocolPage = document.getElementById("accountprotocol"); + accountProtocolPage.addEventListener( + "pageadvanced", + this.selectProtocol.bind(this) + ); + let accountUsernamePage = document.getElementById("accountusername"); + accountUsernamePage.addEventListener( + "pageshow", + this.showUsernamePage.bind(this) + ); + accountUsernamePage.addEventListener( + "pagehide", + this.hideUsernamePage.bind(this) + ); + let accountAdvancedPage = document.getElementById("accountadvanced"); + accountAdvancedPage.addEventListener( + "pageshow", + this.showAdvanced.bind(this) + ); + let accountSummaryPage = document.getElementById("accountsummary"); + accountSummaryPage.addEventListener( + "pageshow", + this.showSummary.bind(this) + ); + + // Ensure the im core is initialized before we get a list of protocols. + IMServices.core.init(); + + accountWizard.setGetMoreProtocols(); + + var protoList = document.getElementById("protolist"); + var protos = IMServices.core.getProtocols(); + protos.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + return a.name > b.name ? 1 : 0; + }); + protos.forEach(function (proto) { + let image = document.createElement("img"); + image.setAttribute("src", ChatIcons.getProtocolIconURI(proto)); + image.setAttribute("alt", ""); + image.classList.add("protoIcon"); + + let label = document.createXULElement("label"); + label.setAttribute("value", proto.name); + + let item = document.createXULElement("richlistitem"); + item.setAttribute("value", proto.id); + item.appendChild(image); + item.appendChild(label); + protoList.appendChild(item); + }); + + // there is a strange selection bug without this timeout + setTimeout(function () { + protoList.selectedIndex = 0; + }, 0); + + Services.obs.addObserver(this, "prpl-quit"); + window.addEventListener("unload", this.unload); + }, + unload() { + Services.obs.removeObserver(accountWizard, "prpl-quit"); + }, + observe(aObject, aTopic, aData) { + if (aTopic == "prpl-quit") { + // libpurple is being uninitialized. We can't create any new + // account so keeping this wizard open would be pointless, close it. + window.close(); + } + }, + + /** + * Builds the full username from the username boxes. + * + * @returns {string} assembled username + */ + getUsername() { + let usernameBoxIndex = 0; + if (this.proto.usernamePrefix) { + usernameBoxIndex = 1; + } + // If the first username input is empty, make sure we return an empty + // string so that it blocks the 'next' button of the wizard. + if (!this.userNameBoxes[usernameBoxIndex].value) { + return ""; + } + + return this.userNameBoxes.reduce((prev, elt) => prev + elt.value, ""); + }, + + /** + * Check that the username fields generate a new username, and if it is valid + * allow advancing the wizard. + */ + checkUsername() { + var wizard = document.querySelector("wizard"); + var name = accountWizard.getUsername(); + var duplicateWarning = document.getElementById("duplicateAccount"); + if (!name) { + wizard.canAdvance = false; + duplicateWarning.hidden = true; + return; + } + + var exists = accountWizard.proto.accountExists(name); + wizard.canAdvance = !exists; + duplicateWarning.hidden = !exists; + }, + + /** + * Takes the value of the primary username field and splits it if the value + * matches the split field syntax. + */ + splitUsername() { + let usernameBoxIndex = 0; + if (this.proto.usernamePrefix) { + usernameBoxIndex = 1; + } + let username = this.userNameBoxes[usernameBoxIndex].value; + let splitValues = this.proto.splitUsername(username); + if (!splitValues.length) { + return; + } + for (const box of this.userNameBoxes) { + if (Element.isInstance(box)) { + box.value = splitValues.shift(); + } + } + this.checkUsername(); + }, + + selectProtocol() { + var protoList = document.getElementById("protolist"); + var id = protoList.selectedItem.value; + this.proto = IMServices.core.getProtocolById(id); + }, + + /** + * Create a new input field for receiving a username. + * + * @param {string} aName - The id for the input. + * @param {string} aLabel - The text for the username label. + * @param {Element} grid - A container with a two column grid display to + * append the new elements to. + * @param {string} [aDefaultValue] - The initial value for the username. + * + * @returns {HTMLInputElement} - The newly created username input. + */ + insertUsernameField(aName, aLabel, grid, aDefaultValue) { + var label = document.createXULElement("label"); + label.setAttribute("value", aLabel); + label.setAttribute("control", aName); + label.setAttribute("id", aName + "-label"); + label.classList.add("label-inline"); + grid.appendChild(label); + + var input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.setAttribute("id", aName); + input.classList.add("input-inline"); + if (aDefaultValue) { + input.setAttribute("value", aDefaultValue); + } + input.addEventListener("input", event => { + this.checkUsername(); + }); + // Only add the split logic to the first input field + if (!this.userNameBoxes) { + input.addEventListener("blur", event => { + this.splitUsername(); + }); + } + grid.appendChild(input); + + return input; + }, + + /** + * Builds the username input boxes from the username split defined by the + * protocol. + */ + showUsernamePage() { + var proto = this.proto.id; + if ("userNameBoxes" in this && this.userNameProto == proto) { + this.checkUsername(); + return; + } + + var bundle = document.getElementById("accountsBundle"); + var usernameInfo; + var emptyText = this.proto.usernameEmptyText; + if (emptyText) { + usernameInfo = bundle.getFormattedString( + "accountUsernameInfoWithDescription", + [emptyText, this.proto.name] + ); + } else { + usernameInfo = bundle.getFormattedString("accountUsernameInfo", [ + this.proto.name, + ]); + } + document.getElementById("usernameInfo").textContent = usernameInfo; + + var grid = document.getElementById("userNameBox"); + // remove anything that may be there for another protocol + while (grid.hasChildNodes()) { + grid.lastChild.remove(); + } + this.userNameBoxes = undefined; + + var splits = this.proto.getUsernameSplit(); + + var label = bundle.getString("accountUsername"); + this.userNameBoxes = [this.insertUsernameField("name", label, grid)]; + this.userNameBoxes[0].emptyText = emptyText; + let usernameBoxIndex = 0; + + if (this.proto.usernamePrefix) { + this.userNameBoxes.unshift({ value: this.proto.usernamePrefix }); + usernameBoxIndex = 1; + } + + for (let i = 0; i < splits.length; ++i) { + this.userNameBoxes.push({ value: splits[i].separator }); + label = bundle.getFormattedString("accountColon", [splits[i].label]); + let defaultVal = splits[i].defaultValue; + this.userNameBoxes.push( + this.insertUsernameField("username-split-" + i, label, grid, defaultVal) + ); + } + this.userNameBoxes[usernameBoxIndex].focus(); + this.userNameProto = proto; + this.checkUsername(); + }, + + hideUsernamePage() { + document.querySelector("wizard").canAdvance = true; + var next = "account" + (this.proto.noPassword ? "advanced" : "password"); + document.getElementById("accountusername").next = next; + }, + + showAdvanced() { + // ensure we don't destroy user data if it's not necessary + var id = this.proto.id; + if ("protoSpecOptId" in this && this.protoSpecOptId == id) { + return; + } + this.protoSpecOptId = id; + + this.populateProtoSpecificBox(); + + // Make sure the protocol specific options and wizard buttons are visible. + let wizard = document.querySelector("wizard"); + if (wizard.scrollHeight > window.innerHeight) { + window.resizeBy(0, wizard.scrollHeight - window.innerHeight); + } + + let alias = document.getElementById("alias"); + alias.focus(); + }, + + populateProtoSpecificBox() { + let haveOptions = accountOptionsHelper.addOptions( + this.proto.id + "-", + this.proto.getOptions() + ); + document.getElementById("protoSpecificGroupbox").hidden = !haveOptions; + if (haveOptions) { + var bundle = document.getElementById("accountsBundle"); + document.getElementById("protoSpecificCaption").textContent = + bundle.getFormattedString("protoOptions", [this.proto.name]); + } + }, + + /** + * Create new summary field and value elements. + * + * @param {string} aLabel - The name of the field being summarised. + * @param {string} aValue - The value of the field being summarised. + * @param {Element} grid - A container with a two column grid display to + * append the new elements to. + */ + createSummaryRow(aLabel, aValue, grid) { + var label = document.createXULElement("label"); + label.classList.add("header", "label-inline"); + if (aLabel.length > 20) { + aLabel = aLabel.substring(0, 20); + aLabel += "…"; + } + + label.setAttribute("value", aLabel); + grid.appendChild(label); + + var input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.setAttribute("value", aValue); + input.classList.add("plain", "input-inline"); + input.setAttribute("readonly", true); + grid.appendChild(input); + }, + + showSummary() { + var rows = document.getElementById("summaryRows"); + var bundle = document.getElementById("accountsBundle"); + while (rows.hasChildNodes()) { + rows.lastChild.remove(); + } + + var label = document.getElementById("protoLabel").value; + this.createSummaryRow(label, this.proto.name, rows); + this.username = this.getUsername(); + label = bundle.getString("accountUsername"); + this.createSummaryRow(label, this.username, rows); + if (!this.proto.noPassword) { + this.password = this.getValue("password"); + if (this.password) { + label = document.getElementById("passwordLabel").value; + var pass = ""; + for (let i = 0; i < this.password.length; ++i) { + pass += "*"; + } + this.createSummaryRow(label, pass, rows); + } + } + this.alias = this.getValue("alias"); + if (this.alias) { + label = document.getElementById("aliasLabel").value; + this.createSummaryRow(label, this.alias, rows); + } + + var id = this.proto.id; + this.prefs = []; + for (let opt of this.proto.getOptions()) { + let name = opt.name; + let eltName = id + "-" + name; + let val = this.getValue(eltName); + // The value will be undefined if the proto specific groupbox has never been opened + if (val === undefined) { + continue; + } + switch (opt.type) { + case Ci.prplIPref.typeBool: + if (val != opt.getBool()) { + this.prefs.push({ opt, name, value: !!val }); + } + break; + case Ci.prplIPref.typeInt: + if (val != opt.getInt()) { + this.prefs.push({ opt, name, value: val }); + } + break; + case Ci.prplIPref.typeString: + if (val != opt.getString()) { + this.prefs.push({ opt, name, value: val }); + } + break; + case Ci.prplIPref.typeList: + if (val != opt.getListDefault()) { + this.prefs.push({ opt, name, value: val }); + } + break; + default: + throw new Error("unknown preference type " + opt.type); + } + } + + for (let i = 0; i < this.prefs.length; ++i) { + let opt = this.prefs[i]; + let label = bundle.getFormattedString("accountColon", [opt.opt.label]); + this.createSummaryRow(label, opt.value, rows); + } + }, + + createAccount() { + var acc = IMServices.accounts.createAccount(this.username, this.proto.id); + if (!this.proto.noPassword && this.password) { + acc.password = this.password; + } + if (this.alias) { + acc.alias = this.alias; + } + + for (let i = 0; i < this.prefs.length; ++i) { + let option = this.prefs[i]; + let opt = option.opt; + switch (opt.type) { + case Ci.prplIPref.typeBool: + acc.setBool(option.name, option.value); + break; + case Ci.prplIPref.typeInt: + acc.setInt(option.name, option.value); + break; + case Ci.prplIPref.typeString: + case Ci.prplIPref.typeList: + acc.setString(option.name, option.value); + break; + default: + throw new Error("unknown type"); + } + } + var autologin = this.getValue("connectNow"); + acc.autoLogin = autologin; + + acc.save(); + + try { + if (autologin) { + acc.connect(); + } + } catch (e) { + // If the connection fails (for example if we are currently in + // offline mode), we still want to close the account wizard + } + + if (window.opener) { + var am = window.opener.gAccountManager; + if (am) { + am.selectAccount(acc.id); + } + } + + var inServer = MailServices.accounts.createIncomingServer( + this.username, + this.proto.id, // hostname + "im" + ); + inServer.wrappedJSObject.imAccount = acc; + + var account = MailServices.accounts.createAccount(); + // Avoid new folder notifications. + inServer.valid = false; + account.incomingServer = inServer; + inServer.valid = true; + MailServices.accounts.notifyServerLoaded(inServer); + + return true; + }, + + getValue(aId) { + var elt = document.getElementById(aId); + if ("selectedItem" in elt) { + return elt.selectedItem.value; + } + // Strangely various input types also have a "checked" property defined, + // so we check for the expected elements explicitly. + if ( + ((elt.localName == "input" && elt.getAttribute("type") == "checkbox") || + elt.localName == "checkbox") && + "checked" in elt + ) { + return elt.checked; + } + if ("value" in elt) { + return elt.value; + } + // If the groupbox has never been opened, the binding isn't attached + // so the attributes don't exist. The calling code in showSummary + // has a special handling of the undefined value for this case. + return undefined; + }, + + *getIter(aEnumerator) { + for (let iter of aEnumerator) { + yield iter; + } + }, + + /* Check for correctness and set URL for the "Get more protocols..."-link + * Stripped down code from preferences/themes.js + */ + setGetMoreProtocols() { + let prefURL = PREF_EXTENSIONS_GETMOREPROTOCOLSURL; + var getMore = document.getElementById("getMoreProtocols"); + var showGetMore = false; + const nsIPrefBranch = Ci.nsIPrefBranch; + + if (Services.prefs.getPrefType(prefURL) != nsIPrefBranch.PREF_INVALID) { + try { + var getMoreURL = Services.urlFormatter.formatURLPref(prefURL); + getMore.setAttribute("getMoreURL", getMoreURL); + showGetMore = getMoreURL != "about:blank"; + } catch (e) {} + } + getMore.hidden = !showGetMore; + }, + + openURL(aURL) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(aURL)); + }, +}; + +window.addEventListener("load", event => { + accountWizard.onload(); +}); diff --git a/comm/mail/components/im/content/imAccountWizard.xhtml b/comm/mail/components/im/content/imAccountWizard.xhtml new file mode 100644 index 0000000000..9ff3cf33ad --- /dev/null +++ b/comm/mail/components/im/content/imAccountWizard.xhtml @@ -0,0 +1,180 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/accountWizard.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/grid-layout.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % accountWizardDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%accountWizardDTD; %brandDTD; ]> + +<html + id="accountWizard" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&windowTitle.label;</title> + <link rel="localization" href="toolkit/global/wizard.ftl" /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://chat/content/imAccountOptionsHelper.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/imAccountWizard.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <stringbundle + id="accountsBundle" + src="chrome://messenger/locale/imAccounts.properties" + /> + + <wizard id="wizard"> + <wizardpage + id="accountprotocol" + pageid="accountprotocol" + next="accountusername" + label="&accountProtocolTitle.label;" + > + <description>&accountProtocolInfo.label;</description> + <separator /> + <label + value="&accountProtocolField.label;" + control="protolist" + id="protoLabel" + hidden="true" + /> + <richlistbox + flex="1" + id="protolist" + ondblclick="document.getElementById('wizard').advance();" + /> + <hbox pack="end"> + <label + id="getMoreProtocols" + class="text-link" + value="&accountProtocolGetMore.label;" + onclick="if (event.button == 0) { accountWizard.openURL(this.getAttribute('getMoreURL')); }" + /> + </hbox> + </wizardpage> + + <wizardpage + id="accountusername" + pageid="accountusername" + next="accountpassword" + label="&accountUsernameTitle.label;" + > + <description id="usernameInfo" /> + <separator /> + <html:div + id="userNameBox" + class="grid-block-two-column-fr grid-items-center" + > + </html:div> + <separator /> + <description id="duplicateAccount" hidden="true" + >&accountUsernameDuplicate.label;</description + > + </wizardpage> + + <wizardpage + id="accountpassword" + pageid="accountpassword" + next="accountadvanced" + label="&accountPasswordTitle.label;" + > + <description>&accountPasswordInfo.label;</description> + <separator /> + <hbox id="passwordBox" align="baseline" class="input-container"> + <label + id="passwordLabel" + value="&accountPasswordField.label;" + class="label-inline" + control="password" + /> + <html:input id="password" type="password" class="input-inline" /> + </hbox> + <separator /> + <description id="passwordManagerDescription" + >&accountPasswordManager.label;</description + > + </wizardpage> + + <wizardpage + id="accountadvanced" + pageid="accountadvanced" + next="accountsummary" + label="&accountAdvancedTitle.label;" + > + <description>&accountAdvancedInfo.label;</description> + <separator class="thin" /> + <html:fieldset id="aliasGroupbox"> + <html:legend id="aliasGroupboxCaption" + >&accountAliasGroupbox.caption;</html:legend + > + <hbox id="aliasBox" align="baseline" class="input-container"> + <label + id="aliasLabel" + value="&accountAliasField.label;" + class="label-inline" + control="alias" + /> + <html:input id="alias" type="text" class="input-inline" /> + </hbox> + <description>&accountAliasInfo.label;</description> + </html:fieldset> + + <html:fieldset id="protoSpecificGroupbox"> + <html:legend id="protoSpecificCaption"></html:legend> + <html:div + id="protoSpecific" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + </html:fieldset> + </wizardpage> + + <wizardpage + id="accountsummary" + pageid="accountsummary" + label="&accountSummaryTitle.label;" + > + <description>&accountSummaryInfo.label;</description> + <separator /> + <html:div + id="summaryRows" + class="grid-block-two-column-fr grid-items-baseline" + > + </html:div> + <separator /> + <checkbox + id="connectNow" + label="&accountSummary.connectNow.label;" + checked="true" + /> + </wizardpage> + </wizard> + </html:body> +</html> diff --git a/comm/mail/components/im/content/imAccounts.js b/comm/mail/components/im/content/imAccounts.js new file mode 100644 index 0000000000..46bb72c197 --- /dev/null +++ b/comm/mail/components/im/content/imAccounts.js @@ -0,0 +1,663 @@ +/* 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/. */ + +/* globals MozElements */ +/* globals statusSelector */ +/* globals MsgAccountManager */ + +var { DownloadUtils } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadUtils.sys.mjs" +); + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +// This is the list of notifications that the account manager window observes +var events = [ + "prpl-quit", + "account-list-updated", + "account-added", + "account-updated", + "account-removed", + "account-connected", + "account-connecting", + "account-disconnected", + "account-disconnecting", + "account-connect-progress", + "account-connect-error", + "autologin-processed", + "status-changed", + "network:offline-status-changed", +]; + +var gAccountManager = { + // Sets the delay after connect() or disconnect() during which + // it is impossible to perform disconnect() and connect() + _disabledDelay: 500, + disableTimerID: 0, + _connectedLabelInterval: 0, + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + document.getElementById("accounts-notification-box").prepend(element); + }); + } + return this._notificationBox; + }, + + load() { + // Wait until the password service is ready before offering anything. + Services.logins.initializationPromise.then( + () => { + this.accountList = document.getElementById("accountlist"); + let defaultID; + IMServices.core.init(); // ensure the imCore is initialized. + for (let acc of this.getAccounts()) { + let elt = document.createXULElement("richlistitem", { + is: "chat-account-richlistitem", + }); + this.accountList.appendChild(elt); + elt.build(acc); + if ( + !defaultID && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + defaultID = acc.id; + } + } + for (let event of events) { + Services.obs.addObserver(this, event); + } + if (!this.accountList.getRowCount()) { + // This is horrible, but it works. Otherwise (at least on mac) + // the wizard is not centered relatively to the account manager + setTimeout(function () { + gAccountManager.new(); + }, 0); + } else { + // we have accounts, show the list + document.getElementById("noAccountScreen").hidden = true; + document.getElementById("accounts-notification-box").hidden = false; + + // ensure an account is selected + if (defaultID) { + this.selectAccount(defaultID); + } else { + this.accountList.selectedIndex = 0; + } + } + + this.setAutoLoginNotification(); + + this.accountList.addEventListener("keypress", this.onKeyPress, true); + window.addEventListener("unload", this.unload.bind(this)); + this._connectedLabelInterval = setInterval( + this.updateConnectedLabels, + 60000 + ); + statusSelector.init(); + }, + () => { + this.close(); + } + ); + }, + unload() { + clearInterval(this._connectedLabelInterval); + for (let event of events) { + Services.obs.removeObserver(this, event); + } + }, + _updateAccountList() { + let accountList = this.accountList; + let i = 0; + for (let acc of this.getAccounts()) { + let oldItem = accountList.getItemAtIndex(i); + if (oldItem.id != acc.id) { + let accElt = document.getElementById(acc.id); + accountList.insertBefore(accElt, oldItem); + accElt.refreshState(); + } + ++i; + } + + if (accountList.itemCount == 0) { + // Focus the "New Account" button if there are no accounts left. + document.getElementById("newaccount").focus(); + // Return early, otherwise we'll run into an 'undefined property' strict + // warning when trying to focus the buttons. Fixes bug 408. + return; + } + + // The selected item is still selected + if (accountList.selectedItem) { + accountList.selectedItem.setFocus(); + } + accountList.ensureSelectedElementIsVisible(); + + // We need to refresh the disabled menu items + this.disableCommandItems(); + }, + observe(aObject, aTopic, aData) { + if (aTopic == "prpl-quit") { + // libpurple is being uninitialized. We don't need the account + // manager window anymore, close it. + this.close(); + return; + } else if (aTopic == "autologin-processed") { + let notification = + this.msgNotificationBar.getNotificationWithValue("autoLoginStatus"); + if (notification) { + notification.close(); + } + return; + } else if (aTopic == "network:offline-status-changed") { + this.setOffline(aData == "offline"); + return; + } else if (aTopic == "status-changed") { + this.setOffline(aObject.statusType == Ci.imIStatusInfo.STATUS_OFFLINE); + return; + } else if (aTopic == "account-list-updated") { + this._updateAccountList(); + return; + } + + // The following notification handlers need an account. + let account = aObject.QueryInterface(Ci.imIAccount); + + if (aTopic == "account-added") { + document.getElementById("noAccountScreen").hidden = true; + document.getElementById("accounts-notification-box").hidden = false; + let elt = document.createXULElement("richlistitem", { + is: "chat-account-richlistitem", + }); + this.accountList.appendChild(elt); + elt.build(account); + if (this.accountList.getRowCount() == 1) { + this.accountList.selectedIndex = 0; + } + } else if (aTopic == "account-removed") { + let elt = document.getElementById(account.id); + elt.destroy(); + if (!elt.selected) { + elt.remove(); + return; + } + // The currently selected element is removed, + // ensure another element gets selected (if the list is not empty) + var selectedIndex = this.accountList.selectedIndex; + // Prevent errors if the timer is active and the account deleted + clearTimeout(this.disableTimerID); + this.disableTimerID = 0; + elt.remove(); + var count = this.accountList.getRowCount(); + if (!count) { + document.getElementById("noAccountScreen").hidden = false; + document.getElementById("accounts-notification-box").hidden = true; + return; + } + if (selectedIndex == count) { + --selectedIndex; + } + this.accountList.selectedIndex = selectedIndex; + } else if (aTopic == "account-updated") { + document.getElementById(account.id).build(account); + this.disableCommandItems(); + } else if (aTopic == "account-connect-progress") { + document.getElementById(account.id).updateConnectingProgress(); + } else if (aTopic == "account-connect-error") { + document.getElementById(account.id).updateConnectionError(); + // See NSSErrorsService::ErrorIsOverridable. + if ( + [ + "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED", + "MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITY", + "MOZILLA_PKIX_ERROR_EMPTY_ISSUER_NAME", + "MOZILLA_PKIX_ERROR_INADEQUATE_KEY_SIZE", + "MOZILLA_PKIX_ERROR_MITM_DETECTED", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE", + "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT", + "MOZILLA_PKIX_ERROR_V1_CERT_USED_AS_CA", + "SEC_ERROR_CA_CERT_INVALID", + "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED", + "SEC_ERROR_EXPIRED_CERTIFICATE", + "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE", + "SEC_ERROR_INVALID_TIME", + "SEC_ERROR_UNKNOWN_ISSUER", + "SSL_ERROR_BAD_CERT_DOMAIN", + ].includes(account.prplAccount.securityInfo?.errorCodeString) + ) { + this.addException(); + } + } else { + const stateEvents = { + "account-connected": "connected", + "account-connecting": "connecting", + "account-disconnected": "disconnected", + "account-disconnecting": "disconnecting", + }; + if (aTopic in stateEvents) { + let elt = document.getElementById(account.id); + if (!elt) { + // Probably disconnecting a removed account. + return; + } + elt.refreshState(stateEvents[aTopic]); + } + } + }, + cancelReconnection() { + this.accountList.selectedItem.cancelReconnection(); + }, + connect() { + let account = this.accountList.selectedItem.account; + if (account.disconnected) { + this.temporarilyDisableButtons(); + account.connect(); + } + }, + disconnect() { + let account = this.accountList.selectedItem.account; + if (account.connected || account.connecting) { + this.temporarilyDisableButtons(); + account.disconnect(); + } + }, + addException() { + let account = this.accountList.selectedItem.account; + let prplAccount = account.prplAccount; + if (!prplAccount.connectionTarget) { + return; + } + + // Open the Gecko SSL exception dialog. + let params = { + exceptionAdded: false, + securityInfo: prplAccount.securityInfo, + prefetchCert: true, + location: prplAccount.connectionTarget, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + // Reconnect the account if an exception was added. + if (params.exceptionAdded) { + account.disconnect(); + account.connect(); + } + }, + copyDebugLog() { + let account = this.accountList.selectedItem.account; + let text = account + .getDebugMessages() + .map(function (dbgMsg) { + let m = dbgMsg.message; + let time = new Date(m.timeStamp); + const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "long", + }); + time = dateTimeFormatter.format(time); + let level = dbgMsg.logLevel; + if (!level) { + return "(" + m.errorMessage + ")"; + } + if (level == dbgMsg.LEVEL_ERROR) { + level = "ERROR"; + } else if (level == dbgMsg.LEVEL_WARNING) { + level = "WARN."; + } else if (level == dbgMsg.LEVEL_LOG) { + level = "LOG "; + } else { + level = "DEBUG"; + } + return ( + "[" + + time + + "] " + + level + + " (@ " + + m.sourceLine + + " " + + m.sourceName + + ":" + + m.lineNumber + + ")\n" + + m.errorMessage + ); + }) + .join("\n"); + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(text); + }, + updateConnectedLabels() { + for (let i = 0; i < gAccountManager.accountList.itemCount; ++i) { + let item = gAccountManager.accountList.getItemAtIndex(i); + if (item.account.connected) { + item.refreshConnectedLabel(); + } + } + }, + /* This function disables the connect/disconnect buttons for + * `this._disabledDelay` ms before calling disableCommandItems to restore + * the state of the buttons. + */ + temporarilyDisableButtons() { + document.getElementById("cmd_disconnect").setAttribute("disabled", "true"); + document.getElementById("cmd_connect").setAttribute("disabled", "true"); + clearTimeout(this.disableTimerID); + this.accountList.focus(); + this.disableTimerID = setTimeout( + function (aItem) { + gAccountManager.disableTimerID = 0; + gAccountManager.disableCommandItems(); + aItem.setFocus(); + }, + this._disabledDelay, + this.accountList.selectedItem + ); + }, + + new() { + this.openDialog("chrome://messenger/content/chat/imAccountWizard.xhtml"); + }, + edit() { + // Find the nsIIncomingServer for the current imIAccount. + let server = null; + let imAccountId = this.accountList.selectedItem.account.numericId; + for (let account of MailServices.accounts.accounts) { + let incomingServer = account.incomingServer; + if (!incomingServer || incomingServer.type != "im") { + continue; + } + if (incomingServer.wrappedJSObject.imAccount.numericId == imAccountId) { + server = incomingServer; + break; + } + } + + MsgAccountManager(null, server); + }, + autologin() { + var elt = this.accountList.selectedItem; + elt.autoLogin = !elt.autoLogin; + }, + close() { + // If a modal dialog is opened, we can't close this window now + if (this.modalDialog) { + setTimeout(function () { + window.close(); + }, 0); + } else { + window.close(); + } + }, + + /* This function disables or enables the currently selected button and + the corresponding context menu item */ + disableCommandItems() { + let accountList = this.accountList; + let selectedItem = accountList.selectedItem; + // When opening the account manager, if accounts have errors, we + // can be called during build(), before any item is selected. + // In this case, just return early. + if (!selectedItem) { + return; + } + + // If the timer that disables the button (for a short time) already exists, + // we don't want to interfere and set the button as enabled. + if (this.disableTimerID) { + return; + } + + let account = selectedItem.account; + let isCommandDisabled = + this.isOffline || + (account.disconnected && + account.connectionErrorReason == Ci.imIAccount.ERROR_UNKNOWN_PRPL); + + let disabledItems = ["connect", "disconnect"]; + for (let name of disabledItems) { + let elt = document.getElementById("cmd_" + name); + if (isCommandDisabled) { + elt.setAttribute("disabled", "true"); + } else { + elt.removeAttribute("disabled"); + } + } + }, + onContextMenuShowing(event) { + let targetElt = event.target.triggerNode.closest( + 'richlistitem[is="chat-account-richlistitem"]' + ); + document.querySelectorAll(".im-context-account-item").forEach(e => { + e.hidden = !targetElt; + }); + if (targetElt) { + let account = targetElt.account; + let hiddenItems = { + connect: !account.disconnected, + disconnect: account.disconnected || account.disconnecting, + cancelReconnection: !targetElt.hasAttribute("reconnectPending"), + accountsItemsSeparator: account.disconnecting, + }; + for (let name in hiddenItems) { + document.getElementById("context_" + name).hidden = hiddenItems[name]; + } + } + }, + + selectAccount(aAccountId) { + this.accountList.selectedItem = document.getElementById(aAccountId); + this.accountList.ensureSelectedElementIsVisible(); + }, + onAccountSelect() { + clearTimeout(this.disableTimerID); + this.disableTimerID = 0; + this.disableCommandItems(); + // Horrible hack here too, see Bug 177 + setTimeout( + function (aThis) { + try { + aThis.accountList.selectedItem.setFocus(); + } catch (e) { + /* Sometimes if the user goes too fast with VK_UP or VK_DOWN, the + selectedItem doesn't have the expected binding attached */ + } + }, + 0, + this + ); + }, + + onKeyPress(event) { + if (!this.selectedItem) { + return; + } + // As we stop propagation, the default action applies to the richlistbox + // so that the selected account is changed with this default action + if (event.keyCode == event.DOM_VK_DOWN) { + if (this.selectedIndex < this.itemCount - 1) { + this.ensureIndexIsVisible(this.selectedIndex + 1); + } + event.stopPropagation(); + return; + } + + if (event.keyCode == event.DOM_VK_UP) { + if (this.selectedIndex > 0) { + this.ensureIndexIsVisible(this.selectedIndex - 1); + } + event.stopPropagation(); + return; + } + + if (event.keyCode == event.DOM_VK_RETURN) { + let target = event.target; + if ( + target.localName != "checkbox" && + (target.localName != "button" || + /^(dis)?connect$/.test(target.getAttribute("anonid"))) + ) { + this.selectedItem.buttons.proceedDefaultAction(); + } + } + }, + + *getAccounts() { + for (let account of IMServices.accounts.getAccounts()) { + yield account; + } + }, + + openDialog(aUrl, aArgs) { + this.modalDialog = true; + window.openDialog(aUrl, "", "chrome,modal,titlebar,centerscreen", aArgs); + this.modalDialog = false; + }, + + setAutoLoginNotification() { + var as = IMServices.accounts; + var autoLoginStatus = as.autoLoginStatus; + let isOffline = false; + let crashCount = 0; + for (let acc of this.getAccounts()) { + if ( + acc.autoLogin && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + ++crashCount; + } + } + + if (autoLoginStatus == as.AUTOLOGIN_ENABLED && crashCount == 0) { + let status = IMServices.core.globalUserStatus.statusType; + this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE); + return; + } + + var bundle = document.getElementById("accountsBundle"); + let box = this.msgNotificationBar; + var prio = box.PRIORITY_INFO_HIGH; + var connectNowButton = { + accessKey: bundle.getString( + "accountsManager.notification.button.accessKey" + ), + callback: this.processAutoLogin, + label: bundle.getString("accountsManager.notification.button.label"), + }; + var barLabel; + + switch (autoLoginStatus) { + case as.AUTOLOGIN_USER_DISABLED: + barLabel = bundle.getString( + "accountsManager.notification.userDisabled.label" + ); + break; + + case as.AUTOLOGIN_SAFE_MODE: + barLabel = bundle.getString( + "accountsManager.notification.safeMode.label" + ); + break; + + case as.AUTOLOGIN_START_OFFLINE: + barLabel = bundle.getString( + "accountsManager.notification.startOffline.label" + ); + isOffline = true; + break; + + case as.AUTOLOGIN_CRASH: + barLabel = bundle.getString("accountsManager.notification.crash.label"); + prio = box.PRIORITY_WARNING_MEDIUM; + break; + + /* One or more accounts made the application crash during their connection. + If none, this function has already returned */ + case as.AUTOLOGIN_ENABLED: + barLabel = bundle.getString( + "accountsManager.notification.singleCrash.label" + ); + barLabel = PluralForm.get(crashCount, barLabel).replace( + "#1", + crashCount + ); + prio = box.PRIORITY_WARNING_MEDIUM; + connectNowButton.callback = this.processCrashedAccountsLogin; + break; + + default: + barLabel = bundle.getString("accountsManager.notification.other.label"); + } + let status = IMServices.core.globalUserStatus.statusType; + this.setOffline(isOffline || status == Ci.imIStatusInfo.STATUS_OFFLINE); + + box.appendNotification( + "autologinStatus", + { + label: barLabel, + priority: prio, + }, + [connectNowButton] + ); + }, + processAutoLogin() { + var ioService = Services.io; + if (ioService.offline) { + ioService.manageOfflineStatus = false; + ioService.offline = false; + } + + IMServices.accounts.processAutoLogin(); + + gAccountManager.accountList.selectedItem.setFocus(); + }, + processCrashedAccountsLogin() { + for (let acc in gAccountManager.getAccounts()) { + if ( + acc.disconnected && + acc.autoLogin && + acc.firstConnectionState == acc.FIRST_CONNECTION_CRASHED + ) { + acc.connect(); + } + } + + let notification = + this.msgNotificationBar.getNotificationWithValue("autoLoginStatus"); + if (notification) { + notification.close(); + } + + gAccountManager.accountList.selectedItem.setFocus(); + }, + setOffline(aState) { + this.isOffline = aState; + if (aState) { + this.accountList.setAttribute("offline", "true"); + } else { + this.accountList.removeAttribute("offline"); + } + this.disableCommandItems(); + }, +}; + +window.addEventListener("DOMContentLoaded", () => { + gAccountManager.load(); +}); diff --git a/comm/mail/components/im/content/imAccounts.xhtml b/comm/mail/components/im/content/imAccounts.xhtml new file mode 100644 index 0000000000..d123521be1 --- /dev/null +++ b/comm/mail/components/im/content/imAccounts.xhtml @@ -0,0 +1,250 @@ +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/imRichlistbox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/imAccounts.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % accountsDTD SYSTEM "chrome://chat/locale/accounts.dtd"> +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd"> +%accountsDTD; %brandDTD; %chatDTD; ]> + +<html + id="accountManager" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="Messenger:Accounts" + scrolling="false" + lightweightthemes="true" + persist="width height screenX screenY" +> + <head> + <title>&accountsWindow.title;</title> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/imAccounts.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/accountUtils.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/imStatusSelector.js" + ></script> + <script + defer="defer" + src="chrome://chat/content/chat-account-richlistitem.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <stringbundle + id="accountsBundle" + src="chrome://messenger/locale/imAccounts.properties" + /> + + <commandset id="accountsCommands"> + <command + id="cmd_connect" + accesskey="&account.connect.accesskey;" + label="&account.connect.label;" + oncommand="gAccountManager.connect()" + /> + <command + id="cmd_disconnect" + label="&account.disconnect.label;" + accesskey="&account.disconnect.accesskey;" + oncommand="gAccountManager.disconnect()" + /> + <command + id="cmd_cancelReconnection" + label="&account.cancelReconnection.label;" + accesskey="&account.cancelReconnection.accesskey;" + oncommand="gAccountManager.cancelReconnection()" + /> + <command + id="cmd_copyDebugLog" + label="&account.copyDebugLog.label;" + accesskey="&account.copyDebugLog.accesskey;" + oncommand="gAccountManager.copyDebugLog();" + /> + <command + id="cmd_edit" + label="&account.edit.label;" + accesskey="&account.edit.accesskey;" + oncommand="gAccountManager.edit()" + /> + <command + id="cmd_new" + label="&accountManager.newAccount.label;" + accesskey="&accountManager.newAccount.accesskey;" + oncommand="gAccountManager.new()" + /> + <command + id="cmd_close" + label="&accountManager.close.label;" + accesskey="&accountManager.close.accesskey;" + oncommand="gAccountManager.close()" + /> + </commandset> + + <keyset id="accountsKeys"> + <key id="key_close1" key="w" modifiers="accel" command="cmd_close" /> + <key id="key_close2" keycode="VK_ESCAPE" command="cmd_close" /> + <key + id="key_close3" + command="cmd_close" + key="&accountManager.close.commandkey;" + modifiers="accel,shift" + /> + </keyset> + + <menupopup + id="accountsContextMenu" + onpopupshowing="gAccountManager.onContextMenuShowing(event)" + > + <menuitem + id="context_connect" + command="cmd_connect" + class="im-context-account-item" + /> + <menuitem + id="context_disconnect" + command="cmd_disconnect" + class="im-context-account-item" + /> + <menuitem + id="context_cancelReconnection" + command="cmd_cancelReconnection" + class="im-context-account-item" + /> + <menuitem id="context_copyDebugLog" command="cmd_copyDebugLog" /> + <menuseparator + id="context_accountsItemsSeparator" + class="im-context-account-item" + /> + <menuitem command="cmd_new" /> + <menuseparator class="im-context-account-item" /> + <menuitem command="cmd_edit" class="im-context-account-item" /> + </menupopup> + + <html:div class="displayUserAccount"> + <stack id="statusImageStack"> + <html:img + id="userIcon" + class="userIcon" + alt="" + onclick="statusSelector.userIconClick();" + /> + <button + type="menu" + id="statusTypeIcon" + class="statusTypeIcon" + status="available" + > + <menupopup + id="setStatusTypeMenupopup" + oncommand="statusSelector.editStatus(event);" + > + <menuitem + id="statusTypeAvailable" + label="&status.available;" + status="available" + class="menuitem-iconic" + /> + <menuitem + id="statusTypeUnavailable" + label="&status.unavailable;" + status="unavailable" + class="menuitem-iconic" + /> + <menuseparator id="statusTypeOfflineSeparator" /> + <menuitem + id="statusTypeOffline" + label="&status.offline;" + status="offline" + class="menuitem-iconic" + /> + </menupopup> + </button> + </stack> + <html:div id="displayNameAndstatusMessageGrid"> + <label + id="displayName" + onclick="statusSelector.displayNameClick();" + align="center" + pack="center" + /> + <!-- FIXME: A keyboard user cannot focus the hidden input, nor click + - the above label in order to reveal it. --> + <html:input + id="displayNameInput" + class="statusMessageInput input-inline" + hidden="hidden" + /> + <html:hr /> + <label + id="statusMessageLabel" + crop="end" + value="" + onclick="statusSelector.statusMessageClick();" + /> + <html:input + id="statusMessageInput" + class="statusMessageInput input-inline" + value="" + hidden="hidden" + /> + </html:div> + </html:div> + + <hbox flex="1" ondblclick="gAccountManager.new();"> + <vbox flex="1" id="noAccountScreen" align="center" pack="center"> + <hbox id="noAccountBox" align="start"> + <vbox id="noAccountInnerBox" flex="1"> + <label + id="noAccountTitle" + value="&accountManager.noAccount.title;" + /> + <description id="noAccountDesc" + >&accountManager.noAccount.description;</description + > + </vbox> + </hbox> + </vbox> + + <vbox id="accounts-notification-box" flex="1"> + <!-- notificationbox will be added here lazily. --> + <richlistbox + id="accountlist" + flex="1" + context="accountsContextMenu" + onselect="gAccountManager.onAccountSelect();" + /> + </vbox> + </hbox> + + <hbox id="bottombuttons" align="center"> + <button id="newaccount" command="cmd_new" /> + <spacer flex="1" /> + <button id="close" command="cmd_close" /> + </hbox> + </html:body> +</html> diff --git a/comm/mail/components/im/content/imContextMenu.js b/comm/mail/components/im/content/imContextMenu.js new file mode 100644 index 0000000000..0d9ecf0763 --- /dev/null +++ b/comm/mail/components/im/content/imContextMenu.js @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file is loaded in messenger.xhtml. +/* globals gatherTextUnder, goUpdateGlobalEditMenuItems, makeURLAbsolute, Services */ +/* import-globals-from ../../../base/content/widgets/browserPopups.js */ + +var gChatContextMenu = null; + +function imContextMenu(aXulMenu) { + this.target = null; + this.menu = null; + this.onLink = false; + this.onMailtoLink = false; + this.onSaveableLink = false; + this.link = false; + this.linkURL = ""; + this.linkURI = null; + this.linkProtocol = null; + this.isTextSelected = false; + this.isContentSelected = false; + this.shouldDisplay = true; + this.ellipsis = "\u2026"; + this.initedActions = false; + + try { + this.ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + + // Initialize new menu. + this.initMenu(aXulMenu); +} + +// Prototype for nsContextMenu "class." +imContextMenu.prototype = { + cleanup() { + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal + ?.getActor("ChatAction") + .reportHide(); + let elt = document.getElementById( + "context-sep-messageactions" + ).nextElementSibling; + // remove the action menuitems added last time we opened the popup + while (elt && elt.localName != "menuseparator") { + let tmp = elt.nextElementSibling; + elt.remove(); + elt = tmp; + } + }, + + /** + * Initialize context menu. Shows/hides relevant items. Message actions are + * handled separately in |initActions| if the actor gets them after this is + * called. + * + * @param {XULMenuPopupElement} aPopup - The popup to initialize on. + */ + initMenu(aPopup) { + this.menu = aPopup; + + // Get contextual info. + this.setTarget(); + + this.isTextSelected = this.isTextSelection(); + this.isContentSelected = this.isContentSelection(); + + // Initialize (disable/remove) menu items. + // Open/Save/Send link depends on whether we're in a link. + var shouldShow = this.onSaveableLink; + this.showItem("context-openlink", shouldShow); + this.showItem("context-sep-open", shouldShow); + this.showItem("context-savelink", shouldShow); + + // Copy depends on whether there is selected text. + // Enabling this context menu item is now done through the global + // command updating system + goUpdateGlobalEditMenuItems(); + + this.showItem("context-copy", this.isContentSelected); + this.showItem("context-selectall", !this.onLink || this.isContentSelected); + if (!this.initedActions) { + let actor = + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal?.getActor( + "ChatAction" + ); + if (actor?.actions) { + this.initActions(actor.actions); + } else { + this.showItem("context-sep-messageactions", false); + } + } + + // Copy email link depends on whether we're on an email link. + this.showItem("context-copyemail", this.onMailtoLink); + + // Copy link location depends on whether we're on a non-mailto link. + this.showItem("context-copylink", this.onLink && !this.onMailtoLink); + this.showItem( + "context-sep-copylink", + this.onLink && this.isContentSelected + ); + }, + + /** + * Adds the given message actions to the context menu. + * + * @param {Array<string>} actions - Array containing the labels for the + * available actions. + */ + initActions(actions) { + this.showItem("context-sep-messageactions", actions.length > 0); + + // Display action menu items. + let sep = document.getElementById("context-sep-messageactions"); + for (let [index, label] of actions.entries()) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", label); + menuitem.addEventListener("command", () => { + nsContextMenu.contentData.browser.browsingContext.currentWindowGlobal + ?.getActor("ChatAction") + .sendAsyncMessage("ChatAction:Run", { index }); + }); + sep.parentNode.appendChild(menuitem); + } + this.initedActions = true; + }, + + // Set various context menu attributes based on the state of the world. + setTarget() { + // Initialize contextual info. + this.onLink = nsContextMenu.contentData.context.onLink; + this.linkURL = nsContextMenu.contentData.context.linkURL; + this.linkURI = this.getLinkURI(); + this.linkProtocol = nsContextMenu.contentData.context.linkProtocol; + this.linkText = nsContextMenu.contentData.context.linkTextStr; + this.onMailtoLink = nsContextMenu.contentData.context.onMailtoLink; + this.onSaveableLink = nsContextMenu.contentData.context.onSaveableLink; + }, + + // Open linked-to URL in a new window. + openLink(aURI) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(aURI || this.linkURI, nsContextMenu.contentData.principal); + }, + + // Generate email address and put it on clipboard. + copyEmail() { + // Copy the comma-separated list of email addresses only. + // There are other ways of embedding email addresses in a mailto: + // link, but such complex parsing is beyond us. + var url = this.linkURL; + var qmark = url.indexOf("?"); + var addresses; + + // 7 == length of "mailto:" + addresses = qmark > 7 ? url.substring(7, qmark) : url.substr(7); + + // Let's try to unescape it using a character set + // in case the address is not ASCII. + try { + var characterSet = this.target.ownerDocument.characterSet; + addresses = Services.textToSubURI.unEscapeURIForUI( + characterSet, + addresses + ); + } catch (ex) { + // Do nothing. + } + + var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(addresses); + }, + + // --------- + // Utilities + + // Show/hide one item (specified via name or the item element itself). + showItem(aItemOrId, aShow) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + if (item) { + item.hidden = !aShow; + } + }, + + // Temporary workaround for DOM api not yet implemented by XUL nodes. + cloneNode(aItem) { + // Create another element like the one we're cloning. + var node = document.createXULElement(aItem.tagName); + + // Copy attributes from argument item to the new one. + var attrs = aItem.attributes; + for (var i = 0; i < attrs.length; i++) { + var attr = attrs.item(i); + node.setAttribute(attr.nodeName, attr.nodeValue); + } + + // Voila! + return node; + }, + + getLinkURI() { + try { + return Services.io.newURI(this.linkURL); + } catch (ex) { + // e.g. empty URL string + } + + return null; + }, + + // Get selected text. Only display the first 15 chars. + isTextSelection() { + // Get 16 characters, so that we can trim the selection if it's greater + // than 15 chars + var selectedText = getBrowserSelection(16); + + if (!selectedText) { + return false; + } + + if (selectedText.length > 15) { + selectedText = selectedText.substr(0, 15) + this.ellipsis; + } + + return true; + }, + + // Returns true if anything is selected. + isContentSelection() { + return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed; + }, +}; + +/** + * Gets the selected text in the active browser. Leading and trailing + * whitespace is removed, and consecutive whitespace is replaced by a single + * space. A maximum of 150 characters will be returned, regardless of the value + * of aCharLen. + * + * @param aCharLen + * The maximum number of characters to return. + */ +function getBrowserSelection(aCharLen) { + // selections of more than 150 characters aren't useful + const kMaxSelectionLen = 150; + const charLen = Math.min(aCharLen || kMaxSelectionLen, kMaxSelectionLen); + + var focusedWindow = document.commandDispatcher.focusedWindow; + var selection = focusedWindow.getSelection().toString(); + + if (selection) { + if (selection.length > charLen) { + // only use the first charLen important chars. see bug 221361 + var pattern = new RegExp("^(?:\\s*.){0," + charLen + "}"); + pattern.test(selection); + selection = RegExp.lastMatch; + } + + selection = selection.trim().replace(/\s+/g, " "); + + if (selection.length > charLen) { + selection = selection.substr(0, charLen); + } + } + return selection; +} diff --git a/comm/mail/components/im/content/imStatusSelector.js b/comm/mail/components/im/content/imStatusSelector.js new file mode 100644 index 0000000000..69bbc2776a --- /dev/null +++ b/comm/mail/components/im/content/imStatusSelector.js @@ -0,0 +1,383 @@ +/* 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 { Status } = ChromeUtils.importESModule( + "resource:///modules/imStatusUtils.sys.mjs" +); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var statusSelector = { + observe(aSubject, aTopic, aMsg) { + if (aTopic == "status-changed") { + this.displayCurrentStatus(); + } else if (aTopic == "user-icon-changed") { + this.displayUserIcon(); + } else if (aTopic == "user-display-name-changed") { + this.displayUserDisplayName(); + } + }, + + displayUserIcon() { + let icon = IMServices.core.globalUserStatus.getUserIcon(); + ChatIcons.setUserIconSrc( + document.getElementById("userIcon"), + icon?.spec, + true + ); + }, + + displayUserDisplayName() { + let displayName = IMServices.core.globalUserStatus.displayName; + let elt = document.getElementById("displayName"); + if (displayName) { + elt.removeAttribute("usingDefault"); + } else { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + displayName = bundle.GetStringFromName("displayNameEmptyText"); + elt.setAttribute("usingDefault", displayName); + } + elt.setAttribute("value", displayName); + }, + + displayStatusType(aStatusType) { + document + .getElementById("statusMessageLabel") + .setAttribute("statusType", aStatusType); + let statusString = Status.toLabel(aStatusType); + let statusTypeIcon = document.getElementById("statusTypeIcon"); + statusTypeIcon.setAttribute("status", aStatusType); + statusTypeIcon.setAttribute("tooltiptext", statusString); + return statusString; + }, + + displayCurrentStatus() { + let us = IMServices.core.globalUserStatus; + let status = Status.toAttribute(us.statusType); + let message = status == "offline" ? "" : us.statusText; + let statusMessage = document.getElementById("statusMessageLabel"); + if (!statusMessage) { + // Chat toolbar not in the DOM yet + return; + } + if (message) { + statusMessage.removeAttribute("usingDefault"); + } else { + let statusString = this.displayStatusType(status); + statusMessage.setAttribute("usingDefault", statusString); + message = statusString; + } + statusMessage.setAttribute("value", message); + statusMessage.setAttribute("tooltiptext", message); + }, + + editStatus(aEvent) { + let status = aEvent.target.getAttribute("status"); + if (status == "offline") { + IMServices.core.globalUserStatus.setStatus( + Ci.imIStatusInfo.STATUS_OFFLINE, + "" + ); + } else if (status) { + this.startEditStatus(status); + } + }, + + startEditStatus(aStatusType) { + let currentStatusType = document + .getElementById("statusTypeIcon") + .getAttribute("status"); + if (aStatusType != currentStatusType) { + this._statusTypeBeforeEditing = currentStatusType; + this._statusTypeEditing = aStatusType; + this.displayStatusType(aStatusType); + } + this.statusMessageClick(); + }, + + statusMessageClick() { + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + statusMessage.setAttribute("hidden", "true"); + statusMessageInput.removeAttribute("hidden"); + let statusType = document + .getElementById("statusTypeIcon") + .getAttribute("status"); + if (statusType == "offline" || statusMessage.disabled) { + return; + } + + if (!statusMessageInput.hasAttribute("editing")) { + statusMessageInput.setAttribute("editing", "true"); + statusMessageInput.addEventListener("blur", event => { + this.finishEditStatusMessage(true); + }); + if (statusMessage.hasAttribute("usingDefault")) { + if ( + "_statusTypeBeforeEditing" in this && + this._statusTypeBeforeEditing == "offline" + ) { + statusMessageInput.setAttribute( + "value", + IMServices.core.globalUserStatus.statusText + ); + } else { + statusMessageInput.removeAttribute("value"); + } + } else { + statusMessageInput.setAttribute( + "value", + statusMessage.getAttribute("value") + ); + } + + if (Services.prefs.getBoolPref("mail.spellcheck.inline")) { + statusMessageInput.setAttribute("spellcheck", "true"); + } else { + statusMessageInput.removeAttribute("spellcheck"); + } + + // force binding attachment by forcing layout + statusMessageInput.getBoundingClientRect(); + statusMessageInput.select(); + } + + this.statusMessageRefreshTimer(); + }, + + statusMessageRefreshTimer() { + const timeBeforeAutoValidate = 20 * 1000; + if ("_stopEditStatusTimeout" in this) { + clearTimeout(this._stopEditStatusTimeout); + } + this._stopEditStatusTimeout = setTimeout( + this.finishEditStatusMessage, + timeBeforeAutoValidate, + true + ); + }, + + statusMessageKeyPress(aEvent) { + if (!this.hasAttribute("editing")) { + if (aEvent.keyCode == aEvent.DOM_VK_DOWN) { + let button = document.getElementById("statusTypeIcon"); + document.getElementById("setStatusTypeMenupopup").openPopup(button); + } + return; + } + + switch (aEvent.keyCode) { + case aEvent.DOM_VK_RETURN: + statusSelector.finishEditStatusMessage(true); + break; + + case aEvent.DOM_VK_ESCAPE: + statusSelector.finishEditStatusMessage(false); + break; + + default: + statusSelector.statusMessageRefreshTimer(); + } + }, + + finishEditStatusMessage(aSave) { + clearTimeout(this._stopEditStatusTimeout); + delete this._stopEditStatusTimeout; + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + statusMessage.removeAttribute("hidden"); + statusMessageInput.toggleAttribute("hidden", "true"); + if (aSave) { + let newStatus = Ci.imIStatusInfo.STATUS_UNKNOWN; + if ("_statusTypeEditing" in this) { + let statusType = this._statusTypeEditing; + if (statusType == "available") { + newStatus = Ci.imIStatusInfo.STATUS_AVAILABLE; + } else if (statusType == "unavailable") { + newStatus = Ci.imIStatusInfo.STATUS_UNAVAILABLE; + } else if (statusType == "offline") { + newStatus = Ci.imIStatusInfo.STATUS_OFFLINE; + } + delete this._statusTypeBeforeEditing; + delete this._statusTypeEditing; + } + // apply the new status only if it is different from the current one + if ( + newStatus != Ci.imIStatusInfo.STATUS_UNKNOWN || + statusMessageInput.value != statusMessageInput.getAttribute("value") + ) { + IMServices.core.globalUserStatus.setStatus( + newStatus, + statusMessageInput.value + ); + } + } else if ("_statusTypeBeforeEditing" in this) { + this.displayStatusType(this._statusTypeBeforeEditing); + delete this._statusTypeBeforeEditing; + delete this._statusTypeEditing; + } + + if (statusMessage.hasAttribute("usingDefault")) { + statusMessage.setAttribute( + "value", + statusMessage.getAttribute("usingDefault") + ); + } + + statusMessageInput.removeAttribute("editing"); + statusMessageInput.removeEventListener("blur", event => { + this.finishEditStatusMessage(true); + }); + + // We need to put the focus back on the label after the textbox + // binding has been detached, otherwise the focus gets lost (it's + // on none of the elements in the document), but before that we + // need to flush the layout. + statusMessageInput.getBoundingClientRect(); + statusMessageInput.focus(); + }, + + userIconClick() { + const nsIFilePicker = Ci.nsIFilePicker; + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + fp.init( + window, + bundle.GetStringFromName("userIconFilePickerTitle"), + nsIFilePicker.modeOpen + ); + fp.appendFilters(nsIFilePicker.filterImages); + fp.open(rv => { + if (rv != nsIFilePicker.returnOK || !fp.file) { + return; + } + IMServices.core.globalUserStatus.setUserIcon(fp.file); + }); + }, + + displayNameClick() { + let displayName = document.getElementById("displayName"); + let displayNameInput = document.getElementById("displayNameInput"); + displayName.setAttribute("hidden", "true"); + displayNameInput.removeAttribute("hidden"); + if (!displayNameInput.hasAttribute("editing")) { + displayNameInput.setAttribute("editing", "true"); + if (displayName.hasAttribute("usingDefault")) { + displayNameInput.removeAttribute("value"); + } else { + displayNameInput.setAttribute( + "value", + displayName.getAttribute("value") + ); + } + displayNameInput.addEventListener("keypress", this.displayNameKeyPress); + displayNameInput.addEventListener("blur", event => { + this.finishEditDisplayName(true); + }); + // force binding attachment by forcing layout + displayNameInput.getBoundingClientRect(); + displayNameInput.select(); + } + + this.displayNameRefreshTimer(); + }, + + _stopEditDisplayNameTimeout: 0, + displayNameRefreshTimer() { + const timeBeforeAutoValidate = 20 * 1000; + clearTimeout(this._stopEditDisplayNameTimeout); + this._stopEditDisplayNameTimeout = setTimeout( + this.finishEditDisplayName, + timeBeforeAutoValidate, + true + ); + }, + + displayNameKeyPress(aEvent) { + switch (aEvent.keyCode) { + case aEvent.DOM_VK_RETURN: + statusSelector.finishEditDisplayName(true); + break; + + case aEvent.DOM_VK_ESCAPE: + statusSelector.finishEditDisplayName(false); + break; + + default: + statusSelector.displayNameRefreshTimer(); + } + }, + + finishEditDisplayName(aSave) { + clearTimeout(this._stopEditDisplayNameTimeout); + let displayName = document.getElementById("displayName"); + let displayNameInput = document.getElementById("displayNameInput"); + displayName.removeAttribute("hidden"); + displayNameInput.toggleAttribute("hidden", "true"); + // Apply the new display name only if it is different from the current one. + if ( + aSave && + displayNameInput.value != displayNameInput.getAttribute("value") + ) { + IMServices.core.globalUserStatus.displayName = displayNameInput.value; + } else if (displayName.hasAttribute("usingDefault")) { + displayName.setAttribute( + "value", + displayName.getAttribute("usingDefault") + ); + } + + displayNameInput.removeAttribute("editing"); + displayNameInput.removeEventListener("keypress", this.displayNameKeyPress); + displayNameInput.removeEventListener("blur", event => { + this.finishEditDisplayName(true); + }); + }, + + init() { + let events = ["status-changed"]; + statusSelector.displayCurrentStatus(); + + if (document.getElementById("displayName")) { + events.push("user-display-name-changed"); + statusSelector.displayUserDisplayName(); + } + + if (document.getElementById("userIcon")) { + events.push("user-icon-changed"); + statusSelector.displayUserIcon(); + } + + let statusMessage = document.getElementById("statusMessageLabel"); + let statusMessageInput = document.getElementById("statusMessageInput"); + if (statusMessage && statusMessageInput) { + statusMessage.addEventListener("keypress", this.statusMessageKeyPress); + statusMessageInput.addEventListener( + "keypress", + this.statusMessageKeyPress + ); + } + + for (let event of events) { + Services.obs.addObserver(statusSelector, event); + } + statusSelector._events = events; + + window.addEventListener("unload", statusSelector.unload); + }, + + unload() { + for (let event of statusSelector._events) { + Services.obs.removeObserver(statusSelector, event); + } + }, +}; diff --git a/comm/mail/components/im/content/joinchat.js b/comm/mail/components/im/content/joinchat.js new file mode 100644 index 0000000000..ae4029eb5a --- /dev/null +++ b/comm/mail/components/im/content/joinchat.js @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +var autoJoinPref = "autoJoin"; + +var joinChat = { + onload() { + var accountList = document.getElementById("accountlist"); + for (let acc of IMServices.accounts.getAccounts()) { + if (!acc.connected || !acc.canJoinChat) { + continue; + } + var proto = acc.protocol; + var item = accountList.appendItem(acc.name, acc.id, proto.name); + item.setAttribute("image", ChatIcons.getProtocolIconURI(proto)); + item.setAttribute("class", "menuitem-iconic"); + item.account = acc; + } + if (!accountList.itemCount) { + document + .getElementById("joinChatDialog") + .querySelector("dialog") + .cancelDialog(); + throw new Error("No connected MUC enabled account!"); + } + accountList.selectedIndex = 0; + }, + + onAccountSelect() { + let joinChatGrid = document.getElementById("joinChatGrid"); + while (joinChatGrid.children.length > 3) { + // leave the first 3 cols + joinChatGrid.lastChild.remove(); + } + + let acc = document.getElementById("accountlist").selectedItem.account; + let defaultValues = acc.getChatRoomDefaultFieldValues(); + joinChat._values = defaultValues; + joinChat._fields = []; + joinChat._account = acc; + + let protoId = acc.protocol.id; + document.getElementById("autojoin").hidden = !( + protoId == "prpl-irc" || + protoId == "prpl-jabber" || + protoId == "prpl-gtalk" + ); + + for (let field of acc.getChatRoomFields()) { + let div1 = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + let label = document.createXULElement("label"); + let text = field.label; + let match = /_(.)/.exec(text); + if (match) { + label.setAttribute("accesskey", match[1]); + text = text.replace(/_/, ""); + } + label.setAttribute("value", text); + label.setAttribute("control", "field-" + field.identifier); + label.setAttribute("id", "field-" + field.identifier + "-label"); + div1.appendChild(label); + joinChatGrid.appendChild(div1); + + let div2 = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + let input = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + input.classList.add("input-inline"); + input.setAttribute("id", "field-" + field.identifier); + input.setAttribute( + "aria-labelledby", + "field-" + field.identifier + "-label" + ); + let val = defaultValues.getValue(field.identifier); + if (val) { + input.setAttribute("value", val); + } + if (field.type == Ci.prplIChatRoomField.TYPE_PASSWORD) { + input.setAttribute("type", "password"); + } else if (field.type == Ci.prplIChatRoomField.TYPE_INT) { + input.setAttribute("type", "number"); + input.setAttribute("min", field.min); + input.setAttribute("max", field.max); + } else { + input.setAttribute("type", "text"); + } + div2.appendChild(input); + joinChatGrid.appendChild(div2); + + let div3 = document.querySelector(".optional-col").cloneNode(true); + div3.classList.toggle("required", field.required); + joinChatGrid.appendChild(div3); + + joinChat._fields.push({ field, input }); + } + + window.sizeToContent(); + }, + + join() { + let values = joinChat._values; + for (let field of joinChat._fields) { + let val = field.input.value.trim(); + if (!val && field.field.required) { + field.input.focus(); + // FIXME: why isn't the return false enough? + throw new Error("Some required fields are empty!"); + // return false; + } + if (val) { + values.setValue(field.field.identifier, val); + } + } + let account = joinChat._account; + account.joinChat(values); + + let protoId = account.protocol.id; + if ( + protoId != "prpl-irc" && + protoId != "prpl-jabber" && + protoId != "prpl-gtalk" + ) { + return; + } + + let name; + if (protoId == "prpl-irc") { + name = values.getValue("channel"); + } else { + name = values.getValue("room") + "@" + values.getValue("server"); + } + + let conv = IMServices.conversations.getConversationByNameAndAccount( + name, + account, + true + ); + if (conv) { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mailWindow) { + mailWindow.focus(); + let tabmail = mailWindow.document.getElementById("tabmail"); + tabmail.openTab("chat", { convType: "focus", conv }); + } + } + + if (document.getElementById("autojoin").checked) { + // "nick" for JS-XMPP, "handle" for libpurple prpls. + let nick = values.getValue("nick") || values.getValue("handle"); + if (nick) { + name += "/" + nick; + } + + let prefBranch = Services.prefs.getBranch( + "messenger.account." + account.id + "." + ); + let autojoin = []; + if (prefBranch.prefHasUserValue(autoJoinPref)) { + let prefValue = prefBranch.getStringPref(autoJoinPref); + if (prefValue) { + autojoin = prefValue.split(","); + } + } + + if (!autojoin.includes(name)) { + autojoin.push(name); + prefBranch.setStringPref(autoJoinPref, autojoin.join(",")); + } + } + }, +}; + +document.addEventListener("dialogaccept", joinChat.join); + +window.addEventListener("DOMContentLoaded", event => { + joinChat.onload(); +}); +window.addEventListener("load", event => { + window.sizeToContent(); +}); diff --git a/comm/mail/components/im/content/joinchat.xhtml b/comm/mail/components/im/content/joinchat.xhtml new file mode 100644 index 0000000000..8bd5753e91 --- /dev/null +++ b/comm/mail/components/im/content/joinchat.xhtml @@ -0,0 +1,58 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/menulist.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/joinchat.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> + +<!DOCTYPE html SYSTEM "chrome://messenger/locale/joinChat.dtd"> + +<html + id="joinChatDialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + scrolling="false" +> + <head> + <title>&joinChatWindow.title;</title> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/joinchat.js" + ></script> + </head> + <body> + <xul:dialog buttons="accept,cancel"> + <div id="joinChatGrid"> + <div> + <xul:label value="&account.label;" control="accountlist" /> + </div> + <div> + <xul:menulist + id="accountlist" + onselect="joinChat.onAccountSelect();" + /> + </div> + <div class="optional-col required">&optional.label;</div> + </div> + <xul:hbox> + <xul:checkbox + id="autojoin" + label="&autojoin.label;" + accesskey="&autojoin.accesskey;" + /> + </xul:hbox> + </xul:dialog> + </body> +</html> diff --git a/comm/mail/components/im/content/toolbarbutton-badge-button.js b/comm/mail/components/im/content/toolbarbutton-badge-button.js new file mode 100644 index 0000000000..def96faf27 --- /dev/null +++ b/comm/mail/components/im/content/toolbarbutton-badge-button.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozBadgebutton widget is used to display a chat toolbar button in + * the main Toolbox in the messenger window. It displays icon and label + * for the button. It also shows a badge on top of the chat icon with a number. + * That number is the count of unread messages in the chat. + * + * @augments MozToolbarbutton + */ + class MozBadgebutton extends customElements.get("toolbarbutton") { + static get inheritedAttributes() { + return { + ".toolbarbutton-icon": "src=image", + ".toolbarbutton-text": "value=label,accesskey,crop", + }; + } + + static get markup() { + return ` + <stack> + <html:img class="toolbarbutton-icon" alt="" /> + <html:span class="badgeButton-badge" hidden="hidden"></html:span> + </stack> + <label class="toolbarbutton-text" crop="end" flex="1"></label> + `; + } + + /** + * toolbarbutton overwrites the fragment getter from MozXULElement. + */ + static get fragment() { + return Reflect.get(MozXULElement, "fragment", this); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.setAttribute("is", "toolbarbutton-badge-button"); + this.appendChild(this.constructor.fragment); + + this._badgeCount = 0; + this.initializeAttributeInheritance(); + } + + set badgeCount(count) { + this._badgeCount = count; + let badge = this.querySelector(".badgeButton-badge"); + badge.textContent = count; + badge.hidden = count == 0; + } + + get badgeCount() { + return this._badgeCount; + } + } + + customElements.define("toolbarbutton-badge-button", MozBadgebutton, { + extends: "toolbarbutton", + }); +} diff --git a/comm/mail/components/im/content/verify.js b/comm/mail/components/im/content/verify.js new file mode 100644 index 0000000000..fbe39d6a50 --- /dev/null +++ b/comm/mail/components/im/content/verify.js @@ -0,0 +1,53 @@ +/* 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 verifySession = { + onload() { + this.sessionVerification = + window.arguments[0].wrappedJSObject || window.arguments[0]; + if ( + this.sessionVerification.challengeType !== + Ci.imISessionVerification.CHALLENGE_TEXT + ) { + throw new Error("Unsupported challenge type"); + } + document.l10n.setAttributes( + document.querySelector("title"), + "verify-window-subject-title", + { + subject: this.sessionVerification.subject, + } + ); + document.getElementById("challenge").textContent = + this.sessionVerification.challenge; + if (this.sessionVerification.challengeDescription) { + let description = document.getElementById("challengeDescription"); + description.hidden = false; + description.textContent = this.sessionVerification.challengeDescription; + } + document.addEventListener("dialogaccept", () => { + this.sessionVerification.submitResponse(true); + }); + document.addEventListener("dialogextra2", () => { + this.sessionVerification.submitResponse(false); + document + .getElementById("verifySessionDialog") + .querySelector("dialog") + .acceptDialog(); + }); + document.addEventListener("dialogcancel", () => { + this.sessionVerification.cancel(); + }); + this.sessionVerification.completePromise.catch(() => { + document + .getElementById("verifySessionDialog") + .querySelector("dialog") + .cancelDialog(); + }); + }, +}; + +window.addEventListener("load", event => { + verifySession.onload(); +}); diff --git a/comm/mail/components/im/content/verify.xhtml b/comm/mail/components/im/content/verify.xhtml new file mode 100644 index 0000000000..930ae81e5d --- /dev/null +++ b/comm/mail/components/im/content/verify.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html + id="verifySessionDialog" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + scrolling="false" +> + <head> + <title data-l10n-id="verify-window-title"></title> + <link rel="localization" href="messenger/chat-verifySession.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/verifychat.css" /> + <link + rel="stylesheet" + href="chrome://messenger/skin/shared/grid-layout.css" + /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/chat/verify.js" + ></script> + </head> + <body> + <xul:dialog + buttons="accept,cancel,extra2" + data-l10n-id="verify-dialog" + data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept, buttonlabelextra2, buttonaccesskeyextra2" + > + <p data-l10n-id="challenge-label"></p> + <p id="challengePresentation"> + <span id="challenge"></span> + <!-- Describes the text content of #challenge in an alternative way. + - E.g. if #challenge is a sequence of emojis, then + - #challengeDescription would be a sequence of emoji names. --> + <span id="challengeDescription" role="note" hidden="hidden"></span> + </p> + </xul:dialog> + </body> +</html> diff --git a/comm/mail/components/im/jar.mn b/comm/mail/components/im/jar.mn new file mode 100644 index 0000000000..98b7735afc --- /dev/null +++ b/comm/mail/components/im/jar.mn @@ -0,0 +1,199 @@ +# 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/. + +messenger.jar: + content/messenger/chat/chat-messenger.js (content/chat-messenger.js) + content/messenger/am-im.js (content/am-im.js) + content/messenger/am-im.xhtml (content/am-im.xhtml) + content/messenger/chat/addbuddy.js (content/addbuddy.js) + content/messenger/chat/addbuddy.xhtml (content/addbuddy.xhtml) + content/messenger/chat/joinchat.js (content/joinchat.js) + content/messenger/chat/joinchat.xhtml (content/joinchat.xhtml) + content/messenger/chat/imAccounts.js (content/imAccounts.js) + content/messenger/chat/imAccounts.xhtml (content/imAccounts.xhtml) + content/messenger/chat/imAccountWizard.xhtml (content/imAccountWizard.xhtml) + content/messenger/chat/imAccountWizard.js (content/imAccountWizard.js) + content/messenger/chat/imContextMenu.js (content/imContextMenu.js) + content/messenger/chat/chat-conversation.js (content/chat-conversation.js) + content/messenger/chat/imStatusSelector.js (content/imStatusSelector.js) + content/messenger/chat/chat-contact.js (content/chat-contact.js) + content/messenger/chat/chat-group.js (content/chat-group.js) + content/messenger/chat/chat-imconv.js (content/chat-imconv.js) + content/messenger/chat/chat-conversation-info.js (content/chat-conversation-info.js) + content/messenger/chat/toolbarbutton-badge-button.js (content/toolbarbutton-badge-button.js) + content/messenger/chat/verify.js (content/verify.js) + content/messenger/chat/verify.xhtml (content/verify.xhtml) +% skin messenger-messagestyles classic/1.0 %skin/classic/messenger/messages/ + skin/classic/messenger/messages/mail/inline.js (messages/mail/inline.js) + skin/classic/messenger/messages/mail/Incoming/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg) + skin/classic/messenger/messages/mail/Outgoing/buddy_icon.svg (messages/mail/Incoming/buddy_icon.svg) + skin/classic/messenger/messages/mail/Incoming/Content.html (messages/mail/Incoming/Content.html) + skin/classic/messenger/messages/mail/Incoming/Context.html (messages/mail/Incoming/Context.html) + skin/classic/messenger/messages/mail/Incoming/NextContent.html (messages/mail/Incoming/NextContent.html) + skin/classic/messenger/messages/mail/Incoming/NextContext.html (messages/mail/Incoming/NextContext.html) + skin/classic/messenger/messages/mail/Outgoing/Content.html (messages/mail/Outgoing/Content.html) + skin/classic/messenger/messages/mail/Outgoing/Context.html (messages/mail/Outgoing/Context.html) + skin/classic/messenger/messages/mail/Outgoing/NextContent.html (messages/mail/Outgoing/NextContent.html) + skin/classic/messenger/messages/mail/Outgoing/NextContext.html (messages/mail/Outgoing/NextContext.html) + skin/classic/messenger/messages/mail/Footer.html (messages/mail/Footer.html) + skin/classic/messenger/messages/mail/Header.html (messages/mail/Header.html) + skin/classic/messenger/messages/mail/Info.plist (messages/mail/Info.plist) + skin/classic/messenger/messages/mail/main.css (messages/mail/main.css) + skin/classic/messenger/messages/mail/NextStatus.html (messages/mail/NextStatus.html) + skin/classic/messenger/messages/mail/Status.html (messages/mail/Status.html) + skin/classic/messenger/messages/mail/Variants/Dark.css (messages/mail/Variants/Dark.css) + skin/classic/messenger/messages/mail/Variants/Light.css (messages/mail/Variants/Light.css) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0.png (messages/bubbles/Bitmaps/indicator_0.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_0_alt.png (messages/bubbles/Bitmaps/indicator_0_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10.png (messages/bubbles/Bitmaps/indicator_10.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100.png (messages/bubbles/Bitmaps/indicator_100.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_100_alt.png (messages/bubbles/Bitmaps/indicator_100_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_10_alt.png (messages/bubbles/Bitmaps/indicator_10_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110.png (messages/bubbles/Bitmaps/indicator_110.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_110_alt.png (messages/bubbles/Bitmaps/indicator_110_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120.png (messages/bubbles/Bitmaps/indicator_120.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_120_alt.png (messages/bubbles/Bitmaps/indicator_120_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130.png (messages/bubbles/Bitmaps/indicator_130.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_130_alt.png (messages/bubbles/Bitmaps/indicator_130_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140.png (messages/bubbles/Bitmaps/indicator_140.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_140_alt.png (messages/bubbles/Bitmaps/indicator_140_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150.png (messages/bubbles/Bitmaps/indicator_150.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_150_alt.png (messages/bubbles/Bitmaps/indicator_150_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160.png (messages/bubbles/Bitmaps/indicator_160.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_160_alt.png (messages/bubbles/Bitmaps/indicator_160_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170.png (messages/bubbles/Bitmaps/indicator_170.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_170_alt.png (messages/bubbles/Bitmaps/indicator_170_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180.png (messages/bubbles/Bitmaps/indicator_180.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_180_alt.png (messages/bubbles/Bitmaps/indicator_180_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190.png (messages/bubbles/Bitmaps/indicator_190.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_190_alt.png (messages/bubbles/Bitmaps/indicator_190_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20.png (messages/bubbles/Bitmaps/indicator_20.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200.png (messages/bubbles/Bitmaps/indicator_200.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_200_alt.png (messages/bubbles/Bitmaps/indicator_200_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_20_alt.png (messages/bubbles/Bitmaps/indicator_20_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210.png (messages/bubbles/Bitmaps/indicator_210.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_210_alt.png (messages/bubbles/Bitmaps/indicator_210_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220.png (messages/bubbles/Bitmaps/indicator_220.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_220_alt.png (messages/bubbles/Bitmaps/indicator_220_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230.png (messages/bubbles/Bitmaps/indicator_230.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_230_alt.png (messages/bubbles/Bitmaps/indicator_230_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240.png (messages/bubbles/Bitmaps/indicator_240.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_240_alt.png (messages/bubbles/Bitmaps/indicator_240_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250.png (messages/bubbles/Bitmaps/indicator_250.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_250_alt.png (messages/bubbles/Bitmaps/indicator_250_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260.png (messages/bubbles/Bitmaps/indicator_260.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_260_alt.png (messages/bubbles/Bitmaps/indicator_260_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270.png (messages/bubbles/Bitmaps/indicator_270.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_270_alt.png (messages/bubbles/Bitmaps/indicator_270_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280.png (messages/bubbles/Bitmaps/indicator_280.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_280_alt.png (messages/bubbles/Bitmaps/indicator_280_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290.png (messages/bubbles/Bitmaps/indicator_290.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_290_alt.png (messages/bubbles/Bitmaps/indicator_290_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30.png (messages/bubbles/Bitmaps/indicator_30.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300.png (messages/bubbles/Bitmaps/indicator_300.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_300_alt.png (messages/bubbles/Bitmaps/indicator_300_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_30_alt.png (messages/bubbles/Bitmaps/indicator_30_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310.png (messages/bubbles/Bitmaps/indicator_310.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_310_alt.png (messages/bubbles/Bitmaps/indicator_310_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320.png (messages/bubbles/Bitmaps/indicator_320.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_320_alt.png (messages/bubbles/Bitmaps/indicator_320_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330.png (messages/bubbles/Bitmaps/indicator_330.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_330_alt.png (messages/bubbles/Bitmaps/indicator_330_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340.png (messages/bubbles/Bitmaps/indicator_340.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_340_alt.png (messages/bubbles/Bitmaps/indicator_340_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350.png (messages/bubbles/Bitmaps/indicator_350.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_350_alt.png (messages/bubbles/Bitmaps/indicator_350_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40.png (messages/bubbles/Bitmaps/indicator_40.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_40_alt.png (messages/bubbles/Bitmaps/indicator_40_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50.png (messages/bubbles/Bitmaps/indicator_50.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_50_alt.png (messages/bubbles/Bitmaps/indicator_50_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60.png (messages/bubbles/Bitmaps/indicator_60.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_60_alt.png (messages/bubbles/Bitmaps/indicator_60_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70.png (messages/bubbles/Bitmaps/indicator_70.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_70_alt.png (messages/bubbles/Bitmaps/indicator_70_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80.png (messages/bubbles/Bitmaps/indicator_80.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_80_alt.png (messages/bubbles/Bitmaps/indicator_80_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90.png (messages/bubbles/Bitmaps/indicator_90.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_90_alt.png (messages/bubbles/Bitmaps/indicator_90_alt.png) + skin/classic/messenger/messages/bubbles/Bitmaps/indicator_grey.png (messages/bubbles/Bitmaps/indicator_grey.png) + skin/classic/messenger/messages/bubbles/Bitmaps/minus-hover.png (messages/bubbles/Bitmaps/minus-hover.png) + skin/classic/messenger/messages/bubbles/Bitmaps/minus.png (messages/bubbles/Bitmaps/minus.png) + skin/classic/messenger/messages/bubbles/Bitmaps/plus-hover.png (messages/bubbles/Bitmaps/plus-hover.png) + skin/classic/messenger/messages/bubbles/Bitmaps/plus.png (messages/bubbles/Bitmaps/plus.png) + skin/classic/messenger/messages/bubbles/Footer.html (messages/bubbles/Footer.html) + skin/classic/messenger/messages/bubbles/inline.js (messages/bubbles/inline.js) + skin/classic/messenger/messages/bubbles/Incoming/Content.html (messages/bubbles/Incoming/Content.html) + skin/classic/messenger/messages/bubbles/Incoming/Context.html (messages/bubbles/Incoming/Context.html) + skin/classic/messenger/messages/bubbles/Incoming/NextContent.html (messages/bubbles/Incoming/NextContent.html) + skin/classic/messenger/messages/bubbles/Info.plist (messages/bubbles/Info.plist) + skin/classic/messenger/messages/bubbles/main.css (messages/bubbles/main.css) + skin/classic/messenger/messages/bubbles/NextStatus.html (messages/bubbles/NextStatus.html) + skin/classic/messenger/messages/bubbles/Status.html (messages/bubbles/Status.html) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green_Alternating.css (messages/bubbles/Variants/Blue_-_Green_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Green.css (messages/bubbles/Variants/Blue_-_Green.css) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink_Alternating.css (messages/bubbles/Variants/Blue_-_Pink_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Pink.css (messages/bubbles/Variants/Blue_-_Pink.css) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red_Alternating.css (messages/bubbles/Variants/Blue_-_Red_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Blue_-_Red.css (messages/bubbles/Variants/Blue_-_Red.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue_Alternating.css (messages/bubbles/Variants/Green_-_Blue_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Blue.css (messages/bubbles/Variants/Green_-_Blue.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple_Alternating.css (messages/bubbles/Variants/Green_-_Purple_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Purple.css (messages/bubbles/Variants/Green_-_Purple.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Red_Alternating.css (messages/bubbles/Variants/Green_-_Red_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Green_-_Red.css (messages/bubbles/Variants/Green_-_Red.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue_Alternating.css (messages/bubbles/Variants/Grey_-_Blue_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Blue.css (messages/bubbles/Variants/Grey_-_Blue.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink_Alternating.css (messages/bubbles/Variants/Grey_-_Pink_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Pink.css (messages/bubbles/Variants/Grey_-_Pink.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple_Alternating.css (messages/bubbles/Variants/Grey_-_Purple_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Purple.css (messages/bubbles/Variants/Grey_-_Purple.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red_Alternating.css (messages/bubbles/Variants/Grey_-_Red_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Grey_-_Red.css (messages/bubbles/Variants/Grey_-_Red.css) + skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue_Alternating.css (messages/bubbles/Variants/Pink_-_Blue_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Pink_-_Blue.css (messages/bubbles/Variants/Pink_-_Blue.css) + skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple_Alternating.css (messages/bubbles/Variants/Pink_-_Purple_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Pink_-_Purple.css (messages/bubbles/Variants/Pink_-_Purple.css) + skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green_Alternating.css (messages/bubbles/Variants/Purple_-_Green_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Purple_-_Green.css (messages/bubbles/Variants/Purple_-_Green.css) + skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink_Alternating.css (messages/bubbles/Variants/Purple_-_Pink_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Purple_-_Pink.css (messages/bubbles/Variants/Purple_-_Pink.css) + skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue_Alternating.css (messages/bubbles/Variants/Red_-_Blue_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Red_-_Blue.css (messages/bubbles/Variants/Red_-_Blue.css) + skin/classic/messenger/messages/bubbles/Variants/Red_-_Green_Alternating.css (messages/bubbles/Variants/Red_-_Green_Alternating.css) + skin/classic/messenger/messages/bubbles/Variants/Red_-_Green.css (messages/bubbles/Variants/Red_-_Green.css) + skin/classic/messenger/messages/dark/inline.js (messages/dark/inline.js) + skin/classic/messenger/messages/dark/Incoming/Content.html (messages/dark/Incoming/Content.html) + skin/classic/messenger/messages/dark/Incoming/Context.html (messages/dark/Incoming/Context.html) + skin/classic/messenger/messages/dark/Incoming/NextContent.html (messages/dark/Incoming/NextContent.html) + skin/classic/messenger/messages/dark/Incoming/NextContext.html (messages/dark/Incoming/NextContext.html) + skin/classic/messenger/messages/dark/Info.plist (messages/dark/Info.plist) + skin/classic/messenger/messages/dark/main.css (messages/dark/main.css) + skin/classic/messenger/messages/dark/Status.html (messages/dark/Status.html) + skin/classic/messenger/messages/dark/Variants/Blue.css (messages/dark/Variants/Blue.css) + skin/classic/messenger/messages/dark/Variants/Green.css (messages/dark/Variants/Green.css) + skin/classic/messenger/messages/dark/Variants/Purple.css (messages/dark/Variants/Purple.css) + skin/classic/messenger/messages/dark/Variants/Red.css (messages/dark/Variants/Red.css) + skin/classic/messenger/messages/dark/Variants/Yellow.css (messages/dark/Variants/Yellow.css) + skin/classic/messenger/messages/papersheets/Bitmaps/information.png (messages/papersheets/Bitmaps/information.png) + skin/classic/messenger/messages/papersheets/Bitmaps/minus.png (messages/papersheets/Bitmaps/minus.png) + skin/classic/messenger/messages/papersheets/Bitmaps/plus.png (messages/papersheets/Bitmaps/plus.png) + skin/classic/messenger/messages/papersheets/inline.js (messages/papersheets/inline.js) + skin/classic/messenger/messages/papersheets/Incoming/Content.html (messages/papersheets/Incoming/Content.html) + skin/classic/messenger/messages/papersheets/Incoming/Context.html (messages/papersheets/Incoming/Context.html) + skin/classic/messenger/messages/papersheets/Incoming/NextContent.html (messages/papersheets/Incoming/NextContent.html) + skin/classic/messenger/messages/papersheets/Info.plist (messages/papersheets/Info.plist) + skin/classic/messenger/messages/papersheets/main.css (messages/papersheets/main.css) + skin/classic/messenger/messages/papersheets/NextStatus.html (messages/papersheets/NextStatus.html) + skin/classic/messenger/messages/papersheets/Status.html (messages/papersheets/Status.html) + skin/classic/messenger/messages/papersheets/Variants/White.css (messages/papersheets/Variants/White.css) + skin/classic/messenger/messages/simple/Incoming/Content.html (messages/simple/Incoming/Content.html) + skin/classic/messenger/messages/simple/Incoming/Context.html (messages/simple/Incoming/Context.html) + skin/classic/messenger/messages/simple/Incoming/NextContext.html (messages/simple/Incoming/NextContext.html) + skin/classic/messenger/messages/simple/Info.plist (messages/simple/Info.plist) + skin/classic/messenger/messages/simple/main.css (messages/simple/main.css) + skin/classic/messenger/messages/simple/Status.html (messages/simple/Status.html) + skin/classic/messenger/messages/simple/Variants/Normal.css (messages/simple/Variants/Normal.css) + skin/classic/messenger/messages/simple/Variants/Dark.css (messages/simple/Variants/Dark.css) +% skin messenger-emoticons classic/1.0 %skin/classic/messenger/smileys/ + skin/classic/messenger/smileys/theme.json (smileys/theme.json) diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png Binary files differnew file mode 100644 index 0000000000..eb0051de34 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png Binary files differnew file mode 100644 index 0000000000..9c5890b792 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_0_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png Binary files differnew file mode 100644 index 0000000000..17295f5474 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png Binary files differnew file mode 100644 index 0000000000..fc54959c86 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png Binary files differnew file mode 100644 index 0000000000..218351534b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_100_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png Binary files differnew file mode 100644 index 0000000000..4692e1cf92 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_10_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png Binary files differnew file mode 100644 index 0000000000..bbd8c91b10 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png Binary files differnew file mode 100644 index 0000000000..be6c4b2b08 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_110_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png Binary files differnew file mode 100644 index 0000000000..de40ea9eba --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png Binary files differnew file mode 100644 index 0000000000..d95237d37c --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_120_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png Binary files differnew file mode 100644 index 0000000000..d6360fb7bd --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png Binary files differnew file mode 100644 index 0000000000..5c10415912 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_130_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png Binary files differnew file mode 100644 index 0000000000..2bc8b95efa --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png Binary files differnew file mode 100644 index 0000000000..a0d8e59ce9 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_140_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png Binary files differnew file mode 100644 index 0000000000..572333b2f6 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png Binary files differnew file mode 100644 index 0000000000..f1e1740e91 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_150_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png Binary files differnew file mode 100644 index 0000000000..f2ff22beae --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png Binary files differnew file mode 100644 index 0000000000..ba4118844e --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_160_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png Binary files differnew file mode 100644 index 0000000000..391439be42 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png Binary files differnew file mode 100644 index 0000000000..b3b2683090 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_170_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png Binary files differnew file mode 100644 index 0000000000..b59ffae9b6 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png Binary files differnew file mode 100644 index 0000000000..1a08183e18 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_180_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png Binary files differnew file mode 100644 index 0000000000..8df7a9d569 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png Binary files differnew file mode 100644 index 0000000000..327ed9be66 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_190_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png Binary files differnew file mode 100644 index 0000000000..f5b2d08f2a --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png Binary files differnew file mode 100644 index 0000000000..fd5baf149f --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png Binary files differnew file mode 100644 index 0000000000..a03b2d7a29 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_200_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png Binary files differnew file mode 100644 index 0000000000..2dbb2241a2 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_20_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png Binary files differnew file mode 100644 index 0000000000..8505ef0de8 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png Binary files differnew file mode 100644 index 0000000000..18e3fac3af --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_210_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png Binary files differnew file mode 100644 index 0000000000..02f82c3972 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png Binary files differnew file mode 100644 index 0000000000..d14afacf6d --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_220_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png Binary files differnew file mode 100644 index 0000000000..f9fb364e28 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png Binary files differnew file mode 100644 index 0000000000..13388613e5 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_230_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png Binary files differnew file mode 100644 index 0000000000..8bb8757871 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png Binary files differnew file mode 100644 index 0000000000..bd70b8d77a --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_240_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png Binary files differnew file mode 100644 index 0000000000..b55967823f --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png Binary files differnew file mode 100644 index 0000000000..2b239c315b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_250_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png Binary files differnew file mode 100644 index 0000000000..f9c0cee4fe --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png Binary files differnew file mode 100644 index 0000000000..56839321e2 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_260_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png Binary files differnew file mode 100644 index 0000000000..cec2e2817e --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png Binary files differnew file mode 100644 index 0000000000..ffcbe04eb8 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_270_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png Binary files differnew file mode 100644 index 0000000000..a2e01b5dfa --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png Binary files differnew file mode 100644 index 0000000000..6cf6949f78 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_280_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png Binary files differnew file mode 100644 index 0000000000..b4acbf8631 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png Binary files differnew file mode 100644 index 0000000000..0652f280ef --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_290_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png Binary files differnew file mode 100644 index 0000000000..86b9ea0206 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png Binary files differnew file mode 100644 index 0000000000..36788859bf --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png Binary files differnew file mode 100644 index 0000000000..45e61fccb0 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_300_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png Binary files differnew file mode 100644 index 0000000000..efd75314fa --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_30_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png Binary files differnew file mode 100644 index 0000000000..69f590d967 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png Binary files differnew file mode 100644 index 0000000000..77a2469399 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_310_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png Binary files differnew file mode 100644 index 0000000000..9ad18a0dea --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png Binary files differnew file mode 100644 index 0000000000..0e7a2e35c0 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_320_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png Binary files differnew file mode 100644 index 0000000000..516e309aec --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png Binary files differnew file mode 100644 index 0000000000..9981a24814 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_330_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png Binary files differnew file mode 100644 index 0000000000..60cc155e03 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png Binary files differnew file mode 100644 index 0000000000..cb2860cf66 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_340_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png Binary files differnew file mode 100644 index 0000000000..cc5a303a75 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png Binary files differnew file mode 100644 index 0000000000..dd0ef8da8a --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_350_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png Binary files differnew file mode 100644 index 0000000000..15f010224b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png Binary files differnew file mode 100644 index 0000000000..8d40d43293 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_40_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png Binary files differnew file mode 100644 index 0000000000..7281760571 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png Binary files differnew file mode 100644 index 0000000000..bb4cc9044e --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_50_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png Binary files differnew file mode 100644 index 0000000000..f7d05aae55 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png Binary files differnew file mode 100644 index 0000000000..a939ea98b9 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_60_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png Binary files differnew file mode 100644 index 0000000000..823cd4f2b0 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png Binary files differnew file mode 100644 index 0000000000..85b1781135 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_70_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png Binary files differnew file mode 100644 index 0000000000..0cbff3ee35 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png Binary files differnew file mode 100644 index 0000000000..e51a56935c --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_80_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png Binary files differnew file mode 100644 index 0000000000..758a8f95e3 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png Binary files differnew file mode 100644 index 0000000000..5e41f98397 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_90_alt.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png Binary files differnew file mode 100644 index 0000000000..b3c8e68eba --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/indicator_grey.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png Binary files differnew file mode 100644 index 0000000000..93a69cc789 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus-hover.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png Binary files differnew file mode 100644 index 0000000000..72107d151f --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/minus.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png Binary files differnew file mode 100644 index 0000000000..4509b17c0e --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus-hover.png diff --git a/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png Binary files differnew file mode 100644 index 0000000000..eaf364177d --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Bitmaps/plus.png diff --git a/comm/mail/components/im/messages/bubbles/Footer.html b/comm/mail/components/im/messages/bubbles/Footer.html new file mode 100644 index 0000000000..b024066d50 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Footer.html @@ -0,0 +1,5 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<p id="lastMessage"/> diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Content.html b/comm/mail/components/im/messages/bubbles/Incoming/Content.html new file mode 100644 index 0000000000..f37578f699 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Incoming/Content.html @@ -0,0 +1,7 @@ +<div class="bubble %messageClasses%" data-senderColor="%senderColor%"> +<div class="indicator"> +<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p> +<p class="%messageClasses%">%message%</p> +<div id="insert"></div> +</div> +</div> diff --git a/comm/mail/components/im/messages/bubbles/Incoming/Context.html b/comm/mail/components/im/messages/bubbles/Incoming/Context.html new file mode 100644 index 0000000000..8d29cbefbe --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Incoming/Context.html @@ -0,0 +1,7 @@ +<div class="bubble context %messageClasses%" data-senderColor="%senderColor%"> +<div class="indicator"> +<p class="pseudo">%sender%<span class="time"> - %time{%H:%M}%</span></p> +<p class="%messageClasses%">%message%</p> +<div id="insert"></div> +</div> +</div> diff --git a/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html new file mode 100644 index 0000000000..3c8aa904ba --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Incoming/NextContent.html @@ -0,0 +1,3 @@ +<hr/> +<p class="%messageClasses%">%message%</p> +<div id="insert"></div> diff --git a/comm/mail/components/im/messages/bubbles/Info.plist b/comm/mail/components/im/messages/bubbles/Info.plist new file mode 100644 index 0000000000..0b26e9413b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Info.plist @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ActionMessageTemplate</key> + <string>%sender% %message%</string> + + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + + <key>CFBundleGetInfoString</key> + <string>Instantbird Bubbles Message Style</string> + + <key>CFBundleIdentifier</key> + <string>org.instantbird.bubbles.message.style</string> + + <key>CFBundleInfoDictionaryVersion</key> + <string>1.0</string> + + <key>CFBundleName</key> + <string>Bubbles</string> + + <key>CFBundlePackageType</key> + <string>AdIM</string> + + <key>DefaultBackgroundColor</key> + <string>FFFFFF</string> + + <key>DefaultVariant</key> + <string>Blue_-_Red_Alternating</string> + + <key>DisableCustomBackground</key> + <false/> + + <key>MessageViewVersion</key> + <integer>4</integer> + + <key>ShowsUserIcons</key> + <true/> +</dict> +</plist> diff --git a/comm/mail/components/im/messages/bubbles/NextStatus.html b/comm/mail/components/im/messages/bubbles/NextStatus.html new file mode 100644 index 0000000000..5aa62afb78 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/NextStatus.html @@ -0,0 +1,3 @@ +<hr/> +<p class="%messageClasses%">%time% - %message%</p> +<div id="insert"></div> diff --git a/comm/mail/components/im/messages/bubbles/Status.html b/comm/mail/components/im/messages/bubbles/Status.html new file mode 100644 index 0000000000..5e5c927b47 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Status.html @@ -0,0 +1,4 @@ +<div class="bubble %messageClasses%"> +<p class="%messageClasses%">%time% - %message%</p> +<div id="insert"></div> +</div> diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css new file mode 100644 index 0000000000..456b4054ed --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css new file mode 100644 index 0000000000..8b67d64b38 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Green_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css new file mode 100644 index 0000000000..82c84545e9 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css new file mode 100644 index 0000000000..813af66880 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Pink_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css new file mode 100644 index 0000000000..77e5082b15 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css new file mode 100644 index 0000000000..9e91c0c21d --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Blue_-_Red_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css new file mode 100644 index 0000000000..336e241aea --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css new file mode 100644 index 0000000000..1f9ab284e3 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Blue_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css new file mode 100644 index 0000000000..90a2fcb51d --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css new file mode 100644 index 0000000000..a3b835b49b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Purple_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css new file mode 100644 index 0000000000..30186fa0cd --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css new file mode 100644 index 0000000000..ba999760b9 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Green_-_Red_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css new file mode 100644 index 0000000000..f2b1f89b62 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css new file mode 100644 index 0000000000..f1c10ff4a4 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Blue_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css new file mode 100644 index 0000000000..84a8b04754 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css new file mode 100644 index 0000000000..974e7b1698 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Pink_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css new file mode 100644 index 0000000000..7051e00d86 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css new file mode 100644 index 0000000000..601158153c --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Purple_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css new file mode 100644 index 0000000000..81eaacf886 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css new file mode 100644 index 0000000000..7c6c5ae5ef --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Grey_-_Red_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(240, 20%, 97%); + border-color: hsl(240, 20%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(240, 20%, 75%); + background-color: hsl(240, 20%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_grey.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_0_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css new file mode 100644 index 0000000000..70568ca0d5 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css new file mode 100644 index 0000000000..605b051393 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Blue_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css new file mode 100644 index 0000000000..f04b8bd51d --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css new file mode 100644 index 0000000000..eb814bdcd3 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Pink_-_Purple_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_270_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css new file mode 100644 index 0000000000..3122ad8df3 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css new file mode 100644 index 0000000000..dfd40e6335 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Green_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css new file mode 100644 index 0000000000..beea02943e --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_320.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css new file mode 100644 index 0000000000..869ee36eb8 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Purple_-_Pink_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(270, 100%, 97%); + border-color: hsl(270, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(270, 100%, 75%); + background-color: hsl(270, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_270.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(320, 100%, 97%); + border-color: hsl(320, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(320, 100%, 75%); + background-color: hsl(320, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_320_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css new file mode 100644 index 0000000000..2fbe69c40b --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_240.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css new file mode 100644 index 0000000000..e0337a8d7f --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Blue_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(240, 100%, 97%); + border-color: hsl(240, 100%, 80%); +} + +.incoming > .indicator > .pseudo { + color: hsl(240, 100%, 75%); + background-color: hsl(240, 100%, 94%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_240_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css new file mode 100644 index 0000000000..cae44aa14a --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_120.png') no-repeat center left; +} diff --git a/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css new file mode 100644 index 0000000000..0cbe20430a --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/Variants/Red_-_Green_Alternating.css @@ -0,0 +1,36 @@ +/* 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/. */ + +.bubble.outgoing { + background-color: hsl(0, 100%, 97%); + border-color: hsl(0, 100%, 80%); +} + +.outgoing > .indicator > .pseudo { + color: hsl(0, 100%, 75%); + background-color: hsl(0, 100%, 94%); +} + +.outgoing > .indicator { + margin-left: -17px; + padding-left: 32px; + background: url('../Bitmaps/indicator_0.png') no-repeat center left; +} + + +.bubble.incoming { + background-color: hsl(120, 100%, 97%); + border-color: hsl(120, 100%, 70%); +} + +.incoming > .indicator > .pseudo { + color: hsl(120, 100%, 45%); + background-color: hsl(120, 100%, 92%); +} + +.incoming > .indicator { + margin-right: -19px; + padding-right: 34px; + background: url('../Bitmaps/indicator_120_alt.png') no-repeat center right; +} diff --git a/comm/mail/components/im/messages/bubbles/inline.js b/comm/mail/components/im/messages/bubbles/inline.js new file mode 100644 index 0000000000..11bdec3f29 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/inline.js @@ -0,0 +1,330 @@ +/* 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/. */ + +// See chat/content/conversation-browser.js _exposeMethodsToContent +/* globals convScrollEnabled, scrollToElement */ + +/* [pseudo_color, pseudo_background, bubble_borders] */ +const elements_lightness = [ + [75, 94, 80], + [75, 94, 80], + [70, 93, 75], + [65, 92, 70], + [55, 90, 65], + [48, 90, 60], + [44, 86, 50], + [44, 88, 60], + [45, 88, 70], + [45, 90, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [45, 92, 70], + [60, 92, 70], + [70, 93, 75], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], + [75, 94, 80], +]; + +const bubble_background = "hsl(#, 100%, 97%)"; +const bubble_borders = "hsl(#, 100%, #%)"; +const pseudo_color = "hsl(#, 100%, #%)"; +const pseudo_background = "hsl(#, 100%, #%)"; + +var alternating = null; + +function setColors(target) { + var senderColor = target.getAttribute("data-senderColor"); + + if (!senderColor) { + return; + } + + var regexp = + /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/; + var parsed = regexp.exec(senderColor); + + if (!parsed) { + return; + } + + var senderHue = (Math.round(parsed[1] / 10) * 10) % 360; + var lightness = elements_lightness[senderHue / 10]; + + target.style.backgroundColor = bubble_background.replace("#", senderHue); + target.style.borderColor = bubble_borders + .replace("#", senderHue) + .replace("#", lightness[2]); + + var pseudo = target.getElementsByClassName("pseudo")[0]; + pseudo.style.color = pseudo_color + .replace("#", senderHue) + .replace("#", lightness[0]); + pseudo.style.backgroundColor = pseudo_background + .replace("#", senderHue) + .replace("#", lightness[1]); + + var div_indicator = target.getElementsByClassName("indicator")[0]; + var imageURL = "url('Bitmaps/indicator_" + senderHue; + if (target.classList.contains("incoming")) { + // getComputedStyle is prohibitively expensive, and we need it only to + // know if we are using an alternating variant, so we cache the result. + if (alternating === null) { + alternating = document.defaultView + .getComputedStyle(div_indicator) + .backgroundImage.endsWith('_alt.png")') + ? "_alt" + : ""; + } + imageURL += alternating; + } + div_indicator.style.backgroundImage = imageURL + ".png')"; +} + +function prettyPrintTime(aValue, aNoSeconds) { + if (aValue < 60 && aNoSeconds) { + return ""; + } + + if (aNoSeconds) { + aValue -= aValue % 60; + } + + let valuesAndUnits = window.convertTimeUnits(aValue); + if (!valuesAndUnits[2]) { + valuesAndUnits.splice(2, 2); + } + return valuesAndUnits.join(" "); +} + +// The "shadow" constant is the minimum acceptable margin-bottom for a bubble +// with a shadow, and the minimum spacing between the bubbles of two messages +// arriving in the same second. It should match the value of margin-bottom and +// box-shadow-bottom for the "bubble" class. +const shadow = 3; +const coef = 3; +const timebeforetextdisplay = 5 * 60; +const kRulerMarginTop = 11; + +const kMsPerMinute = 60 * 1000; +const kMsPerHour = 60 * kMsPerMinute; +const kMsPerDay = 24 * kMsPerHour; + +function computeSpace(aInterval) { + return Math.round(coef * Math.log(aInterval + 1)); +} + +var lastMessageTimeout; +var lastMessageTimeoutTime = -1; + +/* This function takes care of updating the amount of whitespace + * between the last message and the bottom of the conversation area. + * When the last message is more than timebeforetextdisplay old, we display + * the time in text. To avoid blinking Mac scrollbar and visual distractions + * for some very sensitive users, we update the whitespace only when a new + * message is displayed or when the user switches between tabs. While the + * conversation is visible, this function is called by timers, but we will + * only update the time displayed in text (this behavior is obtained by + * setting the aUpdateTextOnly parameter to true; otherwise it is omitted). + */ +function handleLastMessage(aUpdateTextOnly) { + if (window.messageInsertPending) { + return; + } + + var intervalInMs = Date.now() - lastMsgTime * 1000; + var interval = Math.round(intervalInMs / 1000); + var p = document.getElementById("lastMessage"); + var margin; + if (!aUpdateTextOnly) { + // Impose a minimum to ensure the last bubble doesn't touch the editbox. + margin = computeSpace(Math.max(intervalInMs, 5000) / 1000); + } + var text = ""; + if (interval >= timebeforetextdisplay) { + if (!aUpdateTextOnly) { + p.style.lineHeight = margin + shadow + "px"; + } + p.setAttribute("class", "interval"); + text = prettyPrintTime(interval, true); + margin = 0; + } + p.textContent = text; + if (!aUpdateTextOnly) { + p.style.marginTop = margin - shadow + "px"; + if (convScrollEnabled()) { + scrollToElement(p); + } + } + + var next = timebeforetextdisplay * 1000 - intervalInMs; + if (next <= 0) { + if (intervalInMs > kMsPerDay) { + next = kMsPerHour - (intervalInMs % kMsPerHour); + } else { + next = kMsPerMinute - (intervalInMs % kMsPerMinute); + } + aUpdateTextOnly = true; + } + + // The setTimeout callbacks are frequently called a few ms early, + // but our code prefers being called a little late, so add 20ms. + lastMessageTimeoutTime = next + 20; + lastMessageTimeout = setTimeout( + handleLastMessage, + lastMessageTimeoutTime, + aUpdateTextOnly + ); +} + +var lastMsgTime = 0; +function updateLastMsgTime(aMsgTime) { + if (aMsgTime > lastMsgTime) { + lastMsgTime = aMsgTime; + } + + if (lastMsgTime && lastMessageTimeoutTime != 0 && !document.hidden) { + clearTimeout(lastMessageTimeout); + setTimeout(handleLastMessage, 0); + lastMessageTimeoutTime = 0; + } +} + +function visibilityChanged() { + if (document.hidden) { + clearTimeout(lastMessageTimeout); + lastMessageTimeoutTime = -1; + } else if (lastMsgTime) { + handleLastMessage(); + } +} + +function checkNewText(target) { + var nicks = target.getElementsByClassName("ib-nick"); + for (var i = 0; i < nicks.length; ++i) { + var nick = nicks[i]; + if (nick.hasAttribute("data-left")) { + continue; + } + var hue = nick.getAttribute("data-nickColor"); + var senderHue = (Math.round(hue / 10) * 10) % 360; + var lightness = elements_lightness[senderHue / 10]; + nick.style.backgroundColor = pseudo_background + .replace("#", senderHue) + .replace("#", lightness[1]); + nick.style.color = pseudo_color + .replace("#", senderHue) + .replace("#", lightness[0]); + nick.style.borderColor = bubble_borders + .replace("#", senderHue) + .replace("#", lightness[2]); + } + + var msgTime = null; + if (target._originalMsg) { + msgTime = target._originalMsg.time; + } + if (target.tagName == "DIV" && target.classList.contains("bubble")) { + setColors(target); + + var prev = target.previousElementSibling; + var shouldSetUnreadRuler = prev && prev.id && prev.id == "unread-ruler"; + var shouldSetSessionRuler = + prev && prev.className && prev.className == "sessionstart-ruler"; + // We need an extra pixel of margin at the top to make the margins appear + // to be of equal size, since the preceding bubble will have a shadow. + var rulerMarginBottom = kRulerMarginTop - 1; + + if (lastMsgTime && msgTime >= lastMsgTime) { + var interval = msgTime - lastMsgTime; + var margin = computeSpace(interval); + let isTimetext = interval >= timebeforetextdisplay; + if (isTimetext) { + let p = document.createElement("p"); + p.className = "interval"; + if (shouldSetSessionRuler) { + // Hide the hr and style the time text accordingly instead. + prev.classList.remove("sessionstart-ruler"); + prev.style.border = "none"; + p.classList.add("sessionstart-ruler"); + margin += 6; + prev = p; + } + p.style.lineHeight = margin + shadow + "px"; + p.style.marginTop = -shadow + "px"; + p.textContent = prettyPrintTime(interval); + target.parentNode.insertBefore(p, target); + margin = 0; + } + target.style.marginTop = margin + "px"; + if (shouldSetUnreadRuler || shouldSetSessionRuler) { + if (margin > rulerMarginBottom) { + // Set the unread ruler margin so it is constant after margin collapse. + // See https://developer.mozilla.org/en/CSS/margin_collapsing + rulerMarginBottom -= margin; + } + if (isTimetext && shouldSetUnreadRuler) { + // If a text display follows, use the minimum bubble margin after the + // ruler, taking account of the absence of a shadow on the ruler. + rulerMarginBottom = shadow - 1; + } + } + } + if (shouldSetUnreadRuler || shouldSetSessionRuler) { + prev.style.marginBottom = rulerMarginBottom + "px"; + prev.style.marginTop = kRulerMarginTop + "px"; + } + } else if (target.tagName == "P" && target.className == "event") { + let parent = target.parentNode; + // We need to start a group with this element if there are at least 4 + // system messages and they aren't already grouped. + if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(4)")) { + let p = document.createElement("p"); + p.className = "eventToggle"; + p.addEventListener("click", event => + event.target.parentNode.classList.toggle("hide-children") + ); + parent.insertBefore(p, parent.querySelector("p.event:nth-of-type(2)")); + parent.classList.add("hide-children"); + parent.grouped = true; + } + } + + if (msgTime) { + updateLastMsgTime(msgTime); + } +} + +new MutationObserver(function (aMutations) { + for (let mutation of aMutations) { + for (let node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkNewText(node); + } + } + } +}).observe(document.getElementById("ibcontent"), { + childList: true, + subtree: true, +}); + +document.addEventListener("visibilitychange", visibilityChanged); diff --git a/comm/mail/components/im/messages/bubbles/main.css b/comm/mail/components/im/messages/bubbles/main.css new file mode 100644 index 0000000000..84e8c7b8d6 --- /dev/null +++ b/comm/mail/components/im/messages/bubbles/main.css @@ -0,0 +1,210 @@ +/* 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/. */ + +body { + margin: 0; + padding: 0; + background: -moz-linear-gradient(top, -moz-dialog, -moz-default-background-color) fixed; + color: #000; +} + +p { + font-family: sans-serif; + margin: 0; + padding: 0; +} + +.bubble { + margin: 20px 20px 3px; + padding: 0; + border-width: 2px; + border-style: solid; + border-radius: 10px; + box-shadow: rgba(0, 0, 0, 0.3) 1px 1px 3px; +} + +#ibcontent:not(.log) > #Chat > .bubble:not(.context,.event) { + -moz-animation-duration: 0.5s; + -moz-animation-name: fadein; + -moz-animation-iteration-count: 1; +} + +@-moz-keyframes fadein { + from { + opacity: 0; + } + + to { + opacity: 1.0; + } +} + +.bubble.context:not(:hover) { + filter: saturate(40%); +} + +.indicator { + margin: 0; + padding: 9px 15px 10px 15px; +} + +.bubble.event { + padding: 4px 15px 4px 15px; + background-color: hsl(0, 0%, 99%); + border-color: hsl(0, 0%, 85%); + box-shadow: rgba(0, 0, 0, 0.1) 1px 1px 3px; +} + +.pseudo { + display: inline-block; + font-size: smaller; + font-weight: bold; + margin: -9px 0px 3px -15px; + padding: 0px 15px 1px 15px; + /* border-top-left-radius = (border-radius - border-width) of div.bubble, + see bug 1775 for an explanation */ + border-top-left-radius: 8px; + border-bottom-right-radius: 10px; +} + +.pseudo > .time { + display: none; +} + +.bubble:hover > .indicator > .pseudo > .time { + display: inline; +} + +.bubble > .indicator > hr, +.bubble > hr { + margin: 3px 0px 1px 0px; + height: 2px; + border-style: none; + border-top: 1px solid rgba(0, 0, 0, 0.07); + border-bottom: 1px solid rgba(255, 255, 255, 0.5); +} + +.interval, #lastMessage { + text-align: center; + color: hsl(0, 0%, 60%); +} + +#lastMessage { + line-height: 20px; +} + +#ibcontent.log > #lastMessage { + display: none; +} + +p.nick { + font-weight: bold; +} + +p.action { + font-style: italic; +} + +p.action::before { + content: "*** "; +} + +p.event { + color: hsl(0, 0%, 60%); +} + +p.event *:any-link:not(:hover) { + color: hsl(0, 0%, 60%); + text-decoration: none; +} + +p.event *:any-link:hover { + color: hsl(0, 0%, 25%); +} + +#Chat { + white-space: normal; +} + +p *:any-link img { + margin-bottom: 1px; + border-bottom: solid 1px; +} + +#unread-ruler { + border-top: 1px solid rgba(0, 0, 0, 0.16) !important; + border-bottom: 1px solid rgb(255,255,255) !important; +} + +.sessionstart-ruler { + margin: 0; + width: 100%; + border: none; + min-height: 13px; + background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(0,0,0,0.18)); +} + +.ib-sender.message-encrypted { + position: relative; +} + +.ib-sender.message-encrypted::after { + position: relative; + display: inline-block; + content: ''; + width: 11px; + height: 10px; + background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center; + background-size: contain; + margin-inline-start: 4px; + -moz-context-properties: fill; + fill: currentColor; +} + +/* used by javascript */ +.eventToggle { + cursor: pointer; + min-height: 20px; + margin-left: -24px; + padding-left: 24px; + background: url('Bitmaps/minus.png') no-repeat left top; + margin-bottom: -20px; + width: 0; +} + +.eventToggle:hover { + background-image: url('Bitmaps/minus-hover.png'); +} + +.hide-children > .eventToggle { + width: 100%; + margin-bottom: -3px; + background-image: url('Bitmaps/plus.png'); +} + +.hide-children > .eventToggle:hover { + background-image: url('Bitmaps/plus-hover.png'); +} + +.hide-children > .eventToggle::after { + content: "\2026"; /* … */ + color: hsl(0, 0%, 60%); +} + +.hide-children > :is(p.event,hr):not(:first-of-type,:last-of-type,.no-collapse) { + display: none; +} + +.ib-nick { + font-size: smaller; + border: 1px solid; + border-radius: 6px; + padding: 0 0.3em; +} + +.ib-nick[left] { + color: hsl(0, 0%, 60%); + background-color: hsl(0, 0%, 99%); + border-color: hsl(0, 0%, 85%); +} diff --git a/comm/mail/components/im/messages/dark/Incoming/Content.html b/comm/mail/components/im/messages/dark/Incoming/Content.html new file mode 100644 index 0000000000..3db2719441 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Incoming/Content.html @@ -0,0 +1,2 @@ +<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/dark/Incoming/Context.html b/comm/mail/components/im/messages/dark/Incoming/Context.html new file mode 100644 index 0000000000..0b8c7ec20f --- /dev/null +++ b/comm/mail/components/im/messages/dark/Incoming/Context.html @@ -0,0 +1,2 @@ +<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="pseudo">%sender%</span><span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContent.html b/comm/mail/components/im/messages/dark/Incoming/NextContent.html new file mode 100644 index 0000000000..c62098d838 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Incoming/NextContent.html @@ -0,0 +1,2 @@ +<p class="%messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/dark/Incoming/NextContext.html b/comm/mail/components/im/messages/dark/Incoming/NextContext.html new file mode 100644 index 0000000000..d57fd3b1a6 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Incoming/NextContext.html @@ -0,0 +1,2 @@ +<p class="context %messageClasses%" data-senderColor="%senderColor%"><span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/dark/Info.plist b/comm/mail/components/im/messages/dark/Info.plist new file mode 100644 index 0000000000..3de1af0f4d --- /dev/null +++ b/comm/mail/components/im/messages/dark/Info.plist @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ActionMessageTemplate</key> + <string>%sender% %message%</string> + + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + + <key>CFBundleGetInfoString</key> + <string>Instantbird Dark Message Style</string> + + <key>CFBundleIdentifier</key> + <string>org.instantbird.dark.message.style</string> + + <key>CFBundleInfoDictionaryVersion</key> + <string>1.0</string> + + <key>CFBundleName</key> + <string>Dark</string> + + <key>CFBundlePackageType</key> + <string>AdIM</string> + + <key>DefaultBackgroundColor</key> + <string>000000</string> + + <key>DefaultVariant</key> + <string>Blue</string> + + <key>DisableCustomBackground</key> + <false/> + + <key>MessageViewVersion</key> + <integer>4</integer> + + <key>ShowsUserIcons</key> + <true/> +</dict> +</plist> diff --git a/comm/mail/components/im/messages/dark/Status.html b/comm/mail/components/im/messages/dark/Status.html new file mode 100644 index 0000000000..cb3bedf216 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Status.html @@ -0,0 +1 @@ +<p class="event-messages">%time% - %message%</p> diff --git a/comm/mail/components/im/messages/dark/Variants/Blue.css b/comm/mail/components/im/messages/dark/Variants/Blue.css new file mode 100644 index 0000000000..d32a90406f --- /dev/null +++ b/comm/mail/components/im/messages/dark/Variants/Blue.css @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +p.incoming { + border-top: 1px solid hsla(215, 100%, 80%, 0.4); + background: -moz-linear-gradient(top, hsla(215, 100%, 80%, 0.3), hsla(215, 100%, 80%, 0.1) 30px); +} diff --git a/comm/mail/components/im/messages/dark/Variants/Green.css b/comm/mail/components/im/messages/dark/Variants/Green.css new file mode 100644 index 0000000000..d2a8ecca33 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Variants/Green.css @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +p.incoming { + border-top: 1px solid hsla(150, 80%, 80%, 0.4); + background: -moz-linear-gradient(top, hsla(150, 80%, 80%, 0.3), hsla(150, 80%, 80%, 0.1) 30px); +} diff --git a/comm/mail/components/im/messages/dark/Variants/Purple.css b/comm/mail/components/im/messages/dark/Variants/Purple.css new file mode 100644 index 0000000000..bf26f8d549 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Variants/Purple.css @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +p.incoming { + border-top: 1px solid hsla(275, 100%, 80%, 0.4); + background: -moz-linear-gradient(top, hsla(275, 100%, 80%, 0.3), hsla(275, 100%, 80%, 0.1) 30px); +} diff --git a/comm/mail/components/im/messages/dark/Variants/Red.css b/comm/mail/components/im/messages/dark/Variants/Red.css new file mode 100644 index 0000000000..5bb6dab2ed --- /dev/null +++ b/comm/mail/components/im/messages/dark/Variants/Red.css @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +p.incoming { + border-top: 1px solid hsla(0, 100%, 80%, 0.4); + background: -moz-linear-gradient(top, hsla(0, 100%, 80%, 0.3), hsla(0, 100%, 80%, 0.1) 30px); +} diff --git a/comm/mail/components/im/messages/dark/Variants/Yellow.css b/comm/mail/components/im/messages/dark/Variants/Yellow.css new file mode 100644 index 0000000000..aa493bfdc7 --- /dev/null +++ b/comm/mail/components/im/messages/dark/Variants/Yellow.css @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +p.incoming { + border-top: 1px solid hsla(50, 100%, 80%, 0.4); + background: -moz-linear-gradient(top, hsla(50, 100%, 80%, 0.3), hsla(50, 100%, 80%, 0.1) 30px); +} diff --git a/comm/mail/components/im/messages/dark/inline.js b/comm/mail/components/im/messages/dark/inline.js new file mode 100644 index 0000000000..71cbd46475 --- /dev/null +++ b/comm/mail/components/im/messages/dark/inline.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const p_border_top = "1px solid hsla(#, 100%, 80%, 0.4)"; +const p_background = + "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 30px)"; +const nick_background = + "-moz-linear-gradient(top, hsla(#, 100%, 80%, 0.3), hsla(#, 100%, 80%, 0.1) 1em)"; + +function setColors(target) { + var senderColor = target.getAttribute("data-senderColor"); + + if (!senderColor) { + return; + } + + var regexp = + /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/; + var parsed = regexp.exec(senderColor); + + if (!parsed) { + return; + } + + var senderHue = parsed[1]; + + target.style.borderTop = p_border_top.replace("#", senderHue); + target.style.background = p_background.replace(/#/g, senderHue); +} + +function checkNewText(target) { + if (target.tagName == "P" && target.className != "event-messages") { + setColors(target); + } + + var nicks = target.getElementsByClassName("ib-nick"); + for (var i = 0; i < nicks.length; ++i) { + var nick = nicks[i]; + if (!nick.hasAttribute("data-left")) { + nick.style.background = nick_background.replace( + /#/g, + nick.getAttribute("data-nickColor") + ); + } + } +} + +new MutationObserver(function (aMutations) { + for (let mutation of aMutations) { + for (let node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkNewText(node); + } + } + } +}).observe(document.getElementById("ibcontent"), { + childList: true, + subtree: true, +}); diff --git a/comm/mail/components/im/messages/dark/main.css b/comm/mail/components/im/messages/dark/main.css new file mode 100644 index 0000000000..b3f94d9d2c --- /dev/null +++ b/comm/mail/components/im/messages/dark/main.css @@ -0,0 +1,127 @@ +/* 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/. */ + +body { + margin: 0; + padding: 0; + background-color: black; +} + +p { + font-family: sans-serif; + margin: 0; + padding: 0; + color: rgba(255, 255, 255, 0.6); +} + +p.message { + margin: 0; + padding: 4px 15px 6px 15px; + border-bottom: 1px solid black; + border-top: 1px solid rgba(255, 255, 255, 0.3); + background: -moz-linear-gradient(top, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.07) 30px); +} + +p.context:not(:hover) { + opacity: 0.5; + color: rgba(255, 255, 255, 1); +} + +span.message-style, +p.event-messages { + font-size: 90%; +} + +p.event-messages { + margin: 5px 0px 5px 0px; + text-align: center; + opacity: 0.4; + -moz-transition-property: opacity; + -moz-transition-duration: 0.3s; +} + +p.event-messages:hover { + opacity: 1; +} + +.message-style { + display: block; +} + +.pseudo { + margin-bottom: 3px; + font-weight: bold; + color: white; + display: block; +} + +.nick > .message-style { + font-weight: bold; +} + +.action > .message-style { + font-style: italic; +} + +.action > .message-style::before { + content: "*** "; +} + +a, +a:hover { + color: rgba(255, 255, 255, 0.6); +} + +a:active { + color: rgba(255, 255, 255, 1); +} + +a:visited { + color: rgba(255, 255, 255, 0.4); +} + +#Chat { + white-space: normal; +} + +p *:any-link img { + margin-bottom: 1px; + border-bottom: solid 1px; +} + +.ib-nick { + color: white !important; + border-radius: 3px; + padding: 0 0.25em; +} + +.ib-nick[left] { + color: white !important; + background-color: black; + opacity: 0.4; + -moz-transition-property: opacity; + -moz-transition-duration: 0.3s; +} + +.ib-nick[left]:hover { + opacity: 1; +} + +.ib-sender.message-encrypted { + position: relative; +} + +.ib-sender.message-encrypted::after { + position: relative; + display: inline-block; + content: ''; + width: 11px; + height: 11px; + opacity: 0.5; + background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center; + background-size: contain; + margin-inline-start: 4px; + -moz-context-properties: fill; + fill: currentColor; +} diff --git a/comm/mail/components/im/messages/mail/Footer.html b/comm/mail/components/im/messages/mail/Footer.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Footer.html diff --git a/comm/mail/components/im/messages/mail/Header.html b/comm/mail/components/im/messages/mail/Header.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Header.html diff --git a/comm/mail/components/im/messages/mail/Incoming/Content.html b/comm/mail/components/im/messages/mail/Incoming/Content.html new file mode 100644 index 0000000000..cfc6270d37 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Incoming/Content.html @@ -0,0 +1 @@ +<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div> diff --git a/comm/mail/components/im/messages/mail/Incoming/Context.html b/comm/mail/components/im/messages/mail/Incoming/Context.html new file mode 100644 index 0000000000..6a297f0fba --- /dev/null +++ b/comm/mail/components/im/messages/mail/Incoming/Context.html @@ -0,0 +1 @@ +<div class="context %messageClasses%" data-prpl="%service%"><div class="sidebar"><img src="%userIconPath%" alt="" class="usericon"/><div class="date">%time{%H:%M}%</div></div><div class="body"><div class="pseudo" style="%senderColor%">%sender%</div>%message%</div></div> diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContent.html b/comm/mail/components/im/messages/mail/Incoming/NextContent.html new file mode 100644 index 0000000000..02c51fd70a --- /dev/null +++ b/comm/mail/components/im/messages/mail/Incoming/NextContent.html @@ -0,0 +1 @@ +<div class="%messageClasses%" data-prpl="%service%"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body">%message%</div></div> diff --git a/comm/mail/components/im/messages/mail/Incoming/NextContext.html b/comm/mail/components/im/messages/mail/Incoming/NextContext.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Incoming/NextContext.html diff --git a/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg new file mode 100644 index 0000000000..6f9e4e7b93 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Incoming/buddy_icon.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> + <path fill="context-fill" fill-opacity="0.25" d="M2 48v-8c-.06-7.74 15.71-6.56 16.01-11.12.1-1.33.34-1.66-.23-3.08-.98-.65-1.41-2.86-1.52-4.1 0-.97-.95-.24-1.01-1.39-.32-1.5-.46-2.91.14-4.37.55-.47.83.74.83-.13a8.1 8.1 0 01.64-4.52c1.27-4.73 11.16-4.57 13.54.36.7 1.98.61 2.86.76 4.84 0 .84.4-.61.81.1a7.9 7.9 0 01-.1 4.01c-.53 1.95-1.39.16-1.52 1.52-.6 1.24-.32 3.04-1.8 3.73-.46 1.13-.28 1.85-.14 2.99 0 4.38 15.1 4.14 15.59 11.16v7.86"/> +</svg> diff --git a/comm/mail/components/im/messages/mail/Info.plist b/comm/mail/components/im/messages/mail/Info.plist new file mode 100644 index 0000000000..042b7b49bb --- /dev/null +++ b/comm/mail/components/im/messages/mail/Info.plist @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ActionMessageTemplate</key> + <string>%message%</string> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleGetInfoString</key> + <string>Thunderbird Message Style</string> + <key>CFBundleIdentifier</key> + <string>org.mozilla.thunderbird.message.style</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>1.0</string> + <key>CFBundleName</key> + <string>Minimal</string> + <key>CFBundlePackageType</key> + <string>AdIM</string> + <key>DefaultBackgroundColor</key> + <string>FFFFFF</string> + <key>DefaultVariant</key> + <string>Light</string> + <key>DisableCustomBackground</key> + <false/> + <key>MessageViewVersion</key> + <integer>4</integer> + <key>ShowsUserIcons</key> + <true/> +</dict> +</plist> diff --git a/comm/mail/components/im/messages/mail/NextStatus.html b/comm/mail/components/im/messages/mail/NextStatus.html new file mode 100644 index 0000000000..26dd6fac41 --- /dev/null +++ b/comm/mail/components/im/messages/mail/NextStatus.html @@ -0,0 +1 @@ +<div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/> diff --git a/comm/mail/components/im/messages/mail/Outgoing/Content.html b/comm/mail/components/im/messages/mail/Outgoing/Content.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Outgoing/Content.html diff --git a/comm/mail/components/im/messages/mail/Outgoing/Context.html b/comm/mail/components/im/messages/mail/Outgoing/Context.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Outgoing/Context.html diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContent.html b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Outgoing/NextContent.html diff --git a/comm/mail/components/im/messages/mail/Outgoing/NextContext.html b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Outgoing/NextContext.html diff --git a/comm/mail/components/im/messages/mail/Status.html b/comm/mail/components/im/messages/mail/Status.html new file mode 100644 index 0000000000..a59a34e211 --- /dev/null +++ b/comm/mail/components/im/messages/mail/Status.html @@ -0,0 +1 @@ +<div aria-live="polite" class="%messageClasses%"><div class="event-row"><div class="sidebar"><div class="date">%time{%H:%M}%</div></div><div class="body"><p class="event-paragraph">%message%</p></div></div><span id="insert"/></div> diff --git a/comm/mail/components/im/messages/mail/Variants/Dark.css b/comm/mail/components/im/messages/mail/Variants/Dark.css new file mode 100644 index 0000000000..63044cc7fa --- /dev/null +++ b/comm/mail/components/im/messages/mail/Variants/Dark.css @@ -0,0 +1,49 @@ +/* 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/. */ + +body { + background-color: #18181a; + color: #f9f9fa; +} + +#Chat .event p { + color: #999; +} + +#Chat #unread-ruler { + border-top: 1px solid #30e60b; +} + +.message:hover, +.message:focus { + background-color: rgba(255, 255, 255, 0.03); +} + +.outgoing .pseudo { + color: #007cff; +} + +.incoming .pseudo { + color: #e5509f; +} + +.date { + color: #999; +} + +.ib-sender.message-encrypted::before { + fill: #fff; +} + +.context { + color: #aeaeaf; +} + +.sessionstart-ruler { + border-top: 1px solid #e9e9ea; +} + +.eventToggle { + stroke: #fff; +} diff --git a/comm/mail/components/im/messages/mail/Variants/Light.css b/comm/mail/components/im/messages/mail/Variants/Light.css new file mode 100644 index 0000000000..7f1404cf9c --- /dev/null +++ b/comm/mail/components/im/messages/mail/Variants/Light.css @@ -0,0 +1,49 @@ +/* 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/. */ + +body { + background-color: white; + color: black; +} + +#Chat .event p { + color: GrayText; +} + +#Chat #unread-ruler { + border-top: 1px solid #30e60b; +} + +.message:hover, +.message:focus { + background-color: rgba(0, 0, 0, 0.03); +} + +.outgoing .pseudo { + color: #0060DF; +} + +.incoming .pseudo { + color: #B5007F; +} + +.date { + color: GrayText; +} + +.ib-sender.message-encrypted::before { + fill: #000; +} + +.context { + color: rgb(91, 91, 91); +} + +.sessionstart-ruler { + border-top: 1px solid ThreeDDarkShadow; +} + +.eventToggle { + stroke: #000; +} diff --git a/comm/mail/components/im/messages/mail/inline.js b/comm/mail/components/im/messages/mail/inline.js new file mode 100644 index 0000000000..a6e7f72302 --- /dev/null +++ b/comm/mail/components/im/messages/mail/inline.js @@ -0,0 +1,40 @@ +/* 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/. */ + +function checkNewText(target) { + if (target.className == "event-row") { + let parent = target.closest(".event"); + // We need to start a group with this element if there are at least 4 + // system messages and they aren't already grouped. + if ( + !parent?.grouped && + parent?.querySelector(".event-row:nth-of-type(4)") + ) { + let toggle = document.createElement("div"); + toggle.className = "eventToggle"; + toggle.addEventListener("click", event => { + toggle.closest(".event").classList.toggle("hide-children"); + }); + parent.insertBefore( + toggle, + parent.querySelector(".event-row:nth-of-type(2)") + ); + parent.classList.add("hide-children"); + parent.grouped = true; + } + } +} + +new MutationObserver(function (aMutations) { + for (let mutation of aMutations) { + for (let node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkNewText(node); + } + } + } +}).observe(document.getElementById("ibcontent"), { + childList: true, + subtree: true, +}); diff --git a/comm/mail/components/im/messages/mail/main.css b/comm/mail/components/im/messages/mail/main.css new file mode 100644 index 0000000000..1989b2e3d3 --- /dev/null +++ b/comm/mail/components/im/messages/mail/main.css @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#Chat { + white-space: normal; +} + +/* The "#chat " is required to override "#Chat *" from conv.css */ + +.message { + display: flex; + align-items: flex-start; + margin-block: 5px; + padding: 5px 6px; + border-radius: 4px; +} + +#Chat .event { + display: flex; + flex-direction: column; + margin-left: 0; + clear: none; + padding-inline: 6px; +} + +.event-row { + display: flex; + align-items: start; +} + +#Chat .event p { + margin: 0; + margin-block-end: 5px; +} + +#Chat #unread-ruler { + margin: 4px; +} + +.sidebar { + display: flex; + justify-content: end; + margin-inline-end: 10px; + margin-block-start: 2px; + width: 4.5em; + flex-wrap: wrap; + text-align: right; +} + +.body { + display: flex; + flex-direction: column; + flex: 1; +} + +.pseudo { + font-size: 0.9em; + font-weight: bold; + letter-spacing: 0.01em; + margin-block-end: 0; +} + +.message.outgoing + .message.outgoing, +.message.incoming + .message.incoming { + margin-block: 0; +} + +.message:not(.action) > .next { + visibility: hidden; +} + +.date { + font-size: 0.75em; + text-transform: uppercase; + font-style: normal; + font-weight: normal; + white-space: nowrap; +} + +.ib-sender.message-encrypted { + position: relative; +} + +.ib-sender.message-encrypted::before { + position: relative; + display: inline-block; + content: ''; + width: 11px; + height: 11px; + opacity: 0.5; + background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center; + background-size: contain; + margin-inline-end: 4px; + -moz-context-properties: fill; +} + +.usericon { + display: none; +} + +.nick { + font-weight: bold; +} + +.nick > .pseudo { + text-decoration: underline; +} + +.action { + font-style: italic; +} + +.context > .pseudo { + opacity: 0.7; +} + +p *:any-link img { + margin-bottom: 1px; + border-bottom: solid 1px; +} + +.sessionstart-ruler { + margin: 8px 0 12px; + width: 100%; + border: none; +} + +/* used by javascript */ +.eventToggle { + background: var(--icon-nav-down-sm) no-repeat left center; + margin-bottom: -22px; + cursor: pointer; + height: 22px; + width: 20px; + z-index: 1; + opacity: 0.5; + -moz-context-properties: stroke; +} + +.eventToggle:hover { + opacity: 1; +} + +.hide-children > :is(.event-row,hr):not(:first-of-type,:last-of-type,.no-collapse) { + display: none; +} + +.hide-children .eventToggle { + background: var(--icon-nav-right-sm) no-repeat left center; +} + +.hide-children .eventToggle:-moz-locale-dir(rtl) { + background: var(--icon-nav-left-sm) no-repeat right center; +} diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/information.png b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png Binary files differnew file mode 100644 index 0000000000..ff62c80758 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Bitmaps/information.png diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png Binary files differnew file mode 100644 index 0000000000..f84a080807 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Bitmaps/minus.png diff --git a/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png Binary files differnew file mode 100644 index 0000000000..9f5e414f44 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Bitmaps/plus.png diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Content.html b/comm/mail/components/im/messages/papersheets/Incoming/Content.html new file mode 100644 index 0000000000..c395055382 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Incoming/Content.html @@ -0,0 +1,4 @@ +<div class="messages-group %messageClasses%" data-senderColor="%senderColor%"> +<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> +</div> diff --git a/comm/mail/components/im/messages/papersheets/Incoming/Context.html b/comm/mail/components/im/messages/papersheets/Incoming/Context.html new file mode 100644 index 0000000000..38c9bc0ee8 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Incoming/Context.html @@ -0,0 +1,4 @@ +<div class="messages-group context %messageClasses%" data-senderColor="%senderColor%"> +<p class="%messageClasses%"><span class="date">%time%</span> <span class="pseudo" style="%senderColor%">%sender%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> +</div> diff --git a/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html new file mode 100644 index 0000000000..8bba392803 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Incoming/NextContent.html @@ -0,0 +1,3 @@ +<hr/> +<p class="%messageClasses%"><span class="date date-next">%time%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/papersheets/Info.plist b/comm/mail/components/im/messages/papersheets/Info.plist new file mode 100644 index 0000000000..420ceb5498 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Info.plist @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ActionMessageTemplate</key> + <string>%sender% %message%</string> + + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + + <key>CFBundleGetInfoString</key> + <string>Instantbird PaperSheets Message Style</string> + + <key>CFBundleIdentifier</key> + <string>org.instantbird.papersheets.message.style</string> + + <key>CFBundleInfoDictionaryVersion</key> + <string>1.0</string> + + <key>CFBundleName</key> + <string>PaperSheets</string> + + <key>CFBundlePackageType</key> + <string>AdIM</string> + + <key>DefaultBackgroundColor</key> + <string>FFFFFF</string> + + <key>DisableCustomBackground</key> + <false/> + + <key>MessageViewVersion</key> + <integer>4</integer> + + <key>ShowsUserIcons</key> + <true/> +</dict> +</plist> diff --git a/comm/mail/components/im/messages/papersheets/NextStatus.html b/comm/mail/components/im/messages/papersheets/NextStatus.html new file mode 100644 index 0000000000..b72b0f30ba --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/NextStatus.html @@ -0,0 +1,2 @@ +<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> diff --git a/comm/mail/components/im/messages/papersheets/Status.html b/comm/mail/components/im/messages/papersheets/Status.html new file mode 100644 index 0000000000..2f1c524a51 --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Status.html @@ -0,0 +1,4 @@ +<div class="messages-group %messageClasses%"> +<p class="%messageClasses%"><span class="date">%time%</span> <span class="message-style">%message%</span></p> +<div id="insert"/> +</div> diff --git a/comm/mail/components/im/messages/papersheets/Variants/White.css b/comm/mail/components/im/messages/papersheets/Variants/White.css new file mode 100644 index 0000000000..c0221a94fc --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/Variants/White.css @@ -0,0 +1,22 @@ +/* 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/. */ + +div.outgoing { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important; +} + +div.incoming { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 255, 1) 15px, rgba(255, 255, 255, 1)) !important; +} + + + +/* used by javascript */ +.outgoing-color { + background-color: rgb(255, 255, 255); +} + +.incoming-color { + background-color: rgb(255, 255, 255); +} diff --git a/comm/mail/components/im/messages/papersheets/inline.js b/comm/mail/components/im/messages/papersheets/inline.js new file mode 100644 index 0000000000..5c711a34fb --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/inline.js @@ -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/. */ + +const bg_gradient = + "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, hsla(#, 100%, 98%, 1) 15px, hsla(#, 100%, 98%, 1));"; +const bg_context_gradient = + "background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, hsla(#, 20%, 98%, 1) 15px, hsla(#, 20%, 98%, 1));"; +const bg_color = "background-color: hsl(#, 100%, 98%);"; + +var body = document.getElementById("ibcontent"); + +function setColors(target) { + var senderColor = target.getAttribute("data-senderColor"); + + if (senderColor) { + var regexp = + /color:\s*hsl\(\s*(\d{1,3})\s*,\s*\d{1,3}\%\s*,\s*\d{1,3}\%\s*\)/; + var parsed = regexp.exec(senderColor); + + if (parsed) { + var senderHue = parsed[1]; + if (target.classList.contains("context")) { + target.setAttribute( + "style", + bg_context_gradient.replace(/#/g, senderHue) + ); + } else { + target.setAttribute("style", bg_gradient.replace(/#/g, senderHue)); + } + } + } + + if (body.scrollHeight <= screen.height) { + if (senderHue) { + body.setAttribute("style", bg_color.replace("#", senderHue)); + } else if (target.classList.contains("outgoing")) { + body.className = "outgoing-color"; + body.removeAttribute("style"); + } else if (target.classList.contains("incoming")) { + body.className = "incoming-color"; + body.removeAttribute("style"); + } else if (target.classList.contains("event")) { + body.className = "event-color"; + body.removeAttribute("style"); + } + } +} + +function checkNewText(target) { + if (target.tagName == "DIV") { + setColors(target); + } else if (target.tagName == "P" && target.className == "event") { + let parent = target.parentNode; + // We need to start a group with this element if there are at least 3 + // system messages and they aren't already grouped. + if (!parent?.grouped && parent?.querySelector("p.event:nth-of-type(3)")) { + var div = document.createElement("div"); + div.className = "eventToggle"; + div.addEventListener("click", event => + event.target.parentNode.classList.toggle("hide-children") + ); + parent.insertBefore(div, parent.querySelector("p.event:first-of-type")); + parent.classList.add("hide-children"); + parent.grouped = true; + } + } +} + +new MutationObserver(function (aMutations) { + for (let mutation of aMutations) { + for (let node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkNewText(node); + } + } + } +}).observe(document.getElementById("ibcontent"), { + childList: true, + subtree: true, +}); diff --git a/comm/mail/components/im/messages/papersheets/main.css b/comm/mail/components/im/messages/papersheets/main.css new file mode 100644 index 0000000000..af70637d4f --- /dev/null +++ b/comm/mail/components/im/messages/papersheets/main.css @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +body { + margin: 0; + padding: 0; + color: #000; +} + +p { + font-family: sans-serif; + margin: 0; + padding: 0; +} + +div.messages-group { + margin: -15px 0 0 0; + padding: 18px 5px 20px 5px; +} + +div.outgoing { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(245, 245, 255, 1) 15px, rgba(245, 245, 255, 1)); +} + +div.incoming { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 245, 245, 1) 15px, rgba(255, 245, 245, 1)); +} + +div.event { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1)); +} + +div.context+div.event { + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.05) 15px, rgba(255, 255, 240, 1) 15px, rgba(255, 255, 240, 1)); +} + +div.context:not(:hover) > p { + opacity: 0.55; +} + +div.messages-group:last-child { + padding-bottom: 10px; +} + +div.messages-group > hr { + margin: 3px 50px 0px 20px; + background-color: rgba(0, 0, 0, 0.05); + height: 1px; + border: 0; +} + +span.message-style { + margin: 2px 50px 0px 20px; + display: block; + float: none; +} + +span.date { + color: rgba(0, 0, 0, 0.4); + font-size: smaller; + text-align: right; + float: inline-end; + display: block; +} + +span.date-next { + opacity: 0.4; + margin-top: -6px; + -moz-transition-property: opacity; + -moz-transition-duration: 0.3s; +} + +p:hover > span.date-next { + opacity: 1; +} + +span.pseudo { + font-weight: bold; + float: none; + display: block; +} + +p.outgoing > span.pseudo { + color: rgb(80,80,200); +} + +p.incoming > span.pseudo { + color: rgb(200,80,80); +} + +p.nick > span.message-style { + font-weight: bold; +} + +p.action > span.message-style { + font-style: italic; +} + +p.action > span.message-style::before { + content: "*** "; +} + +p.event { + margin-left: 0px; + min-height: 16px; + background: url('Bitmaps/information.png') no-repeat top left; +} + +p.event > span.message-style { + color: rgba(0, 0, 0, 0.4); +} + +#Chat { + white-space: normal; +} + +p *:any-link img { + margin-bottom: 1px; + border-bottom: solid 1px; +} + +.ib-sender.message-encrypted { + position: relative; +} + +.ib-sender.message-encrypted::after { + position: relative; + display: inline-block; + content: ''; + width: 11px; + height: 11px; + opacity: 0.7; + background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center; + background-size: contain; + margin-inline-start: 4px; + -moz-context-properties: fill; + fill: currentColor; +} + +/* used by javascript */ +.outgoing-color { + background-color: rgb(245, 245, 255); +} + +.incoming-color { + background-color: rgb(255, 245, 245); +} + +.event-color { + background-color: rgb(255, 255, 240); +} + +.eventToggle { + margin-top: -2px; + margin-left: -4px; + height: 9px; + width: 9px; + cursor: pointer; + background: url('Bitmaps/minus.png') no-repeat left top; +} + +.hide-children > .eventToggle { + background-image: url('Bitmaps/plus.png'); +} + +.hide-children > p.event:first-of-type > .message-style::after { + content: "[\2026]"; /* … */ + margin-left: 1em; + color: #5a7ac6; + font-size: smaller; +} + +.hide-children > p.event:not(:first-of-type,:last-of-type) { + display: none; +} + +/* Adapt styles to narrow windows */ +@media all and (max-width: 400px) { + div.messages-group > hr { + margin-right: 0; + } + + span.message-style { + margin-right: 0; + } + + span.date-next { + display: none; + } +} + +@media all and (max-width: 200px) { + span.date { + display: none; + } +} + +/* Adapt styles when the window is very low */ +@media all and (max-height: 200px) { + div.messages-group { + padding-bottom: 8px; + } + + div.messages-group:last-child { + padding-bottom: 8px; + } +} diff --git a/comm/mail/components/im/messages/simple/Incoming/Content.html b/comm/mail/components/im/messages/simple/Incoming/Content.html new file mode 100644 index 0000000000..ed8630393a --- /dev/null +++ b/comm/mail/components/im/messages/simple/Incoming/Content.html @@ -0,0 +1 @@ +<p class="%messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p> diff --git a/comm/mail/components/im/messages/simple/Incoming/Context.html b/comm/mail/components/im/messages/simple/Incoming/Context.html new file mode 100644 index 0000000000..8b0226d610 --- /dev/null +++ b/comm/mail/components/im/messages/simple/Incoming/Context.html @@ -0,0 +1 @@ +<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p> diff --git a/comm/mail/components/im/messages/simple/Incoming/NextContext.html b/comm/mail/components/im/messages/simple/Incoming/NextContext.html new file mode 100644 index 0000000000..8b0226d610 --- /dev/null +++ b/comm/mail/components/im/messages/simple/Incoming/NextContext.html @@ -0,0 +1 @@ +<p class="context %messageClasses%"><span class="date">%time%</span><span class="pseudo" style="%senderColor%">%sender%</span>%message%</p> diff --git a/comm/mail/components/im/messages/simple/Info.plist b/comm/mail/components/im/messages/simple/Info.plist new file mode 100644 index 0000000000..f32f062d7d --- /dev/null +++ b/comm/mail/components/im/messages/simple/Info.plist @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ActionMessageTemplate</key> + <string>%message%</string> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleGetInfoString</key> + <string>Instantbird Minimal Message Style</string> + <key>CFBundleIdentifier</key> + <string>org.instantbird.minimal.message.style</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>1.0</string> + <key>CFBundleName</key> + <string>Minimal</string> + <key>CFBundlePackageType</key> + <string>AdIM</string> + <key>DefaultBackgroundColor</key> + <string>FFFFFF</string> + <key>DefaultVariant</key> + <string>Normal</string> + <key>DisableCustomBackground</key> + <false/> + <key>MessageViewVersion</key> + <integer>4</integer> + <key>ShowsUserIcons</key> + <true/> + <key>NoScript</key> + <true/> +</dict> +</plist> diff --git a/comm/mail/components/im/messages/simple/Status.html b/comm/mail/components/im/messages/simple/Status.html new file mode 100644 index 0000000000..ce30b16cec --- /dev/null +++ b/comm/mail/components/im/messages/simple/Status.html @@ -0,0 +1 @@ +<p aria-live="polite" class="%messageClasses%"><span class="date">%time%</span>%message%</p> diff --git a/comm/mail/components/im/messages/simple/Variants/Dark.css b/comm/mail/components/im/messages/simple/Variants/Dark.css new file mode 100644 index 0000000000..ea5f0b8f5b --- /dev/null +++ b/comm/mail/components/im/messages/simple/Variants/Dark.css @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +body { + background: #222; + color: #eee; +} +.outgoing .pseudo { + color: #7878dc; +} +.incoming .pseudo { + color: #dc7878; +} +.event { + color: #828282; +} +a { + color: #5497ea; +} +.context { + color: #b2b2b4; +} diff --git a/comm/mail/components/im/messages/simple/Variants/Normal.css b/comm/mail/components/im/messages/simple/Variants/Normal.css new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/comm/mail/components/im/messages/simple/Variants/Normal.css diff --git a/comm/mail/components/im/messages/simple/main.css b/comm/mail/components/im/messages/simple/main.css new file mode 100644 index 0000000000..3baf44d1ab --- /dev/null +++ b/comm/mail/components/im/messages/simple/main.css @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#Chat { + white-space: normal; +} + +.pseudo { + font-weight: bold; +} + +.outgoing .pseudo { + color: rgb(80,80,200); +} + +.incoming .pseudo { + color: rgb(200,80,80); +} + +.date { + font-style: normal; + font-weight: normal; +} + +span.date::after { + content: " - "; +} + +.action > span.date::after { + content: " * "; +} + +span.pseudo::after { + content: ": "; +} + +.action > span.pseudo::after { + content: " "; +} + +.event > span.pseudo::after { + content: none; +} + +.event { + color: rgb(170,170,170); +} + +.nick { + font-weight: bold; +} + +.action { + font-style: italic; +} + +.context { + color: rgb(91,91,91); +} + +p.context > .pseudo, +p.context .ib-nick { + opacity: 0.7; +} + +p { + margin: 0px auto; +} + +p *:any-link img { + margin-bottom: 1px; + border-bottom: solid 1px; +} + +.ib-sender.message-encrypted { + position: relative; +} + +.ib-sender.message-encrypted::before { + position: relative; + display: inline-block; + content: ''; + width: 11px; + height: 10px; + opacity: 0.5; + background: url("chrome://messenger/skin/icons/connection-secure.svg") no-repeat center; + background-size: contain; + margin-inline-end: 4px; +} diff --git a/comm/mail/components/im/modules/ChatEncryption.sys.mjs b/comm/mail/components/im/modules/ChatEncryption.sys.mjs new file mode 100644 index 0000000000..4206b3397d --- /dev/null +++ b/comm/mail/components/im/modules/ChatEncryption.sys.mjs @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + OTRUI: "resource:///modules/OTRUI.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["messenger/otr/otrUI.ftl"], true) +); + +function _str(id) { + return lazy.l10n.formatValueSync(id); +} + +const STATE_STRING = { + [Ci.prplIConversation.ENCRYPTION_AVAILABLE]: "not-private", + [Ci.prplIConversation.ENCRYPTION_ENABLED]: "unverified", + [Ci.prplIConversation.ENCRYPTION_TRUSTED]: "private", +}; + +export const ChatEncryption = { + /** + * If OTR is enabled. + * + * @type {boolean} + */ + get otrEnabled() { + if (!this.hasOwnProperty("_otrEnabled")) { + this._otrEnabled = Services.prefs.getBoolPref("chat.otr.enable"); + } + return this._otrEnabled; + }, + /** + * Check if the given protocol has encryption settings for accounts. + * + * @param {prplIProtocol} protocol - Protocol to check against. + * @returns {boolean} If encryption can be configured. + */ + canConfigureEncryption(protocol) { + if (this.otrEnabled && lazy.OTRUI.enabled) { + return true; + } + return protocol.canEncrypt; + }, + /** + * Check if the conversation should offer encryption settings. + * + * @param {prplIConversation} conversation + * @returns {boolean} + */ + hasEncryptionActions(conversation) { + if (!conversation.isChat && this.otrEnabled && lazy.OTRUI.enabled) { + return true; + } + return ( + conversation.encryptionState !== + Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED + ); + }, + /** + * Show and initialize the encryption selector in the conversation UI for the + * given conversation, if encryption is available. + * + * @param {DOMDocument} document + * @param {imIConversation} conversation + */ + updateEncryptionButton(document, conversation) { + if (!this.hasEncryptionActions(conversation)) { + this.hideEncryptionButton(document); + } + if ( + conversation.encryptionState !== + Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED + ) { + // OTR is not available if the conversation can natively encrypt + document.querySelector(".otr-start").hidden = true; + document.querySelector(".otr-end").hidden = true; + document.querySelector(".otr-auth").hidden = true; + lazy.OTRUI.hideAllOTRNotifications(); + + const actionsAvailable = + conversation.encryptionState !== + Ci.prplIConversation.ENCRYPTION_AVAILABLE; + + document.querySelector(".protocol-encrypt").hidden = false; + document.querySelector(".protocol-encrypt").disabled = actionsAvailable; + document.querySelector(".encryption-container").hidden = false; + + const trustStringLevel = STATE_STRING[conversation.encryptionState]; + const otrButton = document.querySelector(".encryption-button"); + otrButton.setAttribute( + "tooltiptext", + _str("state-generic-" + trustStringLevel) + ); + otrButton.setAttribute( + "label", + _str("state-" + trustStringLevel + "-label") + ); + otrButton.className = "encryption-button encryption-" + trustStringLevel; + } else if (!conversation.isChat && lazy.OTRUI.enabled) { + document.querySelector(".otr-start").hidden = false; + document.querySelector(".otr-end").hidden = false; + document.querySelector(".otr-auth").hidden = false; + lazy.OTRUI.updateOTRButton(conversation); + document.querySelector(".protocol-encrypt").hidden = true; + } else { + this.hideEncryptionButton(document); + } + }, + /** + * Hide the encryption selector in the converstaion UI. + * + * @param {DOMDocument} document + */ + hideEncryptionButton(document) { + document.querySelector(".encryption-container").hidden = true; + if (this.otrEnabled) { + lazy.OTRUI.hideOTRButton(); + } + }, + /** + * Verify identity of a participant of buddy. + * + * @param {DOMWindow} window - Window that the verification dialog attaches to. + * @param {prplIAccountBuddy|prplIConvChatBuddy} buddy - Buddy to verify. + */ + verifyIdentity(window, buddy) { + if (!buddy.canVerifyIdentity) { + Promise.resolve(); + } + buddy + .verifyIdentity() + .then(sessionVerification => { + window.openDialog( + "chrome://messenger/content/chat/verify.xhtml", + "", + "chrome,modal,titlebar,centerscreen", + sessionVerification + ); + }) + .catch(error => { + // Only prplIAccountBuddy has a reference to the owner account. + if (buddy.account) { + buddy.account.prplAccount.wrappedJSObject.ERROR(error); + } else { + console.error(error); + } + }); + }, +}; diff --git a/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs new file mode 100644 index 0000000000..f97519ddea --- /dev/null +++ b/comm/mail/components/im/modules/GlodaIMSearcher.sys.mjs @@ -0,0 +1,352 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Gloda } = ChromeUtils.import( + "resource:///modules/gloda/GlodaPublic.jsm" +); + +/** + * How much time boost should a 'score point' amount to? The authoritative, + * incontrivertible answer, across all time and space, is a week. + * Note that gloda stores conversation timestamps in seconds. + */ +// var FUZZSCORE_TIMESTAMP_FACTOR = 60 * 60 * 24 * 7; + +// var RANK_USAGE = +// "glodaRank(matchinfo(imConversationsText), 1.0, 2.0, 2.0, 1.5, 1.5)"; + +var DASCORE = "imConversations.time"; +// "(((" + RANK_USAGE + ") * " + +// FUZZSCORE_TIMESTAMP_FACTOR + +// ") + imConversations.time)"; + +/** + * A new optimization decision we are making is that we do not want to carry + * around any data in our ephemeral tables that is not used for whittling the + * result set. The idea is that the btree page cache or OS cache is going to + * save us from the disk seeks and carrying around the extra data is just going + * to be CPU/memory churn that slows us down. + * + * Additionally, we try and avoid row lookups that would have their results + * discarded by the LIMIT. Because of limitations in FTS3 (which might + * be addressed in FTS4 by a feature request), we can't avoid the 'imConversations' + * lookup since that has the message's date and static notability but we can + * defer the 'imConversationsText' lookup. + * + * This is the access pattern we are after here: + * 1) Order the matches with minimized lookup and result storage costs. + * - The innermost MATCH does the doclist magic and provides us with + * matchinfo() support which does not require content row retrieval + * from imConversationsText. Unfortunately, this is not enough to whittle anything + * because we still need static interestingness, so... + * - Based on the match we retrieve the date and notability for that row from + * 'imConversations' using this in conjunction with matchinfo() to provide a score + * that we can then use to LIMIT our results. + * 2) We reissue the MATCH query so that we will be able to use offsets(), but + * we intersect the results of this MATCH against our LIMITed results from + * step 1. + * - We use 'docid IN (phase 1 query)' to accomplish this because it results in + * efficient lookup. If we just use a join, we get O(mn) performance because + * a cartesian join ends up being performed where either we end up performing + * the fulltext query M times and table scan intersect with the results from + * phase 1 or we do the fulltext once but traverse the entire result set from + * phase 1 N times. + * - We believe that the re-execution of the MATCH query should have no disk + * costs because it should still be cached by SQLite or the OS. In the case + * where memory is so constrained this is not true our behavior is still + * probably preferable than the old way because that would have caused lots + * of swapping. + * - This part of the query otherwise resembles the basic gloda query but with + * the inclusion of the offsets() invocation. The imConversations table lookup + * should not involve any disk traffic because the pages should still be + * cached (SQLite or OS) from phase 1. The imConversationsText lookup is new, and + * this is the major disk-seek reduction optimization we are making. (Since + * we avoid this lookup for all of the documents that were excluded by the + * LIMIT.) Since offsets() also needs to retrieve the row from imConversationsText + * there is a nice synergy there. + */ +var NUEVO_FULLTEXT_SQL = + "SELECT imConversations.*, imConversationsText.*, offsets(imConversationsText) AS osets " + + "FROM imConversationsText, imConversations " + + "WHERE" + + " imConversationsText MATCH ?1 " + + " AND imConversationsText.docid IN (" + + "SELECT docid " + + "FROM imConversationsText JOIN imConversations ON imConversationsText.docid = imConversations.id " + + "WHERE imConversationsText MATCH ?1 " + + "ORDER BY " + + DASCORE + + " DESC " + + "LIMIT ?2" + + " )" + + " AND imConversations.id = imConversationsText.docid"; + +function identityFunc(x) { + return x; +} + +function oneLessMaxZero(x) { + if (x <= 1) { + return 0; + } + return x - 1; +} + +function reduceSum(accum, curValue) { + return accum + curValue; +} + +/* + * Columns are: body, subject, attachment names, author, recipients + */ + +/** + * Scores if all search terms match in a column. We bias against author + * slightly and recipient a bit more in this case because a search that + * entirely matches just on a person should give a mention of that person + * in the subject or attachment a fighting chance. + * Keep in mind that because of our indexing in the face of address book + * contacts (namely, we index the name used in the e-mail as well as the + * display name on the address book card associated with the e-mail address) + * a contact is going to bias towards matching multiple times. + */ +var COLUMN_ALL_MATCH_SCORES = [4, 20, 20, 16, 12]; +/** + * Score for each distinct term that matches in the column. This is capped + * by COLUMN_ALL_SCORES. + */ +var COLUMN_PARTIAL_PER_MATCH_SCORES = [1, 4, 4, 4, 3]; +/** + * If a term matches multiple times, what is the marginal score for each + * additional match. We count the total number of matches beyond the + * first match for each term. In other words, if we have 3 terms which + * matched 5, 3, and 0 times, then the total from our perspective is + * (5 - 1) + (3 - 1) + 0 = 4 + 2 + 0 = 6. We take the minimum of that value + * and the value in COLUMN_MULTIPLE_MATCH_LIMIT and multiply by the value in + * COLUMN_MULTIPLE_MATCH_SCORES. + */ +var COLUMN_MULTIPLE_MATCH_SCORES = [1, 0, 0, 0, 0]; +var COLUMN_MULTIPLE_MATCH_LIMIT = [10, 0, 0, 0, 0]; + +/** + * Score the message on its offsets (from stashedColumns). + */ +function scoreOffsets(aMessage, aContext) { + let score = 0; + + let termTemplate = aContext.terms.map(_ => 0); + // for each column, a list of the incidence of each term + let columnTermIncidence = [ + termTemplate.concat(), + termTemplate.concat(), + termTemplate.concat(), + termTemplate.concat(), + termTemplate.concat(), + ]; + + // we need a friendlyParseInt because otherwise the radix stuff happens + // because of the extra arguments map parses. curse you, map! + let offsetNums = aContext.stashedColumns[aMessage.id][0] + .split(" ") + .map(x => parseInt(x)); + for (let i = 0; i < offsetNums.length; i += 4) { + let columnIndex = offsetNums[i]; + let termIndex = offsetNums[i + 1]; + columnTermIncidence[columnIndex][termIndex]++; + } + + for (let iColumn = 0; iColumn < COLUMN_ALL_MATCH_SCORES.length; iColumn++) { + let termIncidence = columnTermIncidence[iColumn]; + if (termIncidence.every(identityFunc)) { + // Bestow all match credit. + score += COLUMN_ALL_MATCH_SCORES[iColumn]; + } else if (termIncidence.some(identityFunc)) { + // Bestow partial match credit. + score += Math.min( + COLUMN_ALL_MATCH_SCORES[iColumn], + COLUMN_PARTIAL_PER_MATCH_SCORES[iColumn] * + termIncidence.filter(identityFunc).length + ); + } + // bestow multiple match credit + score += + Math.min( + termIncidence.map(oneLessMaxZero).reduce(reduceSum, 0), + COLUMN_MULTIPLE_MATCH_LIMIT[iColumn] + ) * COLUMN_MULTIPLE_MATCH_SCORES[iColumn]; + } + + return score; +} + +/** + * The searcher basically looks like a query, but is specialized for fulltext + * search against imConversations. Most of the explicit specialization involves + * crafting a SQL query that attempts to order the matches by likelihood that + * the user was looking for it. This is based on full-text matches combined + * with an explicit (generic) interest score value placed on the message at + * indexing time (TODO). This is followed by using the more generic gloda scoring + * mechanism to explicitly score the IM conversations given the search context in + * addition to the more generic score adjusting rules. + */ +export function GlodaIMSearcher(aListener, aSearchString, aAndTerms) { + this.listener = aListener; + + this.searchString = aSearchString; + this.fulltextTerms = this.parseSearchString(aSearchString); + this.andTerms = aAndTerms != null ? aAndTerms : true; + + this.query = null; + this.collection = null; + + this.scores = null; +} + +GlodaIMSearcher.prototype = { + /** + * Number of messages to retrieve initially. + */ + get retrievalLimit() { + return Services.prefs.getIntPref( + "mailnews.database.global.search.im.limit" + ); + }, + + /** + * Parse the string into terms/phrases by finding matching double-quotes. + */ + parseSearchString(aSearchString) { + aSearchString = aSearchString.trim(); + let terms = []; + + /* + * Add the term as long as the trim on the way in didn't obliterate it. + * + * In the future this might have other helper logic; it did once before. + */ + function addTerm(aTerm) { + if (aTerm) { + terms.push(aTerm); + } + } + + while (aSearchString) { + if (aSearchString.startsWith('"')) { + let endIndex = aSearchString.indexOf(aSearchString[0], 1); + // eat the quote if it has no friend + if (endIndex == -1) { + aSearchString = aSearchString.substring(1); + continue; + } + + addTerm(aSearchString.substring(1, endIndex).trim()); + aSearchString = aSearchString.substring(endIndex + 1); + continue; + } + + let spaceIndex = aSearchString.indexOf(" "); + if (spaceIndex == -1) { + addTerm(aSearchString); + break; + } + + addTerm(aSearchString.substring(0, spaceIndex)); + aSearchString = aSearchString.substring(spaceIndex + 1); + } + + return terms; + }, + + buildFulltextQuery() { + let query = Gloda.newQuery(Gloda.lookupNoun("im-conversation"), { + noMagic: true, + explicitSQL: NUEVO_FULLTEXT_SQL, + limitClauseAlreadyIncluded: true, + // osets is 0-based column number 4 (volatile to column changes) + // save the offset column for extra analysis + stashColumns: [6], + }); + + let fulltextQueryString = ""; + + for (let [iTerm, term] of this.fulltextTerms.entries()) { + if (iTerm) { + fulltextQueryString += this.andTerms ? " " : " OR "; + } + + // Put our term in quotes. This is needed for the tokenizer to be able + // to do useful things. The exception is people clever enough to use + // NEAR. + if (/^NEAR(\/\d+)?$/.test(term)) { + fulltextQueryString += term; + } else if (term.length == 1 && term.charCodeAt(0) >= 0x2000) { + // This is a single-character CJK search query, so add a wildcard. + // Our tokenizer treats anything at/above 0x2000 as CJK for now. + fulltextQueryString += term + "*"; + } else if ( + (term.length == 2 && + term.charCodeAt(0) >= 0x2000 && + term.charCodeAt(1) >= 0x2000) || + term.length >= 3 + ) { + fulltextQueryString += '"' + term + '"'; + } + } + + query.fulltextMatches(fulltextQueryString); + query.limit(this.retrievalLimit); + + return query; + }, + + getCollection(aListenerOverride, aData) { + if (aListenerOverride) { + this.listener = aListenerOverride; + } + + this.query = this.buildFulltextQuery(); + this.collection = this.query.getCollection(this, aData); + this.completed = false; + + return this.collection; + }, + + sortBy: "-dascore", + + onItemsAdded(aItems, aCollection) { + let newScores = Gloda.scoreNounItems( + aItems, + { + terms: this.fulltextTerms, + stashedColumns: aCollection.stashedColumns, + }, + [scoreOffsets] + ); + if (this.scores) { + this.scores = this.scores.concat(newScores); + } else { + this.scores = newScores; + } + + if (this.listener) { + this.listener.onItemsAdded(aItems, aCollection); + } + }, + onItemsModified(aItems, aCollection) { + if (this.listener) { + this.listener.onItemsModified(aItems, aCollection); + } + }, + onItemsRemoved(aItems, aCollection) { + if (this.listener) { + this.listener.onItemsRemoved(aItems, aCollection); + } + }, + onQueryCompleted(aCollection) { + this.completed = true; + if (this.listener) { + this.listener.onQueryCompleted(aCollection); + } + }, +}; diff --git a/comm/mail/components/im/modules/chatHandler.sys.mjs b/comm/mail/components/im/modules/chatHandler.sys.mjs new file mode 100644 index 0000000000..4b54535aa5 --- /dev/null +++ b/comm/mail/components/im/modules/chatHandler.sys.mjs @@ -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/. */ + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +export var allContacts = {}; +export var onlineContacts = {}; + +export var ChatCore = { + initialized: false, + _initializing: false, + init() { + if (this._initializing) { + return; + } + this._initializing = true; + + Services.obs.addObserver(this, "browser-request"); + Services.obs.addObserver(this, "contact-signed-on"); + Services.obs.addObserver(this, "contact-signed-off"); + Services.obs.addObserver(this, "contact-added"); + Services.obs.addObserver(this, "contact-removed"); + }, + idleStart() { + IMServices.core.init(); + + // Find the accounts that exist in the im account service but + // not in nsMsgAccountManager. They have probably been lost if + // the user has used an older version of Thunderbird on a + // profile with IM accounts. See bug 736035. + let accountsById = {}; + for (let account of IMServices.accounts.getAccounts()) { + accountsById[account.numericId] = account; + } + for (let account of MailServices.accounts.accounts) { + let incomingServer = account.incomingServer; + if (!incomingServer || incomingServer.type != "im") { + continue; + } + delete accountsById[incomingServer.wrappedJSObject.imAccount.numericId]; + } + // Let's recreate each of them... + for (let id in accountsById) { + let account = accountsById[id]; + let inServer = MailServices.accounts.createIncomingServer( + account.name, + account.protocol.id, // hostname + "im" + ); + inServer.wrappedJSObject.imAccount = account; + let acc = MailServices.accounts.createAccount(); + // Avoid new folder notifications. + inServer.valid = false; + acc.incomingServer = inServer; + inServer.valid = true; + MailServices.accounts.notifyServerLoaded(inServer); + } + + IMServices.tags.getTags().forEach(function (aTag) { + aTag.getContacts().forEach(function (aContact) { + let name = aContact.preferredBuddy.normalizedName; + allContacts[name] = aContact; + }); + }); + + ChatCore.initialized = true; + Services.obs.notifyObservers(null, "chat-core-initialized"); + ChatCore._initializing = false; + }, + observe(aSubject, aTopic, aData) { + if (aTopic == "browser-request") { + Services.ww.openWindow( + null, + "chrome://messenger/content/browserRequest.xhtml", + null, + "chrome,private,centerscreen,width=980,height=750", + aSubject + ); + return; + } + + if (aTopic == "contact-signed-on") { + onlineContacts[aSubject.preferredBuddy.normalizedName] = aSubject; + return; + } + + if (aTopic == "contact-signed-off") { + delete onlineContacts[aSubject.preferredBuddy.normalizedName]; + return; + } + + if (aTopic == "contact-added") { + allContacts[aSubject.preferredBuddy.normalizedName] = aSubject; + return; + } + + if (aTopic == "contact-removed") { + delete allContacts[aSubject.preferredBuddy.normalizedName]; + } + }, +}; diff --git a/comm/mail/components/im/modules/chatIcons.sys.mjs b/comm/mail/components/im/modules/chatIcons.sys.mjs new file mode 100644 index 0000000000..e965c23183 --- /dev/null +++ b/comm/mail/components/im/modules/chatIcons.sys.mjs @@ -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/. */ + +export var ChatIcons = { + /** + * Get the icon URI for the given protocol. + * + * @param {prplIProtocol} protocol - The protocol to get the icon URI for. + * @param {16|32|48} [size=16] - The width and height of the icon. + * + * @returns {string} - The icon's URI. + */ + getProtocolIconURI(protocol, size = 16) { + return `${protocol.iconBaseURI}icon${size === 16 ? "" : size}.png`; + }, + + /** + * Sets the opacity of the given protocol icon depending on the given chat + * status (see getStatusIconURI). + * + * @param {HTMLImageElement} protoIconElement - The protocol icon. + * @param {string} statusName - The name for the chat status. + */ + setProtocolIconOpacity(protoIconElement, statusName) { + switch (statusName) { + case "unknown": + case "offline": + case "left": + protoIconElement.classList.add("protoIconDimmed"); + break; + default: + protoIconElement.classList.remove("protoIconDimmed"); + } + }, + + fallbackUserIconURI: "chrome://messenger/skin/icons/userIcon.svg", + + /** + * Set up the user icon to show the given uri, or a fallback. + * + * @param {HTMLImageElement} userIconElement - An icon with the "userIcon" + * class. + * @param {string|null} iconUri - The uri to set, or "" to use a fallback + * icon, or null to hide the icon. + * @param {boolean} useFallback - True if the "fallback" icon should be shown + * if iconUri isn't provided. + */ + setUserIconSrc(userIconElement, iconUri, useFallback) { + if (iconUri) { + userIconElement.setAttribute("src", iconUri); + userIconElement.classList.remove("fillUserIcon"); + } else if (useFallback) { + userIconElement.setAttribute("src", this.fallbackUserIconURI); + userIconElement.classList.add("fillUserIcon"); + } else { + userIconElement.removeAttribute("src"); + userIconElement.classList.remove("fillUserIcon"); + } + }, + + /** + * Get the icon URI for the given chat status. Often given statusName would be + * the return of Status.toAttribute for a given status type. But a few more + * terms or aliases are supported. + * + * @param {string} statusName - The name for the chat status. + * + * @returns {string|null} - The icon URI for the given status, or null if none + * exists. + */ + getStatusIconURI(statusName) { + switch (statusName) { + case "unknown": + return "chrome://chat/skin/unknown.svg"; + case "available": + case "connected": + return "chrome://messenger/skin/icons/new/status-online.svg"; + case "unavailable": + case "away": + return "chrome://messenger/skin/icons/new/status-away.svg"; + case "offline": + case "disconnected": + case "invisible": + case "left": + return "chrome://messenger/skin/icons/new/status-offline.svg"; + case "connecting": + case "disconnecting": + case "joining": + return "chrome://global/skin/icons/loading.png"; + case "idle": + return "chrome://messenger/skin/icons/new/status-idle.svg"; + case "mobile": + return "chrome://chat/skin/mobile.svg"; + case "chat": + return "chrome://messenger/skin/icons/new/compact/chat.svg"; + case "chat-left": + return "chrome://chat/skin/chat-left.svg"; + case "active-typing": + return "chrome://chat/skin/typing.svg"; + case "paused-typing": + return "chrome://chat/skin/typed.svg"; + } + return null; + }, +}; diff --git a/comm/mail/components/im/modules/chatNotifications.sys.mjs b/comm/mail/components/im/modules/chatNotifications.sys.mjs new file mode 100644 index 0000000000..664fe4e5ca --- /dev/null +++ b/comm/mail/components/im/modules/chatNotifications.sys.mjs @@ -0,0 +1,262 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { PluralForm } from "resource://gre/modules/PluralForm.sys.mjs"; + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { ChatIcons } from "resource:///modules/chatIcons.sys.mjs"; + +// Time in seconds: it is the minimum time of inactivity +// needed to show the bundled notification. +var kTimeToWaitForMoreMsgs = 3; + +export var Notifications = { + get ellipsis() { + let ellipsis = "[\u2026]"; + + try { + ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + return ellipsis; + }, + + // Holds the first direct message of a bundle while we wait for further + // messages from the same sender to arrive. + _heldMessage: null, + // Number of messages to be bundled in the notification (excluding + // _heldMessage). + _msgCounter: 0, + // Time the last message was received. + _lastMessageTime: 0, + // Sender of the last message. + _lastMessageSender: null, + // timeout Id for the set timeout for showing notification. + _timeoutId: null, + + _showMessageNotification(aMessage, aCounter = 0) { + // We are about to show the notification, so let's play the notification sound. + // We play the sound if the user is away from TB window or even away from chat tab. + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if ( + !Services.focus.activeWindow || + win.document.getElementById("tabmail").currentTabInfo.mode.name != "chat" + ) { + Services.obs.notifyObservers(aMessage, "play-chat-notification-sound"); + } + + // If TB window has focus, there's no need to show the notification.. + if (win && win.document.hasFocus()) { + this._heldMessage = null; + this._msgCounter = 0; + return; + } + + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + let messageText, icon, name; + let notificationContent = Services.prefs.getIntPref( + "mail.chat.notification_info" + ); + // 0 - show all the info, + // 1 - show only the sender not the message, + // 2 - show no details about the message being notified. + switch (notificationContent) { + case 0: + let parser = new DOMParser(); + let doc = parser.parseFromString(aMessage.displayMessage, "text/html"); + let body = doc.querySelector("body"); + let encoder = Cu.createDocumentEncoder("text/plain"); + encoder.init(doc, "text/plain", 0); + encoder.setNode(body); + messageText = encoder.encodeToString().replace(/\s+/g, " "); + + // Crop the end of the text if needed. + if (messageText.length > 50) { + messageText = messageText.substr(0, 50); + if (aCounter == 0) { + messageText = messageText + this.ellipsis; + } + } + + // If there are more messages being bundled, add the count string. + // ellipsis is a part of bundledMessagePreview so we don't include it here. + if (aCounter > 0) { + let bundledMessage = bundle.formatStringFromName( + "bundledMessagePreview", + [messageText] + ); + messageText = PluralForm.get(aCounter, bundledMessage).replace( + "#1", + aCounter + ); + } + // Falls through + case 1: + // Use the buddy icon if available for the icon of the notification. + let conv = aMessage.conversation; + icon = conv.convIconFilename; + if (!icon && !conv.isChat) { + icon = conv.buddy?.buddyIconFilename; + } + + // Handle third person messages + name = aMessage.alias || aMessage.who; + if (messageText && aMessage.action) { + messageText = name + " " + messageText; + } + // Falls through + case 2: + if (!icon) { + icon = ChatIcons.fallbackUserIconURI; + } + + if (!messageText) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/chat.properties" + ); + messageText = bundle.GetStringFromName("messagePreview"); + } + } + + let alert = Cc["@mozilla.org/alert-notification;1"].createInstance( + Ci.nsIAlertNotification + ); + alert.init( + "", // name + icon, + name, // title + messageText, + true // clickable + ); + // Show the notification! + Cc["@mozilla.org/alerts-service;1"] + .getService(Ci.nsIAlertsService) + .showAlert(alert, (subject, topic, data) => { + if (topic != "alertclickcallback") { + return; + } + + // If there is a timeout set, clear it. + clearTimeout(this._timeoutId); + this._heldMessage = null; + this._msgCounter = 0; + this._lastMessageTime = 0; + this._lastMessageSender = null; + // Focus the conversation if the notification is clicked. + let uiConv = IMServices.conversations.getUIConversation( + aMessage.conversation + ); + let mainWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mainWindow) { + mainWindow.focus(); + mainWindow.showChatTab(); + mainWindow.chatHandler.focusConversation(uiConv); + } else { + Services.appShell.hiddenDOMWindow.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + null, + { + tabType: "chat", + tabParams: { convType: "focus", conv: uiConv }, + } + ); + } + if (AppConstants.platform == "macosx") { + Cc["@mozilla.org/widget/macdocksupport;1"] + .getService(Ci.nsIMacDockSupport) + .activateApplication(true); + } + }); + + this._heldMessage = null; + this._msgCounter = 0; + }, + + init() { + Services.obs.addObserver(Notifications, "new-otr-verification-request"); + Services.obs.addObserver(Notifications, "new-directed-incoming-message"); + Services.obs.addObserver(Notifications, "alertclickcallback"); + }, + + _notificationPrefName: "mail.chat.show_desktop_notifications", + observe(aSubject, aTopic, aData) { + if (!Services.prefs.getBoolPref(this._notificationPrefName)) { + return; + } + + switch (aTopic) { + case "new-directed-incoming-message": + // If this is the first message, we show the notification and + // store the sender's name. + let sender = aSubject.who || aSubject.alias; + if (this._lastMessageSender == null) { + this._lastMessageSender = sender; + this._lastMessageTime = aSubject.time; + this._showMessageNotification(aSubject); + } else if ( + this._lastMessageSender != sender || + aSubject.time > this._lastMessageTime + kTimeToWaitForMoreMsgs + ) { + // If the sender is not the same as the previous sender or the + // time elapsed since the last message is greater than kTimeToWaitForMoreMsgs, + // we show the held notification and set timeout for the message just arrived. + if (this._heldMessage) { + // if the time for the current message is greater than _lastMessageTime by + // more than kTimeToWaitForMoreMsgs, this will not happen since the notification will + // have already been dispatched. + clearTimeout(this._timeoutId); + this._showMessageNotification(this._heldMessage, this._msgCounter); + } + this._lastMessageSender = sender; + this._lastMessageTime = aSubject.time; + this._showMessageNotification(aSubject); + } else if ( + this._lastMessageSender == sender && + this._lastMessageTime + kTimeToWaitForMoreMsgs >= aSubject.time + ) { + // If the sender is same as the previous sender and the time elapsed since the + // last held message is less than kTimeToWaitForMoreMsgs, we increase the held messages + // counter and update the last message's arrival time. + this._lastMessageTime = aSubject.time; + if (!this._heldMessage) { + this._heldMessage = aSubject; + } else { + this._msgCounter++; + } + + clearTimeout(this._timeoutId); + this._timeoutId = setTimeout(() => { + this._showMessageNotification(this._heldMessage, this._msgCounter); + }, kTimeToWaitForMoreMsgs * 1000); + } + break; + + case "new-otr-verification-request": + // If the Chat tab is not focused, play the sounds and update the icon + // counter, and show the counter in the buddy richlistitem. + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if ( + !Services.focus.activeWindow || + win.document.getElementById("tabmail").currentTabInfo.mode.name != + "chat" + ) { + Services.obs.notifyObservers( + aSubject, + "play-chat-notification-sound" + ); + } + + break; + } + }, +}; diff --git a/comm/mail/components/im/modules/index_im.sys.mjs b/comm/mail/components/im/modules/index_im.sys.mjs new file mode 100644 index 0000000000..bcea54e1ea --- /dev/null +++ b/comm/mail/components/im/modules/index_im.sys.mjs @@ -0,0 +1,928 @@ +/* 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 CC = Components.Constructor; + +const { Gloda } = ChromeUtils.import( + "resource:///modules/gloda/GlodaPublic.jsm" +); +const { GlodaAccount } = ChromeUtils.import( + "resource:///modules/gloda/GlodaDataModel.jsm" +); +const { GlodaConstants } = ChromeUtils.import( + "resource:///modules/gloda/GlodaConstants.jsm" +); +const { GlodaIndexer, IndexingJob } = ChromeUtils.import( + "resource:///modules/gloda/GlodaIndexer.jsm" +); +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "GlodaDatastore", + "resource:///modules/gloda/GlodaDatastore.jsm" +); + +var kCacheFileName = "indexedFiles.json"; + +var FileInputStream = CC( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); +var ScriptableInputStream = CC( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" +); + +// kIndexingDelay is how long we wait from the point of scheduling an indexing +// job to actually carrying it out. +var kIndexingDelay = 5000; // in milliseconds + +XPCOMUtils.defineLazyGetter(lazy, "MailFolder", () => + Cc["@mozilla.org/mail/folder-factory;1?name=mailbox"].createInstance( + Ci.nsIMsgFolder + ) +); + +var gIMAccounts = {}; + +function GlodaIMConversation(aTitle, aTime, aPath, aContent) { + // grokNounItem from Gloda.jsm puts automatically the values of all + // JS properties in the jsonAttributes magic attribute, except if + // they start with _, so we put the values in _-prefixed properties, + // and have getters in the prototype. + this._title = aTitle; + this._time = aTime; + this._path = aPath; + this._content = aContent; +} +GlodaIMConversation.prototype = { + get title() { + return this._title; + }, + get time() { + return this._time; + }, + get path() { + return this._path; + }, + get content() { + return this._content; + }, + + // for glodaFacetBindings.xml compatibility (pretend we are a message object) + get account() { + let [protocol, username] = this._path.split("/", 2); + + let cacheName = protocol + "/" + username; + if (cacheName in gIMAccounts) { + return gIMAccounts[cacheName]; + } + + // Find the nsIIncomingServer for the current imIAccount. + for (let account of MailServices.accounts.accounts) { + let incomingServer = account.incomingServer; + if (!incomingServer || incomingServer.type != "im") { + continue; + } + let imAccount = incomingServer.wrappedJSObject.imAccount; + if ( + imAccount.protocol.normalizedName == protocol && + imAccount.normalizedName == username + ) { + return (gIMAccounts[cacheName] = new GlodaAccount(incomingServer)); + } + } + // The IM conversation is probably for an account that no longer exists. + return null; + }, + get subject() { + return this._title; + }, + get date() { + return new Date(this._time * 1000); + }, + get involves() { + return GlodaConstants.IGNORE_FACET; + }, + _recipients: null, + get recipients() { + if (!this._recipients) { + this._recipients = [{ contact: { name: this._path.split("/", 2)[1] } }]; + } + return this._recipients; + }, + _from: null, + get from() { + if (!this._from) { + let from = ""; + let account = this.account; + if (account) { + from = account.incomingServer.wrappedJSObject.imAccount.protocol.name; + } + this._from = { value: "", contact: { name: from } }; + } + return this._from; + }, + get tags() { + return []; + }, + get starred() { + return false; + }, + get attachmentNames() { + return null; + }, + get indexedBodyText() { + return this._content; + }, + get read() { + return true; + }, + get folder() { + return GlodaConstants.IGNORE_FACET; + }, + + // for glodaFacetView.js _removeDupes + get headerMessageID() { + return this.id; + }, +}; + +// FIXME +var WidgetProvider = { + providerName: "widget", + *process() { + // XXX What is this supposed to do? + yield GlodaConstants.kWorkDone; + }, +}; + +var IMConversationNoun = { + name: "im-conversation", + clazz: GlodaIMConversation, + allowsArbitraryAttrs: true, + tableName: "imConversations", + schema: { + columns: [ + ["id", "INTEGER PRIMARY KEY"], + ["title", "STRING"], + ["time", "NUMBER"], + ["path", "STRING"], + ], + fulltextColumns: [["content", "STRING"]], + }, +}; +Gloda.defineNoun(IMConversationNoun); + +// Needs to be set after calling defineNoun, otherwise it's replaced +// by GlodaDatabind.jsm' implementation. +IMConversationNoun.objFromRow = function (aRow) { + // Row columns are: + // 0 id + // 1 title + // 2 time + // 3 path + // 4 jsonAttributes + // 5 content + // 6 offsets + let conv = new GlodaIMConversation( + aRow.getString(1), + aRow.getInt64(2), + aRow.getString(3), + aRow.getString(5) + ); + conv.id = aRow.getInt64(0); // handleResult will keep only our first result + // if the id property isn't set. + return conv; +}; + +var EXT_NAME = "im"; + +// --- special (on-row) attributes +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "time", + singular: true, + special: GlodaConstants.kSpecialColumn, + specialColumnName: "time", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_NUMBER, + canQuery: true, +}); +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "title", + singular: true, + special: GlodaConstants.kSpecialString, + specialColumnName: "title", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_STRING, + canQuery: true, +}); +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "path", + singular: true, + special: GlodaConstants.kSpecialString, + specialColumnName: "path", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_STRING, + canQuery: true, +}); + +// --- fulltext attributes +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "content", + singular: true, + special: GlodaConstants.kSpecialFulltext, + specialColumnName: "content", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_FULLTEXT, + canQuery: true, +}); + +// -- fulltext search helper +// fulltextMatches. Match over message subject, body, and attachments +// @testpoint gloda.noun.message.attr.fulltextMatches +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrDerived, + attributeName: "fulltextMatches", + singular: true, + special: GlodaConstants.kSpecialFulltext, + specialColumnName: "imConversationsText", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_FULLTEXT, +}); +// For Facet.jsm DateFaceter +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrDerived, + attributeName: "date", + singular: true, + special: GlodaConstants.kSpecialColumn, + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_NUMBER, + facet: { + type: "date", + }, + canQuery: true, +}); + +var GlodaIMIndexer = { + name: "index_im", + cacheVersion: 1, + enable() { + Services.obs.addObserver(this, "conversation-closed"); + Services.obs.addObserver(this, "new-ui-conversation"); + Services.obs.addObserver(this, "conversation-update-type"); + Services.obs.addObserver(this, "ui-conversation-closed"); + Services.obs.addObserver(this, "ui-conversation-replaced"); + + // The shutdown blocker ensures pending saves happen even if the app + // gets shut down before the timer fires. + if (this._shutdownBlockerAdded) { + return; + } + this._shutdownBlockerAdded = true; + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "GlodaIMIndexer cache save", + () => { + if (!this._cacheSaveTimer) { + return Promise.resolve(); + } + clearTimeout(this._cacheSaveTimer); + return this._saveCacheNow(); + } + ); + + this._knownFiles = {}; + + let dir = FileUtils.getFile("ProfD", ["logs"]); + if (!dir.exists() || !dir.isDirectory()) { + return; + } + let cacheFile = dir.clone(); + cacheFile.append(kCacheFileName); + if (!cacheFile.exists()) { + return; + } + + const PR_RDONLY = 0x01; + let fis = new FileInputStream( + cacheFile, + PR_RDONLY, + parseInt("0444", 8), + Ci.nsIFileInputStream.CLOSE_ON_EOF + ); + let sis = new ScriptableInputStream(fis); + let text = sis.read(sis.available()); + sis.close(); + + let data = JSON.parse(text); + + // Check to see if the Gloda datastore ID matches the one that we saved + // in the cache. If so, we can trust it. If not, that means that the + // cache is likely invalid now, so we ignore it (and eventually + // overwrite it). + if ( + "datastoreID" in data && + Gloda.datastoreID && + data.datastoreID === Gloda.datastoreID + ) { + // Ok, the cache's datastoreID matches the one we expected, so it's + // still valid. + this._knownFiles = data.knownFiles; + } + + this.cacheVersion = data.version; + + // If there was no version set on the cache, there is a chance that the index + // is affected by bug 1069845. fixEntriesWithAbsolutePaths() sets the version to 1. + if (!this.cacheVersion) { + this.fixEntriesWithAbsolutePaths(); + } + }, + disable() { + Services.obs.removeObserver(this, "conversation-closed"); + Services.obs.removeObserver(this, "new-ui-conversation"); + Services.obs.removeObserver(this, "conversation-update-type"); + Services.obs.removeObserver(this, "ui-conversation-closed"); + Services.obs.removeObserver(this, "ui-conversation-replaced"); + }, + + /* _knownFiles is a tree whose leaves are the last modified times of + * log files when they were last indexed. + * Each level of the tree is stored as an object. The root node is an + * object that maps a protocol name to an object representing the subtree + * for that protocol. The structure is: + * _knownFiles -> protoObj -> accountObj -> convObj + * The corresponding keys of the above objects are: + * protocol names -> account names -> conv names -> file names -> last modified time + * convObj maps ALL previously indexed log files of a chat buddy or MUC to + * their last modified times. Note that gloda knows nothing about log grouping + * done by logger.js. + */ + _knownFiles: {}, + _cacheSaveTimer: null, + _shutdownBlockerAdded: false, + _scheduleCacheSave() { + if (this._cacheSaveTimer) { + return; + } + this._cacheSaveTimer = setTimeout(this._saveCacheNow, 5000); + }, + _saveCacheNow() { + GlodaIMIndexer._cacheSaveTimer = null; + + let data = { + knownFiles: GlodaIMIndexer._knownFiles, + datastoreID: Gloda.datastoreID, + version: GlodaIMIndexer.cacheVersion, + }; + + // Asynchronously copy the data to the file. + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "logs", + kCacheFileName + ); + return IOUtils.writeJSON(path, data, { + tmpPath: path + ".tmp", + }).catch(aError => console.error("Failed to write cache file: " + aError)); + }, + + _knownConversations: {}, + // Promise queue for indexing jobs. The next indexing job is queued using this + // promise's then() to ensure we only load logs for one conv at a time. + _indexingJobPromise: null, + // Maps a conv id to the function that resolves the promise representing the + // ongoing indexing job on it. This is called from indexIMConversation when it + // finishes and will trigger the next queued indexing job. + _indexingJobCallbacks: new Map(), + + _scheduleIndexingJob(aConversation) { + let convId = aConversation.id; + + // If we've already scheduled this conversation to be indexed, let's + // not repeat. + if (!(convId in this._knownConversations)) { + this._knownConversations[convId] = { + id: convId, + scheduledIndex: null, + logFileCount: null, + convObj: {}, + }; + } + + if (!this._knownConversations[convId].scheduledIndex) { + // Ok, let's schedule the job. + this._knownConversations[convId].scheduledIndex = setTimeout( + this._beginIndexingJob.bind(this, aConversation), + kIndexingDelay + ); + } + }, + + _beginIndexingJob(aConversation) { + let convId = aConversation.id; + + // In the event that we're triggering this indexing job manually, without + // bothering to schedule it (for example, when a conversation is closed), + // we give the conversation an entry in _knownConversations, which would + // normally have been done in _scheduleIndexingJob. + if (!(convId in this._knownConversations)) { + this._knownConversations[convId] = { + id: convId, + scheduledIndex: null, + logFileCount: null, + convObj: {}, + }; + } + + let conv = this._knownConversations[convId]; + (async () => { + // We need to get the log files every time, because a new log file might + // have been started since we last got them. + let logFiles = await IMServices.logs.getLogPathsForConversation( + aConversation + ); + if (!logFiles || !logFiles.length) { + // No log files exist yet, nothing to do! + return; + } + + if (conv.logFileCount == undefined) { + // We initialize the _knownFiles tree path for the current files below in + // case it doesn't already exist. + let folder = PathUtils.parent(logFiles[0]); + let convName = PathUtils.filename(folder); + folder = PathUtils.parent(folder); + let accountName = PathUtils.filename(folder); + folder = PathUtils.parent(folder); + let protoName = PathUtils.filename(folder); + if ( + !Object.prototype.hasOwnProperty.call(this._knownFiles, protoName) + ) { + this._knownFiles[protoName] = {}; + } + let protoObj = this._knownFiles[protoName]; + if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) { + protoObj[accountName] = {}; + } + let accountObj = protoObj[accountName]; + if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) { + accountObj[convName] = {}; + } + + // convObj is the penultimate level of the tree, + // maps file name -> last modified time + conv.convObj = accountObj[convName]; + conv.logFileCount = 0; + } + + // The last log file in the array is the one currently being written to. + // When new log files are started, we want to finish indexing the previous + // one as well as index the new ones. The index of the previous one is + // conv.logFiles.length - 1, so we slice from there. This gives us all new + // log files even if there are multiple new ones. + let currentLogFiles = + conv.logFileCount > 1 + ? logFiles.slice(conv.logFileCount - 1) + : logFiles; + for (let logFile of currentLogFiles) { + let fileName = PathUtils.filename(logFile); + let lastModifiedTime = (await IOUtils.stat(logFile)).lastModified; + if ( + Object.prototype.hasOwnProperty.call(conv.convObj, fileName) && + conv.convObj[fileName] == lastModifiedTime + ) { + // The file hasn't changed since we last indexed it, so we're done. + continue; + } + + if (this._indexingJobPromise) { + await this._indexingJobPromise; + } + this._indexingJobPromise = new Promise(aResolve => { + this._indexingJobCallbacks.set(convId, aResolve); + }); + + let job = new IndexingJob("indexIMConversation", null); + job.conversation = conv; + job.path = logFile; + job.lastModifiedTime = lastModifiedTime; + GlodaIndexer.indexJob(job); + } + conv.logFileCount = logFiles.length; + })().catch(console.error); + + // Now clear the job, so we can index in the future. + this._knownConversations[convId].scheduledIndex = null; + }, + + observe(aSubject, aTopic, aData) { + if ( + aTopic == "new-ui-conversation" || + aTopic == "conversation-update-type" + ) { + // Add ourselves to the ui-conversation's list of observers for the + // unread-message-count-changed notification. + // For this notification, aSubject is the ui-conversation that is opened. + aSubject.addObserver(this); + return; + } + + if ( + aTopic == "ui-conversation-closed" || + aTopic == "ui-conversation-replaced" + ) { + aSubject.removeObserver(this); + return; + } + + if (aTopic == "unread-message-count-changed") { + // We get this notification by attaching observers to conversations + // directly (see the new-ui-conversation handler for when we attach). + if (aSubject.unreadIncomingMessageCount == 0) { + // The unread message count changed to 0, meaning that a conversation + // that had been in the background and receiving messages was suddenly + // moved to the foreground and displayed to the user. We schedule an + // indexing job on this conversation now, since we want to index messages + // that the user has seen. + this._scheduleIndexingJob(aSubject.target); + } + return; + } + + if (aTopic == "conversation-closed") { + let convId = aSubject.id; + // If there's a scheduled indexing job, cancel it, because we're going + // to index now. + if ( + convId in this._knownConversations && + this._knownConversations[convId].scheduledIndex != null + ) { + clearTimeout(this._knownConversations[convId].scheduledIndex); + } + + this._beginIndexingJob(aSubject); + delete this._knownConversations[convId]; + return; + } + + if (aTopic == "new-text" && !aSubject.noLog) { + // Ok, some new text is about to be put into a conversation. For this + // notification, aSubject is a prplIMessage. + let conv = aSubject.conversation; + let uiConv = IMServices.conversations.getUIConversation(conv); + + // We only want to schedule an indexing job if this message is + // immediately visible to the user. We figure this out by finding + // the unread message count on the associated UIConversation for this + // message. If the unread count is 0, we know that the message has been + // displayed to the user. + if (uiConv.unreadIncomingMessageCount == 0) { + this._scheduleIndexingJob(conv); + } + } + }, + + /* If there is an existing gloda conversation for the given path, + * find its id. + */ + _getIdFromPath(aPath) { + let selectStatement = lazy.GlodaDatastore._createAsyncStatement( + "SELECT id FROM imConversations WHERE path = ?1" + ); + selectStatement.bindByIndex(0, aPath); + let id; + return new Promise((resolve, reject) => { + selectStatement.executeAsync({ + handleResult: aResultSet => { + let row = aResultSet.getNextRow(); + if (!row) { + return; + } + if (id || aResultSet.getNextRow()) { + console.error( + "Warning: found more than one gloda conv id for " + aPath + "\n" + ); + } + id = id || row.getInt64(0); // We use the first found id. + }, + handleError: aError => + console.error("Error finding gloda id from path:\n" + aError), + handleCompletion: () => { + resolve(id); + }, + }); + }); + }, + + // Get the path of a log file relative to the logs directory - the last 4 + // components of the path. + _getRelativePath(aLogPath) { + return PathUtils.split(aLogPath).slice(-4).join("/"); + }, + + /** + * @param {object} aCache - An object mapping file names to their last + * modified times at the time they were last indexed. The value for the file + * currently being indexed is updated to the aLastModifiedTime parameter's + * value once indexing is complete. + * @param {GlodaIMConversation} [aGlodaConv] - An optional in-out param that + * lets the caller save and reuse the GlodaIMConversation instance created + * when the conversation is indexed the first time. After a conversation is + * indexed for the first time, the GlodaIMConversation instance has its id + * property set to the row id of the conversation in the database. This id + * is required to later update the conversation in the database, so the + * caller dealing with ongoing conversation has to provide the aGlodaConv + * parameter, while the caller dealing with old conversations doesn't care. + */ + async indexIMConversation( + aCallbackHandle, + aLogPath, + aLastModifiedTime, + aCache, + aGlodaConv + ) { + let log = await IMServices.logs.getLogFromFile(aLogPath); + let logConv = await log.getConversation(); + + // Ignore corrupted log files. + if (!logConv) { + return GlodaConstants.kWorkDone; + } + + let fileName = PathUtils.filename(aLogPath); + let messages = logConv + .getMessages() + // Some messages returned, e.g. sessionstart messages, + // may have the noLog flag set. Ignore these. + .filter(m => !m.noLog); + let content = []; + while (messages.length > 0) { + await new Promise(resolve => { + ChromeUtils.idleDispatch(timing => { + while (timing.timeRemaining() > 5 && messages.length > 0) { + let m = messages.shift(); + let who = m.alias || m.who; + // Messages like topic change notifications may not have a source. + let prefix = who ? who + ": " : ""; + content.push( + prefix + + lazy.MailFolder.convertMsgSnippetToPlainText( + "<!DOCTYPE html>" + m.message + ) + ); + } + resolve(); + }); + }); + } + content = content.join("\n\n"); + let glodaConv; + if (aGlodaConv && aGlodaConv.value) { + glodaConv = aGlodaConv.value; + glodaConv._content = content; + } else { + let relativePath = this._getRelativePath(aLogPath); + glodaConv = new GlodaIMConversation( + logConv.title, + log.time, + relativePath, + content + ); + // If we've indexed this file before, we need the id of the existing + // gloda conversation so that the existing entry gets updated. This can + // happen if the log sweep detects that the last messages in an open + // chat were not in fact indexed before that session was shut down. + let id = await this._getIdFromPath(relativePath); + if (id) { + glodaConv.id = id; + } + if (aGlodaConv) { + aGlodaConv.value = glodaConv; + } + } + + if (!aCache) { + throw new Error("indexIMConversation called without aCache parameter."); + } + let isNew = + !Object.prototype.hasOwnProperty.call(aCache, fileName) && !glodaConv.id; + let rv = aCallbackHandle.pushAndGo( + Gloda.grokNounItem(glodaConv, {}, true, isNew, aCallbackHandle) + ); + + if (!aLastModifiedTime) { + console.error( + "indexIMConversation called without lastModifiedTime parameter." + ); + } + aCache[fileName] = aLastModifiedTime || 1; + this._scheduleCacheSave(); + + return rv; + }, + + *_worker_indexIMConversation(aJob, aCallbackHandle) { + let glodaConv = {}; + let existingGlodaConv = aJob.conversation.glodaConv; + if ( + existingGlodaConv && + existingGlodaConv.path == this._getRelativePath(aJob.path) + ) { + glodaConv.value = aJob.conversation.glodaConv; + } + + // indexIMConversation may initiate an async grokNounItem sub-job. + this.indexIMConversation( + aCallbackHandle, + aJob.path, + aJob.lastModifiedTime, + aJob.conversation.convObj, + glodaConv + ).then(() => GlodaIndexer.callbackDriver()); + // Tell the Indexer that we're doing async indexing. We'll be left alone + // until callbackDriver() is called above. + yield GlodaConstants.kWorkAsync; + + // Resolve the promise for this job. + this._indexingJobCallbacks.get(aJob.conversation.id)(); + this._indexingJobCallbacks.delete(aJob.conversation.id); + this._indexingJobPromise = null; + aJob.conversation.indexPending = false; + aJob.conversation.glodaConv = glodaConv.value; + yield GlodaConstants.kWorkDone; + }, + + *_worker_logsFolderSweep(aJob) { + let dir = FileUtils.getFile("ProfD", ["logs"]); + if (!dir.exists() || !dir.isDirectory()) { + // If the folder does not exist, then we are done. + yield GlodaConstants.kWorkDone; + } + + // Sweep the logs directory for log files, adding any new entries to the + // _knownFiles tree as we traverse. + for (let proto of dir.directoryEntries) { + if (!proto.isDirectory()) { + continue; + } + let protoName = proto.leafName; + if (!Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)) { + this._knownFiles[protoName] = {}; + } + let protoObj = this._knownFiles[protoName]; + let accounts = proto.directoryEntries; + for (let account of accounts) { + if (!account.isDirectory()) { + continue; + } + let accountName = account.leafName; + if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) { + protoObj[accountName] = {}; + } + let accountObj = protoObj[accountName]; + for (let conv of account.directoryEntries) { + let convName = conv.leafName; + if (!conv.isDirectory() || convName == ".system") { + continue; + } + if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) { + accountObj[convName] = {}; + } + let job = new IndexingJob("convFolderSweep", null); + job.folder = conv; + job.convObj = accountObj[convName]; + GlodaIndexer.indexJob(job); + } + } + } + + yield GlodaConstants.kWorkDone; + }, + + *_worker_convFolderSweep(aJob, aCallbackHandle) { + let folder = aJob.folder; + + for (let file of folder.directoryEntries) { + let fileName = file.leafName; + if ( + !file.isFile() || + !file.isReadable() || + !fileName.endsWith(".json") || + (Object.prototype.hasOwnProperty.call(aJob.convObj, fileName) && + aJob.convObj[fileName] == file.lastModifiedTime) + ) { + continue; + } + // indexIMConversation may initiate an async grokNounItem sub-job. + this.indexIMConversation( + aCallbackHandle, + file.path, + file.lastModifiedTime, + aJob.convObj + ).then(() => GlodaIndexer.callbackDriver()); + // Tell the Indexer that we're doing async indexing. We'll be left alone + // until callbackDriver() is called above. + yield GlodaConstants.kWorkAsync; + } + yield GlodaConstants.kWorkDone; + }, + + get workers() { + return [ + ["indexIMConversation", { worker: this._worker_indexIMConversation }], + ["logsFolderSweep", { worker: this._worker_logsFolderSweep }], + ["convFolderSweep", { worker: this._worker_convFolderSweep }], + ]; + }, + + initialSweep() { + let job = new IndexingJob("logsFolderSweep", null); + GlodaIndexer.indexJob(job); + }, + + // Due to bug 1069845, some logs were indexed against their full paths instead + // of their path relative to the logs directory. These entries are updated to + // use relative paths below. + fixEntriesWithAbsolutePaths() { + let store = lazy.GlodaDatastore; + let selectStatement = store._createAsyncStatement( + "SELECT id, path FROM imConversations" + ); + let updateStatement = store._createAsyncStatement( + "UPDATE imConversations SET path = ?1 WHERE id = ?2" + ); + + store._beginTransaction(); + selectStatement.executeAsync({ + handleResult: aResultSet => { + let row; + while ((row = aResultSet.getNextRow())) { + // If the path has more than 4 components, it is not relative to + // the logs folder. Update it to use only the last 4 components. + // The absolute paths were stored as OS-specific paths, so we split + // them with PathUtils.split(). It's a safe assumption that nobody + // ported their profile folder to a different OS since the regression, + // so this should work. + let pathComponents = PathUtils.split(row.getString(1)); + if (pathComponents.length > 4) { + updateStatement.bindByIndex(1, row.getInt64(0)); // id + updateStatement.bindByIndex(0, pathComponents.slice(-4).join("/")); // Last 4 path components + updateStatement.executeAsync({ + handleResult: () => {}, + handleError: aError => + console.error("Error updating bad entry:\n" + aError), + handleCompletion: () => {}, + }); + } + } + }, + + handleError: aError => + console.error("Error looking for bad entries:\n" + aError), + + handleCompletion: () => { + store.runPostCommit(() => { + this.cacheVersion = 1; + this._scheduleCacheSave(); + }); + store._commitTransaction(); + }, + }); + }, +}; + +GlodaIndexer.registerIndexer(GlodaIMIndexer); diff --git a/comm/mail/components/im/moz.build b/comm/mail/components/im/moz.build new file mode 100644 index 0000000000..3780532058 --- /dev/null +++ b/comm/mail/components/im/moz.build @@ -0,0 +1,38 @@ +# 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/. + +EXTRA_JS_MODULES += [ + "IMIncomingServer.sys.mjs", + "IMProtocolInfo.sys.mjs", + "modules/ChatEncryption.sys.mjs", + "modules/chatHandler.sys.mjs", + "modules/chatIcons.sys.mjs", + "modules/chatNotifications.sys.mjs", + "modules/GlodaIMSearcher.sys.mjs", + "modules/index_im.sys.mjs", +] + +TESTING_JS_MODULES += [ + "test/TestProtocol.sys.mjs", +] + +JAR_MANIFESTS += ["jar.mn"] + +JS_PREFERENCE_FILES += [ + "all-im.js", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +if CONFIG["ENABLE_TESTS"]: + XPCOM_MANIFESTS += [ + "test/components.conf", + ] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.ini", +] diff --git a/comm/mail/components/im/smileys/theme.json b/comm/mail/components/im/smileys/theme.json new file mode 100644 index 0000000000..bbe0001f64 --- /dev/null +++ b/comm/mail/components/im/smileys/theme.json @@ -0,0 +1,22 @@ +{ + "smileys": [ + { "glyph": "\uD83D\uDE01", "textCodes": [":-)", ":)", "(-:", "(:"] }, + { "glyph": "\uD83D\uDE02", "textCodes": [":-D", ":D"] }, + { "glyph": "\uD83D\uDE09", "textCodes": [";-)", ";)"] }, + { "glyph": "\uD83D\uDE2D", "textCodes": [":'("] }, + { "glyph": "\uD83D\uDE2D", "textCodes": [":-o", ":-O", "o_o", "O_O"] }, + { "glyph": "\uD83D\uDE15", "textCodes": [":-S", ":S", ":-s", ":s"] }, + { "glyph": "\uD83D\uDE1F", "textCodes": [":-/", ":-\\"] }, + { "glyph": "\uD83D\uDE20", "textCodes": ["x-("] }, + { "glyph": "\uD83D\uDE41", "textCodes": [":-(", ":(", ")-:", "):"] }, + { "glyph": "\uD83D\uDE0E", "textCodes": ["B-)", "8-)"] }, + { "glyph": "\uD83D\uDE1B", "textCodes": [":-P", ":P", ":-p", ":p"] }, + { "glyph": "\uD83D\uDE05", "textCodes": [":-]", ":]", "^^'"] }, + { "glyph": "♥", "textCodes": ["<3"] }, + { "glyph": "\uD83D\uDE10", "textCodes": [":-|"] }, + { "glyph": "☺", "textCodes": ["^^"] }, + { "glyph": "\uD83D\uDE2B", "textCodes": ["-_-"] }, + { "glyph": "\uD83D\uDE24", "textCodes": ["-_-'", "--'"] }, + { "glyph": "\uD83E\uDD23", "textCodes": ["XD", "xD"] } + ] +} diff --git a/comm/mail/components/im/test/TestProtocol.sys.mjs b/comm/mail/components/im/test/TestProtocol.sys.mjs new file mode 100644 index 0000000000..7fddbf176b --- /dev/null +++ b/comm/mail/components/im/test/TestProtocol.sys.mjs @@ -0,0 +1,308 @@ +/* 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 { + GenericAccountPrototype, + GenericConvChatPrototype, + GenericConvIMPrototype, + GenericConversationPrototype, + GenericProtocolPrototype, + GenericConvChatBuddyPrototype, + GenericMessagePrototype, + TooltipInfo, +} from "resource:///modules/jsProtoHelper.sys.mjs"; + +import { nsSimpleEnumerator } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +function Message(who, text, properties, conversation) { + this._init(who, text, properties, conversation); + this.displayed = new Promise(resolve => { + this._onDisplayed = resolve; + }); + this.read = new Promise(resolve => { + this._onRead = resolve; + }); + this.actionRan = new Promise(resolve => { + this._onAction = resolve; + }); +} + +Message.prototype = { + __proto__: GenericMessagePrototype, + + whenDisplayed() { + this._onDisplayed(); + }, + + whenRead() { + this._onRead(); + }, + + getActions() { + return [ + { + QueryInterface: ChromeUtils.generateQI(["prplIMessageAction"]), + label: "Test", + run: () => { + this._onAction(); + }, + }, + ]; + }, +}; + +/** + * + * @param {string} who - Nick of the participant. + * @param {string} [alias] - Display name of the participant. + */ +function Participant(who, alias) { + this._name = who; + if (alias) { + this.alias = alias; + } +} +Participant.prototype = { + __proto__: GenericConvChatBuddyPrototype, +}; + +const SharedConversationPrototype = { + _disconnected: false, + /** + * Disconnect the conversation. + */ + _setDisconnected() { + this._disconnected = true; + }, + /** + * Close the conversation, including in the UI. + */ + close() { + this._disconnected = true; + this._account._conversations.delete(this); + GenericConversationPrototype.close.call(this); + }, + /** + * Send an outgoing message. + * + * @param {string} aMsg - Message to send. + * @returns + */ + dispatchMessage(aMsg, aAction = false, aNotice = false) { + if (this._disconnected) { + return; + } + this.writeMessage("You", aMsg, { outgoing: true, notification: aNotice }); + }, + + /** + * + * @param {Array<object>} messages - Array of messages to add to the + * conversation. Expects an object with a |who|, |content| and |options| + * properties, corresponding to the three params of |writeMessage|. + */ + addMessages(messages) { + for (const message of messages) { + this.writeMessage(message.who, message.content, message.options); + } + }, + + /** + * Add a notice to the conversation. + */ + addNotice() { + this.writeMessage("system", "test notice", { system: true }); + }, + + createMessage(who, text, options) { + const message = new Message(who, text, options, this); + return message; + }, +}; + +/** + * + * @param {prplIAccount} account + * @param {string} name - Name of the conversation. + */ +function MUC(account, name) { + this._init(account, name, "You"); +} +MUC.prototype = { + __proto__: GenericConvChatPrototype, + + /** + * + * @param {string} who - Nick of the user to add. + * @param {string} alias - Display name of the participant. + * @returns + */ + addParticipant(who, alias) { + if (this._participants.has(who)) { + return; + } + const participant = new Participant(who, alias); + this._participants.set(who, participant); + }, + ...SharedConversationPrototype, +}; + +/** + * + * @param {prplIAccount} account + * @param {string} name - Name of the conversation. + */ +function DM(account, name) { + this._init(account, name); +} +DM.prototype = { + __proto__: GenericConvIMPrototype, + ...SharedConversationPrototype, +}; + +function Account(aProtoInstance, aImAccount) { + this._init(aProtoInstance, aImAccount); + this._conversations = new Set(); +} +Account.prototype = { + __proto__: GenericAccountPrototype, + + /** + * @type {Set<GenericConversationPrototype>} + */ + _conversations: null, + + /** + * + * @param {string} name - Name of the conversation. + * @returns {MUC} + */ + makeMUC(name) { + const conversation = new MUC(this, name); + this._conversations.add(conversation); + return conversation; + }, + + /** + * + * @param {string} name - Name of the conversation. + * @returns {DM} + */ + makeDM(name) { + const conversation = new DM(this, name); + this._conversations.add(conversation); + return conversation; + }, + + connect() { + this.reportConnecting(); + // do something here + this.reportConnected(); + }, + disconnect() { + this.reportDisconnecting(Ci.prplIAccount.NO_ERROR, ""); + this.reportDisconnected(); + }, + + requestBuddyInfo(who) { + const participant = Array.from(this._conversations) + .find(conv => conv.isChat && conv._participants.has(who)) + ?._participants.get(who); + if (participant) { + const tooltipInfo = [new TooltipInfo("Display Name", participant.alias)]; + Services.obs.notifyObservers( + new nsSimpleEnumerator(tooltipInfo), + "user-info-received", + who + ); + } + }, + + get canJoinChat() { + return true; + }, + chatRoomFields: { + channel: { label: "_Channel Field", required: true }, + channelDefault: { label: "_Field with default", default: "Default Value" }, + password: { + label: "_Password Field", + default: "", + isPassword: true, + required: false, + }, + sampleIntField: { + label: "_Int Field", + default: 4, + min: 0, + max: 10, + required: true, + }, + }, + + // Nothing to do. + unInit() { + for (const conversation of this._conversations) { + conversation.close(); + } + }, + remove() {}, +}; + +export function TestProtocol() {} +TestProtocol.prototype = { + __proto__: GenericProtocolPrototype, + get id() { + return "prpl-mochitest"; + }, + get normalizedName() { + return "mochitest"; + }, + get name() { + return "Mochitest"; + }, + options: { + text: { label: "Text option", default: "foo" }, + bool: { label: "Boolean option", default: true }, + int: { label: "Integer option", default: 42 }, + list: { + label: "Select option", + default: "option2", + listValues: { + option1: "First option", + option2: "Default option", + option3: "Other option", + }, + }, + }, + usernameSplits: [ + { + label: "Server", + separator: "@", + defaultValue: "default.server", + reverse: true, + }, + ], + getAccount(aImAccount) { + return new Account(this, aImAccount); + }, + classID: Components.ID("{a4617631-b8b8-4053-8afa-5c4c43498280}"), +}; + +export function registerTestProtocol() { + Services.catMan.addCategoryEntry( + "im-protocol-plugin", + TestProtocol.prototype.id, + "@mozilla.org/chat/mochitest;1", + false, + true + ); +} + +export function unregisterTestProtocol() { + Services.catMan.deleteCategoryEntry( + "im-protocol-plugin", + TestProtocol.prototype.id, + true + ); +} diff --git a/comm/mail/components/im/test/browser/browser.ini b/comm/mail/components/im/test/browser/browser.ini new file mode 100644 index 0000000000..5592953682 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser.ini @@ -0,0 +1,26 @@ +[default] +prefs = + ldap_2.servers.osx.description= + ldap_2.servers.osx.dirType=-1 + ldap_2.servers.osx.uri= + mail.provider.suppress_dialog_on_startup=true + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank + chat.otr.enable=false +subsuite = thunderbird +head = head.js + +[browser_browserRequest.js] +[browser_chatNotifications.js] +[browser_chatTelemetry.js] +[browser_contextMenu.js] +[browser_logs.js] +[browser_messagesMail.js] +[browser_readMessage.js] +[browser_removeMessage.js] +[browser_requestNotifications.js] +[browser_spacesToolbarChat.js] +[browser_tooltips.js] +[browser_updateMessage.js] diff --git a/comm/mail/components/im/test/browser/browser_browserRequest.js b/comm/mail/components/im/test/browser/browser_browserRequest.js new file mode 100644 index 0000000000..7ffdb1c725 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_browserRequest.js @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { InteractiveBrowser, CancelledError } = ChromeUtils.importESModule( + "resource:///modules/InteractiveBrowser.sys.mjs" +); +const kBaseWindowUri = "chrome://messenger/content/browserRequest.xhtml"; + +add_task(async function testBrowserRequestObserverNotification() { + const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + undefined, + win => win.document.documentURI === kBaseWindowUri + ); + let notifyLoaded; + const loadedPromise = new Promise(resolve => { + notifyLoaded = resolve; + }); + const cancelledPromise = new Promise(resolve => { + Services.obs.notifyObservers( + { + promptText: "", + iconURI: "", + url: "about:blank", + cancelled() { + resolve(); + }, + loaded(window, webProgress) { + ok(webProgress); + notifyLoaded(window); + }, + }, + "browser-request" + ); + }); + + const requestWindow = await windowPromise; + const loadedWindow = await loadedPromise; + ok(loadedWindow); + is(loadedWindow.document.documentURI, kBaseWindowUri); + + const closeEvent = new Event("close"); + requestWindow.dispatchEvent(closeEvent); + await BrowserTestUtils.closeWindow(requestWindow); + + await cancelledPromise; +}); + +add_task(async function testWaitForRedirect() { + const initialUrl = "about:blank"; + const promptText = "just testing"; + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo"; + const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + undefined, + win => win.document.documentURI === kBaseWindowUri + ); + const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText); + const requestWindow = await windowPromise; + is(requestWindow.document.title, promptText, "set window title"); + + const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow); + const browser = requestWindow.document.getElementById("requestFrame"); + await BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, completionUrl); + const result = await request; + is(result, completionUrl, "finished with correct URL"); + + await closedWindow; +}); + +add_task(async function testCancelWaitForRedirect() { + const initialUrl = "about:blank"; + const promptText = "just testing"; + const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + undefined, + win => win.document.documentURI === kBaseWindowUri + ); + const request = InteractiveBrowser.waitForRedirect(initialUrl, promptText); + const requestWindow = await windowPromise; + is(requestWindow.document.title, promptText, "set window title"); + + await new Promise(resolve => setTimeout(resolve)); + + const closeEvent = new Event("close"); + requestWindow.dispatchEvent(closeEvent); + await BrowserTestUtils.closeWindow(requestWindow); + + try { + await request; + ok(false, "request should be rejected"); + } catch (error) { + ok(error instanceof CancelledError, "request was rejected"); + } +}); + +add_task(async function testAlreadyComplete() { + const completionUrl = InteractiveBrowser.COMPLETION_URL + "/done?info=foo"; + const promptText = "just testing"; + const windowPromise = BrowserTestUtils.domWindowOpenedAndLoaded( + undefined, + win => win.document.documentURI === kBaseWindowUri + ); + const request = InteractiveBrowser.waitForRedirect(completionUrl, promptText); + const requestWindow = await windowPromise; + is(requestWindow.document.title, promptText, "set window title"); + + const closedWindow = BrowserTestUtils.domWindowClosed(requestWindow); + const result = await request; + is(result, completionUrl, "finished with correct URL"); + + await closedWindow; +}); diff --git a/comm/mail/components/im/test/browser/browser_chatNotifications.js b/comm/mail/components/im/test/browser/browser_chatNotifications.js new file mode 100644 index 0000000000..f902a9132b --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_chatNotifications.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from ../../content/chat-messenger.js */ + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); +const { ChatIcons } = ChromeUtils.importESModule( + "resource:///modules/chatIcons.sys.mjs" +); + +let originalAlertsServiceCID; +let alertShown; +const reset = () => { + alertShown = false; +}; + +add_setup(async () => { + reset(); + class MockAlertsService { + QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]); + showAlert(alertInfo, listener) { + alertShown = true; + } + } + originalAlertsServiceCID = MockRegistrar.register( + "@mozilla.org/alerts-service;1", + new MockAlertsService() + ); +}); + +registerCleanupFunction(() => { + MockRegistrar.unregister(originalAlertsServiceCID); +}); + +add_task(async function testNotificationsDisabled() { + Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", false); + + Services.obs.notifyObservers( + { + who: "notifier", + alias: "Notifier", + time: Date.now() / 1000 - 10, + displayMessage: "<strong>lorem ipsum</strong>", + action: false, + conversation: { + isChat: true, + }, + }, + "new-directed-incoming-message" + ); + + await TestUtils.waitForTick(); + ok(!alertShown, "No alert shown when they are disabled"); + + Services.prefs.setBoolPref("mail.chat.show_desktop_notifications", true); + reset(); + + let soundPlayed = TestUtils.topicObserved("play-chat-notification-sound"); + Services.obs.notifyObservers( + { + who: "notifier", + alias: "Notifier", + time: Date.now() / 1000 - 5, + displayMessage: "", + action: false, + conversation: { + isChat: true, + }, + }, + "new-directed-incoming-message" + ); + await soundPlayed; + ok(!alertShown, "No alert shown with main window focused"); + + reset(); + + await openChatTab(); + + Services.obs.notifyObservers( + { + who: "notifier", + alias: "Notifier", + time: Date.now() / 1000, + displayMessage: "", + action: false, + conversation: { + isChat: true, + }, + }, + "new-directed-incoming-message" + ); + await TestUtils.waitForTick(); + ok(!alertShown, "No alert shown, no sound with chat tab focused"); + + await closeChatTab(); +}); diff --git a/comm/mail/components/im/test/browser/browser_chatTelemetry.js b/comm/mail/components/im/test/browser/browser_chatTelemetry.js new file mode 100644 index 0000000000..4dbad87708 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_chatTelemetry.js @@ -0,0 +1,52 @@ +/* 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/. */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_task(async function testMessageThemeTelemetry() { + Services.telemetry.clearScalars(); + + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent"); + ok( + !scalars["tb.chat.active_message_theme"], + "Active chat theme not reported without open conversation." + ); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse"); + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + const conversationLoaded = waitForConversationLoad(chatConv.convBrowser); + ok(chatConv, "found conversation"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + await BrowserTestUtils.browserLoaded(chatConv.convBrowser); + + await conversationLoaded; + scalars = TelemetryTestUtils.getProcessScalars("parent"); + // NOTE: tb.chat.active_message_theme expires at v 117. + is( + scalars["tb.chat.active_message_theme"], + "mail:default", + "Active chat message theme and variant reported after opening conversation." + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_contextMenu.js b/comm/mail/components/im/test/browser/browser_contextMenu.js new file mode 100644 index 0000000000..44afcb2a3b --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_contextMenu.js @@ -0,0 +1,243 @@ +/* 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/. */ + +add_task(async function testContextMenu() { + const account = IMServices.accounts.createAccount( + "context", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("context"); + const convNode = getConversationItem(conversation); + ok(convNode); + + const conversationLoaded = waitForConversationLoad(); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + await BrowserTestUtils.browserLoaded(chatConv.convBrowser); + + await conversationLoaded; + + const contextMenu = document.getElementById("chatConversationContextMenu"); + ok(BrowserTestUtils.is_hidden(contextMenu)); + + const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "body", + 0, + 0, + { type: "contextmenu" }, + chatConv.convBrowser, + true + ); + await popupShown; + + const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + // Assume normal context menu semantics work and just close it directly. + contextMenu.hidePopup(); + await popupHidden; + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testMessageContextMenuOnLink() { + const account = IMServices.accounts.createAccount( + "context", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + const conversation = account.prplAccount.wrappedJSObject.makeDM("linker"); + + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + await BrowserTestUtils.browserLoaded(chatConv.convBrowser); + + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + + conversation.addMessages([ + { + who: "linker", + content: "hi https://example.com/", + options: { + incoming: true, + }, + }, + { + who: "linker", + content: "hi mailto:test@example.com", + options: { + incoming: true, + }, + }, + ]); + // Wait for at least one event. + do { + await BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + } while (chatConv.convBrowser.getPendingMessagesCount() > 0); + + const contextMenu = document.getElementById("chatConversationContextMenu"); + ok(BrowserTestUtils.is_hidden(contextMenu)); + + const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + ".message:nth-child(1) a", + 0, + 0, + { type: "contextmenu", centered: true }, + chatConv.convBrowser, + true + ); + await popupShown; + + ok( + BrowserTestUtils.is_visible(contextMenu.querySelector("#context-openlink")), + "open link" + ); + ok( + BrowserTestUtils.is_visible(contextMenu.querySelector("#context-copylink")), + "copy link" + ); + + const popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + // Assume normal context menu semantics work and just close it directly. + contextMenu.hidePopup(); + await popupHidden; + + const popupShownAgain = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + BrowserTestUtils.synthesizeMouse( + ".message:nth-child(2) a", + 0, + 0, + { type: "contextmenu", centered: true }, + chatConv.convBrowser, + true + ); + await popupShownAgain; + + ok( + BrowserTestUtils.is_visible( + contextMenu.querySelector("#context-copyemail") + ), + "copy mail" + ); + + const popupHiddenAgain = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + // Assume normal context menu semantics work and just close it directly. + contextMenu.hidePopup(); + await popupHiddenAgain; + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testMessageAction() { + const account = IMServices.accounts.createAccount( + "context", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("context"); + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + await BrowserTestUtils.browserLoaded(chatConv.convBrowser); + + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + + const messagePromise = waitForNotification(conversation, "new-text"); + const displayedPromise = BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + conversation.writeMessage("context", "hello world", { + incoming: true, + }); + const { subject: message } = await messagePromise; + await displayedPromise; + + const contextMenu = document.getElementById("chatConversationContextMenu"); + ok(BrowserTestUtils.is_hidden(contextMenu)); + + const popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + ".message:nth-child(1)", + 0, + 0, + { type: "contextmenu", centered: true }, + chatConv.convBrowser, + true + ); + await popupShown; + + const separator = contextMenu.querySelector("#context-sep-messageactions"); + if (!BrowserTestUtils.is_visible(separator)) { + await BrowserTestUtils.waitForMutationCondition( + separator, + { + subtree: false, + childList: false, + attributes: true, + attributeFilter: ["hidden"], + }, + () => BrowserTestUtils.is_visible(separator) + ); + } + const item = contextMenu.querySelector( + "#context-sep-messageactions + menuitem" + ); + ok(item, "Item for message action injected"); + is(item.getAttribute("label"), "Test"); + + const popupHiddenAgain = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + item.click(); + // Assume normal context menu semantics work and just close it. + contextMenu.hidePopup(); + await Promise.all([message.actionRan, popupHiddenAgain]); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_logs.js b/comm/mail/components/im/test/browser/browser_logs.js new file mode 100644 index 0000000000..2f95a2accd --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_logs.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); + +add_task(async function testTopicRestored() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = + account.prplAccount.wrappedJSObject.makeMUC("logs topic"); + let convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + let chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + const browserDisplayed = BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + + conversation.addParticipant("topic"); + conversation.addMessages([ + { + who: "topic", + content: "hi", + options: { + incoming: true, + }, + }, + ]); + await browserDisplayed; + + // Close and re-open conversation to get logs + conversation.close(); + const newConversation = + account.prplAccount.wrappedJSObject.makeMUC("logs topic"); + convNode = getConversationItem(newConversation); + ok(convNode); + + let conversationLoaded = waitForConversationLoad(); + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + chatConv = getChatConversationElement(newConversation); + ok(chatConv, "found conversation"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + + const topicChanged = waitForNotification( + newConversation, + "chat-update-topic" + ); + newConversation.setTopic("foo bar", "topic"); + await topicChanged; + const logTree = document.getElementById("logTree"); + const chatTopInfo = document.querySelector("chat-conversation-info"); + + is(chatTopInfo.topic.value, "foo bar"); + + // Wait for log list to be populated, sadly there is no event and it is delayed by promises. + await TestUtils.waitForCondition(() => logTree.view.rowCount > 0); + + await conversationLoaded; + const logBrowser = document.getElementById("conv-log-browser"); + conversationLoaded = waitForConversationLoad(logBrowser); + mailTestUtils.treeClick(EventUtils, window, logTree, 0, 0, { + clickCount: 1, + }); + await conversationLoaded; + + ok(BrowserTestUtils.is_visible(logBrowser)); + is(chatTopInfo.topic.value, "", "Topic is cleared when viewing logs"); + + EventUtils.synthesizeMouseAtCenter( + document.getElementById("goToConversation"), + {} + ); + + ok(BrowserTestUtils.is_hidden(logBrowser)); + is(chatTopInfo.topic.value, "foo bar"); + + newConversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_messagesMail.js b/comm/mail/components/im/test/browser/browser_messagesMail.js new file mode 100644 index 0000000000..6bc73c723c --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_messagesMail.js @@ -0,0 +1,235 @@ +/* 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/. */ + +add_task(async function testCollapse() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("collapse"); + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + const messageParent = await getChatMessageParent(chatConv); + + await addNotice(conversation, chatConv); + + is( + messageParent.querySelector(".event-row:nth-child(1) .body").textContent, + "test notice", + "notice added to conv" + ); + + await addNotice(conversation, chatConv); + await addNotice(conversation, chatConv); + await addNotice(conversation, chatConv); + await Promise.all([ + await addNotice(conversation, chatConv), + BrowserTestUtils.waitForMutationCondition( + messageParent, + { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["class"], + }, + () => messageParent.querySelector(".hide-children") + ), + ]); + + const hiddenGroup = messageParent.querySelector(".hide-children"); + const toggle = hiddenGroup.querySelector(".eventToggle"); + ok(toggle); + ok(hiddenGroup.querySelectorAll(".event-row").length >= 5); + + toggle.click(); + await BrowserTestUtils.waitForMutationCondition( + hiddenGroup, + { + attributes: true, + attributeFilter: ["class"], + }, + () => !hiddenGroup.classList.contains("hide-children") + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testGrouping() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok( + BrowserTestUtils.is_visible(document.getElementById("chatPanel")), + "Chat tab is visible" + ); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("grouping"); + const convNode = getConversationItem(conversation); + ok(convNode, "Conversation is in contacts list"); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "Found conversation element"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + const messageParent = await getChatMessageParent(chatConv); + + conversation.addMessages([ + { + who: "grouping", + content: "system message", + options: { + system: true, + incoming: true, + }, + }, + { + who: "grouping", + content: "normal message", + options: { + incoming: true, + }, + }, + { + who: "grouping", + content: "another system message", + options: { + system: true, + incoming: true, + }, + }, + ]); + // Wait for at least one event. + do { + await BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + } while (chatConv.convBrowser.getPendingMessagesCount() > 0); + + for (let child of messageParent.children) { + isnot(child.id, "insert", "Message element is not the insert point"); + } + is( + messageParent.childElementCount, + 3, + "All three messages are their own top level element" + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testSystemMessageReplacement() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok( + BrowserTestUtils.is_visible(document.getElementById("chatPanel")), + "Chat tab is visible" + ); + + const conversation = account.prplAccount.wrappedJSObject.makeDM("replacing"); + const convNode = getConversationItem(conversation); + ok(convNode, "Conversation is in contacts list"); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "Found conversation element"); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + const messageParent = await getChatMessageParent(chatConv); + + conversation.addMessages([ + { + who: "replacing", + content: "system message", + options: { + system: true, + incoming: true, + remoteId: "foo", + }, + }, + { + who: "replacing", + content: "another system message", + options: { + system: true, + incoming: true, + remoteId: "bar", + }, + }, + ]); + // Wait for at least one event. + do { + await BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + } while (chatConv.convBrowser.getPendingMessagesCount() > 0); + + const updateTextPromise = waitForNotification(conversation, "update-text"); + conversation.updateMessage("replacing", "better system message", { + system: true, + incoming: true, + remoteId: "foo", + }); + await updateTextPromise; + await TestUtils.waitForTick(); + + is(messageParent.childElementCount, 1, "Only one message group in browser"); + is( + messageParent.firstElementChild.childElementCount, + 3, + "Has two messages plus insert inside group" + ); + const firstMessage = messageParent.firstElementChild.firstElementChild; + ok( + firstMessage.classList.contains("event-row"), + "Replacement message is an event-row" + ); + is(firstMessage.dataset.remoteId, "foo"); + is( + firstMessage.querySelector(".body").textContent, + "better system message", + "Message content was updated" + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +function addNotice(conversation, uiConversation) { + conversation.addNotice(); + return BrowserTestUtils.waitForEvent( + uiConversation.convBrowser, + "MessagesDisplayed" + ); +} diff --git a/comm/mail/components/im/test/browser/browser_readMessage.js b/comm/mail/components/im/test/browser/browser_readMessage.js new file mode 100644 index 0000000000..e290cb36fb --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_readMessage.js @@ -0,0 +1,49 @@ +/* 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/. */ + +add_task(async function testDisplayed() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse"); + const convNode = getConversationItem(conversation); + ok(convNode); + + ok(!convNode.hasAttribute("unread"), "No unread messages"); + + const messagePromise = waitForNotification(conversation, "new-text"); + conversation.writeMessage("mochitest", "hello world", { + incoming: true, + }); + const { subject: message } = await messagePromise; + + ok(convNode.hasAttribute("unread"), "Unread message waiting"); + is(convNode.getAttribute("unreadCount"), "(1)"); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + const browserDisplayed = BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + + await browserDisplayed; + await message.displayed; + + ok(!convNode.hasAttribute("unread"), "Message read"); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_removeMessage.js b/comm/mail/components/im/test/browser/browser_removeMessage.js new file mode 100644 index 0000000000..0d95bb77b5 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_removeMessage.js @@ -0,0 +1,54 @@ +/* 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/. */ + +add_task(async function testRemove() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse"); + const convNode = getConversationItem(conversation); + ok(convNode); + + conversation.writeMessage("mochitest", "hello world", { + incoming: true, + remoteId: "foo", + }); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + const browserDisplayed = BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + const messageParent = await getChatMessageParent(chatConv); + await browserDisplayed; + + is( + messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt") + .textContent, + "hello world", + "message added to conv" + ); + + const updateTextPromise = waitForNotification(conversation, "remove-text"); + conversation.removeMessage("foo"); + await updateTextPromise; + await TestUtils.waitForTick(); + + ok(!messageParent.querySelector(".message")); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_requestNotifications.js b/comm/mail/components/im/test/browser/browser_requestNotifications.js new file mode 100644 index 0000000000..62128add8b --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_requestNotifications.js @@ -0,0 +1,350 @@ +/* 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/. */ + +add_task(async function testGrantingBuddyRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const notificationTopic = TestUtils.topicObserved( + "buddy-authorization-request" + ); + const requestPromise = new Promise((resolve, reject) => { + prplAccount.addBuddyRequest("test-user", resolve, reject); + }); + const [request] = await notificationTopic; + is(request.userName, "test-user"); + is(request.account.id, account.id); + await TestUtils.waitForTick(); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = "buddy-auth-request-" + request.account.id + request.userName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + ok( + BrowserTestUtils.is_hidden(notification.closeButton), + "Can't dismiss without interacting" + ); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + EventUtils.synthesizeMouseAtCenter( + notification.buttonContainer.firstElementChild, + {} + ); + await requestPromise; + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testCancellingBuddyRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const notificationTopic = TestUtils.topicObserved( + "buddy-authorization-request" + ); + prplAccount.addBuddyRequest( + "test-user", + () => { + ok(false, "request was granted"); + }, + () => { + ok(false, "request was denied"); + } + ); + const [request] = await notificationTopic; + is(request.userName, "test-user"); + is(request.account.id, account.id); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = "buddy-auth-request-" + request.account.id + request.userName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + const cancelTopic = TestUtils.topicObserved( + "buddy-authorization-request-canceled" + ); + prplAccount.cancelBuddyRequest("test-user"); + const [canceledRequest] = await cancelTopic; + is(canceledRequest.userName, request.userName); + is(canceledRequest.account.id, request.account.id); + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testDenyingBuddyRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const notificationTopic = TestUtils.topicObserved( + "buddy-authorization-request" + ); + const requestPromise = new Promise((resolve, reject) => { + prplAccount.addBuddyRequest("test-user", reject, resolve); + }); + const [request] = await notificationTopic; + is(request.userName, "test-user"); + is(request.account.id, account.id); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = "buddy-auth-request-" + request.account.id + request.userName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + EventUtils.synthesizeMouseAtCenter( + notification.buttonContainer.lastElementChild, + {} + ); + await requestPromise; + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testGrantingChatRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const requestTopic = TestUtils.topicObserved("conv-authorization-request"); + const requestPromise = new Promise((resolve, reject) => { + prplAccount.addChatRequest("test-chat", resolve, reject); + }); + const [request] = await requestTopic; + is(request.conversationName, "test-chat"); + is(request.account.id, account.id); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = + "conv-auth-request-" + request.account.id + request.conversationName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + ok( + BrowserTestUtils.is_hidden(notification.closeButton), + "Can't dismiss without interacting" + ); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + EventUtils.synthesizeMouseAtCenter( + notification.buttonContainer.firstElementChild, + {} + ); + await requestPromise; + const result = await request.completePromise; + ok(result); + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testCancellingChatRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok( + BrowserTestUtils.is_visible(document.getElementById("chatPanel")), + "chat tab visible" + ); + + const requestTopic = TestUtils.topicObserved("conv-authorization-request"); + prplAccount.addChatRequest( + "test-chat", + () => { + ok(false, "chat request was granted"); + }, + () => { + ok(false, "chat request was denied"); + } + ); + const [request] = await requestTopic; + is(request.conversationName, "test-chat", "conversation name matches"); + is(request.account.id, account.id, "account id matches"); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = + "conv-auth-request-" + request.account.id + request.conversationName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + prplAccount.cancelChatRequest("test-chat"); + await Assert.rejects( + request.completePromise, + /Cancelled/, + "completePromise is rejected to indicate cancellation" + ); + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testDenyingChatRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const requestTopic = TestUtils.topicObserved("conv-authorization-request"); + const requestPromise = new Promise((resolve, reject) => { + prplAccount.addChatRequest("test-chat", reject, resolve); + }); + const [request] = await requestTopic; + is(request.conversationName, "test-chat"); + is(request.account.id, account.id); + ok(request.canDeny); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = + "conv-auth-request-" + request.account.id + request.conversationName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + + EventUtils.synthesizeMouseAtCenter( + notification.buttonContainer.lastElementChild, + {} + ); + await requestPromise; + const result = await request.completePromise; + ok(!result); + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testUndenyableChatRequest() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + const prplAccount = account.prplAccount.wrappedJSObject; + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const requestTopic = TestUtils.topicObserved("conv-authorization-request"); + const requestPromise = new Promise(resolve => { + prplAccount.addChatRequest("test-chat", resolve); + }); + const [request] = await requestTopic; + is(request.conversationName, "test-chat"); + is(request.account.id, account.id); + ok(!request.canDeny); + + const notificationBox = window.chatHandler.msgNotificationBar; + const value = + "conv-auth-request-" + request.account.id + request.conversationName; + const notification = notificationBox.getNotificationWithValue(value); + ok(notification, "notification shown"); + const closePromise = new Promise(resolve => { + notification.eventCallback = event => { + resolve(); + }; + }); + is(notification.buttonContainer.children.length, 1); + + EventUtils.synthesizeMouseAtCenter( + notification.buttonContainer.firstElementChild, + {} + ); + await requestPromise; + const result = await request.completePromise; + ok(result); + + await closePromise; + ok(!notificationBox.getNotificationWithValue(value), "notification closed"); + + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js new file mode 100644 index 0000000000..d95b5e48c0 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_spacesToolbarChat.js @@ -0,0 +1,255 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_spacesToolbarChatBadgeMUC() { + window.gSpacesToolbar.toggleToolbar(false); + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + if (window.chatHandler._chatButtonUpdatePending) { + await TestUtils.waitForTick(); + } + + const chatButton = document.getElementById("chatButton"); + + ok( + !chatButton.classList.contains("has-badge"), + "Initially no unread chat messages" + ); + + // Send a new message in a MUC that is not currently open. + const conversation = + account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge"); + const messagePromise = waitForNotification(conversation, "new-text"); + conversation.writeMessage("spaceBadge", "just a normal message", { + incoming: true, + }); + await messagePromise; + // Make sure nothing else was waiting to happen. + await TestUtils.waitForTick(); + + ok( + !chatButton.classList.contains("has-badge"), + "Untargeted MUC message doesn't change badge" + ); + + // Send a new targeted message in the conversation. + const unreadContainer = chatButton.querySelector(".spaces-badge-container"); + const unreadContainerText = unreadContainer.textContent; + const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + conversation.writeMessage("spaceBadge", "new direct message", { + incoming: true, + containsNick: true, + }); + await unreadCountChanged; + ok(chatButton.classList.contains("has-badge"), "Unread badge is shown"); + + // Fluent doesn't immediately apply the translation, wait for it. + await TestUtils.waitForCondition( + () => unreadContainer.textContent !== unreadContainerText + ); + + is(unreadContainer.textContent, "1", "Unread count is in badge"); + ok(unreadContainer.title); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function test_spacesToolbarChatBadgeDM() { + window.gSpacesToolbar.toggleToolbar(false); + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + if (window.chatHandler._chatButtonUpdatePending) { + await TestUtils.waitForTick(); + } + + const chatButton = document.getElementById("chatButton"); + + ok( + !chatButton.classList.contains("has-badge"), + "Initially no unread chat messages" + ); + + const unreadContainer = chatButton.querySelector(".spaces-badge-container"); + if (unreadContainer.textContent !== "0") { + await BrowserTestUtils.waitForMutationCondition( + unreadContainer, + { + subtree: true, + childList: true, + characterData: true, + }, + () => unreadContainer.textContent === "0" + ); + } + + // Send a new message in a DM conversation that is not currently open. + const unreadContainerText = unreadContainer.textContent; + let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge"); + conversation.writeMessage("spaceBadge", "new direct message", { + incoming: true, + }); + await unreadCountChanged; + ok(chatButton.classList.contains("has-badge"), "Unread badge is shown"); + + // Fluent doesn't immediately apply the translation, wait for it. + await TestUtils.waitForCondition( + () => unreadContainer.textContent !== unreadContainerText + ); + + is(unreadContainer.textContent, "1", "Unread count is in badge"); + ok(unreadContainer.title); + + // Display the DM conversation. + unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + await openChatTab(); + const convNode = getConversationItem(conversation); + ok(convNode); + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + const chatConv = getChatConversationElement(conversation); + ok(chatConv); + ok(BrowserTestUtils.is_visible(chatConv)); + await unreadCountChanged; + + ok( + !chatButton.classList.contains("has-badge"), + "Unread badge is hidden again" + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function test_spacesToolbarPinnedChatBadgeMUC() { + window.gSpacesToolbar.toggleToolbar(true); + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + if (window.chatHandler._chatButtonUpdatePending) { + await TestUtils.waitForTick(); + } + + const spacesPopupButtonChat = document.getElementById( + "spacesPopupButtonChat" + ); + + ok( + !spacesPopupButtonChat.classList.contains("has-badge"), + "Initially no unread chat messages" + ); + + // Send a new message in a MUC that is not currently open. + const conversation = + account.prplAccount.wrappedJSObject.makeMUC("noSpaceBadge"); + const messagePromise = waitForNotification(conversation, "new-text"); + conversation.writeMessage("spaceBadge", "just a normal message", { + incoming: true, + }); + await messagePromise; + // Make sure nothing else was waiting to happen. + await TestUtils.waitForTick(); + + ok( + !spacesPopupButtonChat.classList.contains("has-badge"), + "Untargeted MUC message doesn't change badge" + ); + + // Send a new targeted message in the conversation. + const unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + conversation.writeMessage("spaceBadge", "new direct message", { + incoming: true, + containsNick: true, + }); + await unreadCountChanged; + ok( + spacesPopupButtonChat.classList.contains("has-badge"), + "Unread badge is shown" + ); + ok( + document + .getElementById("spacesPinnedButton") + .classList.contains("has-badge"), + "Unread state is propagated to pinned menu button" + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function test_spacesToolbarPinnedChatBadgeDM() { + window.gSpacesToolbar.toggleToolbar(true); + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + if (window.chatHandler._chatButtonUpdatePending) { + await TestUtils.waitForTick(); + } + + const spacesPopupButtonChat = document.getElementById( + "spacesPopupButtonChat" + ); + const spacesPinnedButton = document.getElementById("spacesPinnedButton"); + + ok( + !spacesPopupButtonChat.classList.contains("has-badge"), + "Initially no unread chat messages" + ); + ok(!spacesPinnedButton.classList.contains("has-badge")); + + // Send a new message in a DM conversation that is not currently open. + let unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + const conversation = account.prplAccount.wrappedJSObject.makeDM("spaceBadge"); + conversation.writeMessage("spaceBadge", "new direct message", { + incoming: true, + }); + await unreadCountChanged; + ok( + spacesPopupButtonChat.classList.contains("has-badge"), + "Unread badge is shown" + ); + ok(spacesPinnedButton.classList.contains("has-badge")); + + // Display the DM conversation. + unreadCountChanged = TestUtils.topicObserved("unread-im-count-changed"); + await openChatTab(); + const convNode = getConversationItem(conversation); + ok(convNode); + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + const chatConv = getChatConversationElement(conversation); + ok(chatConv); + ok(BrowserTestUtils.is_visible(chatConv)); + await unreadCountChanged; + + ok( + !spacesPopupButtonChat.classList.contains("has-badge"), + "Unread badge is hidden again" + ); + ok(!spacesPinnedButton.classList.contains("has-badge")); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/browser_tooltips.js b/comm/mail/components/im/test/browser/browser_tooltips.js new file mode 100644 index 0000000000..db8a7fd86b --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_tooltips.js @@ -0,0 +1,194 @@ +/* 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/. */ + +add_task(async function testMUCMessageSenderTooltip() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips"); + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv); + ok(BrowserTestUtils.is_visible(chatConv)); + const messageParent = await getChatMessageParent(chatConv); + + conversation.addParticipant("foo", "1"); + conversation.addParticipant("bar", "2"); + conversation.addParticipant("loremipsum", "3"); + conversation.addMessages([ + // Message without alias + { + who: "foo", + content: "hi", + options: { + incoming: true, + }, + }, + // Message with alias + { + who: "bar", + content: "o/", + options: { + incoming: true, + _alias: "Bar", + }, + }, + // Alias is not directly related to nick + { + who: "loremipsum", + content: "what's up?", + options: { + incoming: true, + _alias: "Dolor sit amet", + }, + }, + ]); + // Wait for at least one event. + do { + await BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + } while (chatConv.convBrowser.getPendingMessagesCount() > 0); + + const tooltip = document.getElementById("imTooltip"); + const tooltipTests = [ + { + messageIndex: 1, + who: "foo", + alias: "1", + displayed: "foo", + }, + { + messageIndex: 2, + who: "bar", + alias: "2", + displayed: "Bar", + }, + { + messageIndex: 3, + who: "loremipsum", + alias: "3", + displayed: "Dolor sit amet", + }, + ]; + window.windowUtils.disableNonTestMouseEvents(true); + try { + for (const testInfo of tooltipTests) { + const usernameSelector = `.message:nth-child(${testInfo.messageIndex}) .ib-sender`; + const username = messageParent.querySelector(usernameSelector); + is(username.textContent, testInfo.displayed); + + let buddyInfo = TestUtils.topicObserved( + "user-info-received", + (subject, data) => data === testInfo.who + ); + await showTooltip(usernameSelector, tooltip, chatConv.convBrowser); + + is(tooltip.getAttribute("displayname"), testInfo.who); + await buddyInfo; + is(tooltip.table.querySelector("td").textContent, testInfo.alias); + await hideTooltip(tooltip, chatConv.convBrowser); + } + } finally { + window.windowUtils.disableNonTestMouseEvents(false); + } + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +add_task(async function testTimestampTooltip() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + const conversation = account.prplAccount.wrappedJSObject.makeMUC("tooltips"); + const convNode = getConversationItem(conversation); + ok(convNode); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv); + ok(BrowserTestUtils.is_visible(chatConv)); + + const messageTime = Math.floor(Date.now() / 1000); + + conversation.addParticipant("foo", "1"); + conversation.addMessages([ + { + who: "foo", + content: "hi", + options: { + incoming: true, + }, + time: messageTime, + }, + ]); + // Wait for at least one event. + do { + await BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + } while (chatConv.convBrowser.getPendingMessagesCount() > 0); + + const tooltip = document.getElementById("imTooltip"); + window.windowUtils.disableNonTestMouseEvents(true); + try { + const messageSelector = ".message:nth-child(1)"; + const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + timeStyle: "medium", + }); + const expectedText = dateTimeFormatter.format(new Date(messageTime * 1000)); + + await showTooltip(messageSelector, tooltip, chatConv.convBrowser); + + const htmlTooltip = tooltip.querySelector(".htmlTooltip"); + ok(BrowserTestUtils.is_visible(htmlTooltip)); + is(htmlTooltip.textContent, expectedText); + await hideTooltip(tooltip, chatConv.convBrowser); + } finally { + window.windowUtils.disableNonTestMouseEvents(false); + } + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); + +async function showTooltip(elementSelector, tooltip, browser) { + const popupShown = BrowserTestUtils.waitForEvent(tooltip, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + elementSelector, + { type: "mousemove" }, + browser + ); + return popupShown; +} + +async function hideTooltip(tooltip, browser) { + const popupHidden = BrowserTestUtils.waitForEvent(tooltip, "popuphidden"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ".message .body", + { type: "mousemove" }, + browser + ); + return popupHidden; +} diff --git a/comm/mail/components/im/test/browser/browser_updateMessage.js b/comm/mail/components/im/test/browser/browser_updateMessage.js new file mode 100644 index 0000000000..1aa74a9c64 --- /dev/null +++ b/comm/mail/components/im/test/browser/browser_updateMessage.js @@ -0,0 +1,62 @@ +/* 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/. */ + +add_task(async function testUpdate() { + const account = IMServices.accounts.createAccount( + "testuser", + "prpl-mochitest" + ); + account.password = "this is a test"; + account.connect(); + + await openChatTab(); + ok(BrowserTestUtils.is_visible(document.getElementById("chatPanel"))); + + const conversation = account.prplAccount.wrappedJSObject.makeMUC("collapse"); + const convNode = getConversationItem(conversation); + ok(convNode); + + conversation.writeMessage("mochitest", "hello world", { + incoming: true, + remoteId: "foo", + }); + + await EventUtils.synthesizeMouseAtCenter(convNode, {}); + + const chatConv = getChatConversationElement(conversation); + ok(chatConv, "found conversation"); + const browserDisplayed = BrowserTestUtils.waitForEvent( + chatConv.convBrowser, + "MessagesDisplayed" + ); + ok(BrowserTestUtils.is_visible(chatConv), "conversation visible"); + const messageParent = await getChatMessageParent(chatConv); + await browserDisplayed; + + is( + messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt") + .textContent, + "hello world", + "message added to conv" + ); + + const updateTextPromise = waitForNotification(conversation, "update-text"); + conversation.updateMessage("mochitest", "bye world", { + incoming: true, + remoteId: "foo", + }); + await updateTextPromise; + await TestUtils.waitForTick(); + + is( + messageParent.querySelector(".message.incoming:nth-child(1) .ib-msg-txt") + .textContent, + "bye world", + "message text updated" + ); + + conversation.close(); + account.disconnect(); + IMServices.accounts.deleteAccount(account.id); +}); diff --git a/comm/mail/components/im/test/browser/head.js b/comm/mail/components/im/test/browser/head.js new file mode 100644 index 0000000000..b80d274149 --- /dev/null +++ b/comm/mail/components/im/test/browser/head.js @@ -0,0 +1,132 @@ +/* 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 { registerTestProtocol, unregisterTestProtocol } = + ChromeUtils.importESModule("resource://testing-common/TestProtocol.sys.mjs"); +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); + +async function openChatTab() { + let tabmail = document.getElementById("tabmail"); + let chatMode = tabmail.tabModes.chat; + + if (chatMode.tabs.length == 1) { + tabmail.selectedTab = chatMode.tabs[0]; + } else { + window.showChatTab(); + } + + is(chatMode.tabs.length, 1, "chat tab is open"); + is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected"); + + await new Promise(resolve => setTimeout(resolve)); +} + +async function closeChatTab() { + let tabmail = document.getElementById("tabmail"); + let chatMode = tabmail.tabModes.chat; + + if (chatMode.tabs.length == 1) { + tabmail.closeTab(chatMode.tabs[0]); + } + + is(chatMode.tabs.length, 0, "chat tab is not open"); + + await new Promise(resolve => setTimeout(resolve)); +} + +/** + * @param {prplIConversation} conversation + * @returns {HTMLElement} The corresponding chat-imconv-richlistitem element. + */ +function getConversationItem(conversation) { + const convList = document.getElementById("contactlistbox"); + const convNode = Array.from(convList.children).find( + element => + element.getAttribute("is") === "chat-imconv-richlistitem" && + element.getAttribute("displayname") === conversation.name + ); + return convNode; +} + +/** + * @param {prplIConversation} conversation + * @returns {HTMLElement} The corresponding chat-conversation element. + */ +function getChatConversationElement(conversation) { + const chatConv = Array.from( + document.querySelectorAll("chat-conversation") + ).find(element => element._conv.target.wrappedJSObject === conversation); + return chatConv; +} + +/** + * @param {HTMLElement} chatConv - chat-conversation element. + * @returns {HTMLElement} The parent element to all chat messages. + */ +async function getChatMessageParent(chatConv) { + await BrowserTestUtils.browserLoaded(chatConv.convBrowser); + const messageParent = chatConv.convBrowser.contentChatNode; + return messageParent; +} + +/** + * @param {HTMLElement} [browser] - The conversation-browser element. + * @returns {Promise<void>} + */ +function waitForConversationLoad(browser) { + return TestUtils.topicObserved( + "conversation-loaded", + subject => !browser || subject === browser + ); +} + +function waitForNotification(target, expectedTopic) { + let observer; + let promise = new Promise(resolve => { + observer = { + observe(subject, topic, data) { + if (topic === expectedTopic) { + resolve({ subject, data }); + target.removeObserver(observer); + } + }, + }; + }); + target.addObserver(observer); + return promise; +} + +registerTestProtocol(); + +registerCleanupFunction(async () => { + // Make sure the chat state is clean + await closeChatTab(); + + const conversations = IMServices.conversations.getConversations(); + is(conversations.length, 0, "All conversations were closed by their test"); + for (const conversation of conversations) { + try { + conversation.close(); + } catch (error) { + ok(false, error.message); + } + } + + const accounts = IMServices.accounts.getAccounts(); + is(accounts.length, 0, "All accounts were removed by their test"); + for (const account of accounts) { + try { + if (account.connected || account.connecting) { + account.disconnect(); + } + IMServices.accounts.deleteAccount(account.id); + } catch (error) { + ok(false, "Error deleting account " + account.id + ": " + error.message); + } + } + + unregisterTestProtocol(); +}); diff --git a/comm/mail/components/im/test/components.conf b/comm/mail/components/im/test/components.conf new file mode 100644 index 0000000000..3f8c09fc09 --- /dev/null +++ b/comm/mail/components/im/test/components.conf @@ -0,0 +1,14 @@ +# -*- 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': '{a4617631-b8b8-4053-8afa-5c4c43498280}', + 'contract_ids': ['@mozilla.org/chat/mochitest;1'], + 'esModule': 'resource://testing-common/TestProtocol.sys.mjs', + 'constructor': 'TestProtocol', + }, +] |