summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/im/content
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/im/content')
-rw-r--r--comm/mail/components/im/content/.eslintrc.js22
-rw-r--r--comm/mail/components/im/content/addbuddy.js58
-rw-r--r--comm/mail/components/im/content/addbuddy.xhtml59
-rw-r--r--comm/mail/components/im/content/am-im.js291
-rw-r--r--comm/mail/components/im/content/am-im.xhtml235
-rw-r--r--comm/mail/components/im/content/chat-contact.js282
-rw-r--r--comm/mail/components/im/content/chat-conversation-info.js353
-rw-r--r--comm/mail/components/im/content/chat-conversation.js1760
-rw-r--r--comm/mail/components/im/content/chat-group.js255
-rw-r--r--comm/mail/components/im/content/chat-imconv.js366
-rw-r--r--comm/mail/components/im/content/chat-menu.inc.xhtml109
-rw-r--r--comm/mail/components/im/content/chat-messenger.inc.xhtml192
-rw-r--r--comm/mail/components/im/content/chat-messenger.js2162
-rw-r--r--comm/mail/components/im/content/imAccountWizard.js526
-rw-r--r--comm/mail/components/im/content/imAccountWizard.xhtml180
-rw-r--r--comm/mail/components/im/content/imAccounts.js663
-rw-r--r--comm/mail/components/im/content/imAccounts.xhtml250
-rw-r--r--comm/mail/components/im/content/imContextMenu.js276
-rw-r--r--comm/mail/components/im/content/imStatusSelector.js383
-rw-r--r--comm/mail/components/im/content/joinchat.js195
-rw-r--r--comm/mail/components/im/content/joinchat.xhtml58
-rw-r--r--comm/mail/components/im/content/toolbarbutton-badge-button.js70
-rw-r--r--comm/mail/components/im/content/verify.js53
-rw-r--r--comm/mail/components/im/content/verify.xhtml46
24 files changed, 8844 insertions, 0 deletions
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 &amp;&amp; gChatContextMenu) { gChatContextMenu.cleanup(); gChatContextMenu = null; }">
+ <menuitem id="context-openlink"
+ label="&openLinkCmd.label;"
+ accesskey="&openLinkCmd.accesskey;"
+ oncommand="gChatContextMenu.openLink();"/>
+ <menuitem id="context-copyemail"
+ label="&copyEmailCmd.label;"
+ accesskey="&copyEmailCmd.accesskey;"
+ oncommand="gChatContextMenu.copyEmail();"/>
+ <menuitem id="context-copylink"
+ label="&copyLinkCmd.label;"
+ accesskey="&copyLinkCmd.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>