Bug 1554633 - [de-xbl] convert the conversation binding to a custom element. r=mkmelin
authorAlessandro Castellani <alessandro@thunderbird.net>
Mon, 17 Jun 2019 01:07:21 +0200
changeset 35905 3252789dc29605fca55494a949c380dc24b95948
parent 35904 e0efa65d39017d0d89c9a6a3e7a74c2a37f0d547
child 35906 451a275be8a60ea6b73cf3a4dc7adbf7fb061d66
push id392
push userclokep@gmail.com
push dateMon, 02 Sep 2019 20:17:19 +0000
reviewersmkmelin
bugs1554633
Bug 1554633 - [de-xbl] convert the conversation binding to a custom element. r=mkmelin
mail/base/content/messenger.xul
mail/components/im/content/chat-conversation.js
mail/components/im/content/chat-imconv.js
mail/components/im/content/chat-messenger.js
mail/components/im/content/chat.css
mail/components/im/content/imconversation.xml
mail/components/im/jar.mn
--- a/mail/base/content/messenger.xul
+++ b/mail/base/content/messenger.xul
@@ -129,16 +129,17 @@
 <script src="chrome://messenger/content/jsTreeView.js"/>
 <script src="chrome://messenger/content/msgHdrView.js"/>
 <script src="chrome://global/content/nsDragAndDrop.js"/>
 <script src="chrome://messenger-smime/content/msgHdrViewSMIMEOverlay.js"/>
 <script src="chrome://messenger-smime/content/msgReadSMIMEOverlay.js"/>
 <script src="chrome://messenger/content/chat/chat-messenger.js"/>
 <script src="chrome://messenger/content/chat/imStatusSelector.js"/>
 <script src="chrome://messenger/content/chat/imContextMenu.js"/>
+<script src="chrome://messenger/content/chat/chat-conversation.js"/>
 <script src="chrome://messenger/content/preferences/preferencesTab.js"/>
 <script src="chrome://messenger/content/mailCore.js"/>
 <script src="chrome://messenger/content/mailCommands.js"/>
 <script src="chrome://messenger/content/junkCommands.js"/>
 <script src="chrome://messenger/content/mailWindowOverlay.js"/>
 <script src="chrome://messenger/content/mailTabs.js"/>
 <script src="chrome://messenger/content/messageDisplay.js"/>
 <script src="chrome://messenger/content/folderDisplay.js"/>
rename from mail/components/im/content/imconversation.xml
rename to mail/components/im/content/chat-conversation.js
--- a/mail/components/im/content/imconversation.xml
+++ b/mail/components/im/content/chat-conversation.js
@@ -1,1708 +1,1591 @@
-<?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/. -->
+/* 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 */
+
+{
+  var {Services} = ChromeUtils.import("resource:///modules/imServices.jsm");
+  var {Status} = ChromeUtils.import("resource:///modules/imStatusUtils.jsm");
+  var {MessageFormat} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
+  var {TextboxSize} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
+  var {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+  var {InlineSpellChecker} = ChromeUtils.import("resource://gre/modules/InlineSpellChecker.jsm");
 
+  /**
+   * The MozChatConversation widget displays the entire chat conversation
+   * including status notifications
+   *
+   * @extends {MozXULElement}
+   */
+  class MozChatConversation extends MozXULElement {
+    constructor() {
+      super();
+
+      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");
+            return;
+          }
 
-<!DOCTYPE bindings [
-  <!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
-  %chatDTD;
-]>
+          switch (topic) {
+            case "new-text":
+              if (this.loaded) {
+                this.addMsg(subject);
+              }
+              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.tab.setAttribute("label", this.conv.title);
+              }
+              break;
 
-<bindings id="conversationBindings"
-          xmlns="http://www.mozilla.org/xbl"
-          xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
-          xmlns:html="http://www.w3.org/1999/xhtml"
-          xmlns:xbl="http://www.mozilla.org/xbl">
+            // Update the status too.
+            case "update-buddy-status":
+            case "update-buddy-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;
+              }
+              subject.QueryInterface(Ci.nsISimpleEnumerator);
+              while (subject.hasMoreElements()) {
+                this.insertBuddy(this.createBuddy(subject.getNext()));
+              }
+              this.updateParticipantCount();
+              break;
 
-  <binding id="conversation">
-    <content>
-      <xul:vbox class="convBox" flex="1">
-        <xul:hbox class="conv-top" flex="1" anonid="conv-top">
-          <xul:notificationbox class="conv-messages" anonid="convNotificationBox" flex="1" xbl:inherits="chat">
-            <xul:vbox flex="1">
-              <xul:browser anonid="browser" is="conversation-browser" type="content" flex="1"
-                           class="chat-conversation-browser"
-                           xbl:inherits="tooltip=contenttooltip,contextmenu=contentcontextmenu,autoscrollpopup"/>
-              <html:progress anonid="browserProgress" hidden="hidden"/>
-              <xul:findbar anonid="FindToolbar" reversed="true"/>
-            </xul:vbox>
-          </xul:notificationbox>
-        </xul:hbox>
-        <xul:splitter class="splitter" anonid="splitter-bottom" orient="vertical"/>
-        <hbox anonid="convStatusContainer" class="conv-status-container" hidden="hidden">
-          <xul:description anonid="convStatus" class="plain conv-status" crop="end" />
-        </hbox>
-        <xul:stack anonid="conv-bottom" class="conv-bottom">
-          <html:textarea anonid="inputBox" class="conv-textbox" flex="1"/>
-          <xul:description anonid="charCounter" class="conv-counter" value="" right="0" bottom="0"/>
-        </xul:stack>
-      </xul:vbox>
-    </content>
-    <implementation implements="nsIObserver">
-     <constructor>
-      <![CDATA[
-       let textbox = this.editor;
-       textbox.addEventListener("keypress", this.inputKeyPress.bind(this));
-       textbox.addEventListener("input", this.inputValueChanged.bind(this));
-       textbox.addEventListener("overflow", this.inputExpand.bind(this), true);
-       textbox.addEventListener("underflow", this._onTextboxUnderflow, true);
+            case "chat-buddy-remove":
+              subject.QueryInterface(Ci.nsISimpleEnumerator);
+              if (!this._isConversationSelected) {
+                while (subject.hasMoreElements()) {
+                  let name = subject.getNext().QueryInterface(Ci.nsISupportsString).toString();
+                  if (this._isBuddyActive(name)) {
+                    delete this._activeBuddies[name];
+                  }
+                }
+                break;
+              }
+              while (subject.hasMoreElements()) {
+                let nick = subject.getNext();
+                nick.QueryInterface(Ci.nsISupportsString);
+                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;
+          }
+        },
+        QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver, Ci.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;
 
-       new MutationObserver(function(aMutations) {
-         for (let mutation of aMutations) {
-           if (mutation.oldValue == "dragging") {
-             this._onSplitterChange();
-             break;
-           }
-         }
-       }.bind(this)).observe(this.getElt("splitter-bottom"),
-                             {attributes: true, attributeOldValue: true,
-                              attributeFilter: ["state"]});
+      // 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("hbox");
+      this.convTop.setAttribute("flex", "1");
+      this.convTop.classList.add("conv-top");
+
+      this.notification = document.createXULElement("hbox");
+      this.notification.setAttribute("inherits", "chat");
+      this.notification.setAttribute("flex", "1");
+
+      let nbox = document.createXULElement("vbox");
+      nbox.setAttribute("flex", "1");
+
+      this.convBrowser = document.createXULElement("browser",
+        { is: "conversation-browser" });
+      this.convBrowser.setAttribute("flex", "1");
+      this.convBrowser.setAttribute("type", "content");
+      this.convBrowser.setAttribute("inherits",
+        "tooltip=contenttooltip,contextmenu=contentcontextmenu,autoscrollpopup");
 
-       var browser = this.browser;
-       browser.addEventListener("keypress", this.browserKeyPress.bind(this));
-       browser.addEventListener("dblclick", this.browserDblClick.bind(this));
-       Services.obs.addObserver(this, "conversation-loaded");
+      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");
+
+      nbox.appendChild(this.convBrowser);
+      nbox.appendChild(this.progressBar);
+      nbox.appendChild(this.findbar);
+      this.notification.appendChild(nbox);
+      this.convTop.appendChild(this.notification);
 
-       // @implements {nsIObserver}
-       this.prefObserver = (subject, topic, data) => {
-         if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
-           this.editor.setAttribute("spellcheck", "true");
-           this._spellchecker.enabled = true;
-         } else {
-           this.editor.removeAttribute("spellcheck");
-           this._spellchecker.enabled = false;
-         }
-       };
-       Services.prefs.addObserver("mail.spellcheck.inline", this.prefObserver);
-      ]]>
-     </constructor>
+      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);
 
-     <destructor>
-      <![CDATA[
-        this.destroy();
-      ]]>
-     </destructor>
+      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.charCounter.setAttribute("right", "0");
+      this.charCounter.setAttribute("bottom", "0");
+
+      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 is necessary because the destructor doesn't always get
-          called when we are removed from a tabbox.  This needs to be
-          explicitly called before removing the DOM node. -->
-     <method name="destroy">
-       <body>
-         <![CDATA[
-           if (this._conv)
-             this._forgetConv();
+      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");
 
-           if ("MessageFormat" in window) {
-             let textbox = this.editor;
-             const {MessageFormat} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-             MessageFormat.unregisterTextbox(textbox);
-           }
-           Services.prefs.removeObserver("mail.spellcheck.inline", this.prefObserver);
-         ]]>
-       </body>
-     </method>
+      // @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() {
+      delete this.msgNotificationBar;
+
+      let newNotificationBox = new MozElements.NotificationBox(element => {
+        element.setAttribute("flex", "1");
+        element.setAttribute("notificationside", "top");
+        this.notification.append(element);
+      });
+
+      return this.msgNotificationBar = newNotificationBox;
+    }
 
-     <method name="_forgetConv">
-       <parameter name="aShouldClose"/>
-       <body>
-        <![CDATA[
-           this._conv.removeObserver(this);
-           delete this._conv;
-           this.browser.destroy();
-           this.findbar.destroy();
-        ]]>
-       </body>
-     </method>
+    destroy() {
+      if (this._conv) {
+        this._forgetConv();
+      }
+
+      if ("MessageFormat" in window) {
+        MessageFormat.unregisterTextbox(this.inputBox);
+      }
+      Services.prefs.removeObserver("mail.spellcheck.inline", this.prefObserver);
+    }
 
-     <method name="close">
-       <body>
-        <![CDATA[
-           this._forgetConv(true);
-        ]]>
-       </body>
-     </method>
+    _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;
+    }
 
-     <field name="loaded">false</field>
+    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;
+      }
 
-     <field name="_readCount">0</field>
-     <method name="_showFirstMessages">
-      <body>
-      <![CDATA[
-        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;
-      ]]>
-      </body>
-     </method>
+      // 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);
 
-     <field name="_statusText">""</field>
-     <method name="displayStatusText">
-       <body>
-       <![CDATA[
-         let convStatusContainer = this.getElt("convStatusContainer");
-         let convStatus = this.getElt("convStatus");
-         convStatus.value = this._statusText;
-         if (this._statusText.length)
-           convStatusContainer.removeAttribute("hidden");
-         else
-           convStatusContainer.setAttribute("hidden", "true");
-       ]]>
-       </body>
-     </method>
+      // 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;
+      }
+
+      if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) {
+        this._lastPing = aMsg.who;
+        this._lastPingTime = aMsg.time;
+      }
 
-     <method name="addMsg">
-      <parameter name="aMsg"/>
-      <body>
-      <![CDATA[
-        if (!this.loaded)
-          throw new Error("Calling addMsg before the browser is ready?");
+      if (isTabFocused) {
+        // Porting note: This will mark the conv as read, but also update
+        // the conv title with the new unread count etc. as required for TB.
+        this.tab.update();
+      }
+
+      if (shouldSetUnreadFlag) {
+        if (conv.isChat && aMsg.containsNick)
+          this.tab.setAttribute("attention", "true");
+        this.tab.setAttribute("unread", "true");
+      }
+    }
 
-        var conv = aMsg.conversation;
-        if (!conv) {
-          // The conversation has already been destroyed,
-          // probably because the window was closed.
-          // Return without doing anything.
+    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;
         }
 
-        // 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.browser.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;
+        if (aMsg.match(/^\/(?:say)? .*/)) {
+          aMsg = aMsg.slice(aMsg.indexOf(" ") + 1);
+        } else if (Services.cmd.executeCommand(aMsg, this._conv.target,
+          convToFocus)) {
+          this._conv.sendTyping("");
+          this.resetInput();
+          if (convToFocus.value) {
+            chatHandler.focusConversation(convToFocus.value);
           }
           return;
         }
 
-        if (isUnreadMessage && (!aMsg.conversation.isChat || aMsg.containsNick)) {
-          this._lastPing = aMsg.who;
-          this._lastPingTime = aMsg.time;
-        }
-
-        if (isTabFocused) {
-          // Porting note: This will mark the conv as read, but also update
-          // the conv title with the new unread count etc. as required for TB.
-          this.tab.update();
-        }
-
-        if (shouldSetUnreadFlag) {
-          if (conv.isChat && aMsg.containsNick)
-            this.tab.setAttribute("attention", "true");
-          this.tab.setAttribute("unread", "true");
-        }
-      ]]>
-      </body>
-     </method>
-
-     <method name="sendMsg">
-      <parameter name="aMsg"/>
-      <body>
-      <![CDATA[
-        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;
-          } else if (aMsg.match(/^\/(?:say)? .*/)) {
-            aMsg = aMsg.slice(aMsg.indexOf(" ") + 1);
-          } else if (Services.cmd.executeCommand(aMsg, this._conv.target,
-                                                 convToFocus)) {
-            this._conv.sendTyping("");
-            this.resetInput();
-            if (convToFocus.value)
-              chatHandler.focusConversation(convToFocus.value);
-            return;
-          } else 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;
-            }
-          }
-        }
-
-        let msg = Cc["@mozilla.org/txttohtmlconv;1"]
-                    .getService(Ci.mozITXTToHTMLConv)
-                    .scanTXT(aMsg, 0);
-
-        if (account.HTMLEnabled) {
-          msg = msg.replace(/\n/g, "<br/>");
-          if (Services.prefs.getBoolPref("messenger.conversations.sendFormat")) {
-            let style = MessageFormat.getMessageStyle();
-            let proto = this._conv.account.protocol.id;
-            if (proto == "prpl-msn") {
-              if ("color" in style)
-                msg = "<font color=\"" + style.color + "\">" + msg + "</font>";
-              if ("fontFamily" in style)
-                msg = "<font face=\"" + style.fontFamily + "\">" + msg + "</font>";
-              // MSN doesn't support font size info in messages...
-            } else if (proto == "prpl-aim" || proto == "prpl-icq") {
-              let styleAttributes = "";
-              if ("color" in style)
-                styleAttributes += " color=\"" + style.color + "\"";
-              if ("fontFamily" in style)
-                styleAttributes += " face=\"" + style.fontFamily + "\"";
-              if ("fontSize" in style) {
-                let size = style.fontSize - style.defaultFontSize;
-                if (size < -4)
-                  size = 1;
-                else if (size < 0)
-                  size = 2;
-                else if (size < 3)
-                  size = 3;
-                else if (size < 7)
-                  size = 4;
-                else if (size < 15)
-                  size = 5;
-                else if (size < 25)
-                  size = 6;
-                else
-                  size = 7;
-                styleAttributes += " size=\"" + size + "\""
-                                 + " style=\"font-size: " + style.fontSize + "px;\"";
-              }
-              if (styleAttributes)
-                msg = "<font" + styleAttributes + ">" + msg + "</font>";
-            } else {
-              let styleProperties = [];
-              if ("color" in style)
-                styleProperties.push("color: " + style.color);
-              if ("fontFamily" in style)
-                styleProperties.push("font-family: " + style.fontFamily);
-              if ("fontSize" in style)
-                styleProperties.push("font-size: " + style.fontSize + "px");
-              style = styleProperties.join("; ");
-              if (style)
-                msg = "<span style=\"" + style + "\">" + msg + "</span>";
-            }
-          }
-          this._conv.sendMsg(msg);
-        } else {
-          msg = account.HTMLEscapePlainText ? msg : aMsg;
-
-          if (account.noNewlines) {
-            // 'Illegal operation on WrappedNative prototype object' if the this
-            // object is not specified (since this._conv implements nsIClassInfo)
-            msg.split("\n").forEach(this._conv.sendMsg, this._conv);
-          } else {
-            this._conv.sendMsg(msg);
-          }
-        }
-        // reset the textbox to its original size
-        this.resetInput();
-      ]]>
-      </body>
-     </method>
-
-     <method name="_onSplitterChange">
-      <body>
-      <![CDATA[
-        let textbox = this.editor;
-        // set the default height as the deck height (modified by the splitter)
-        textbox.defaultHeight = parseInt(textbox.parentNode.height) -
-          this._TEXTBOX_VERTICAL_OVERHEAD;
-      ]]>
-      </body>
-     </method>
-
-     <!--
-      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.
-     -->
-     <field name="_TEXTBOX_VERTICAL_OVERHEAD">0</field>
-     <!--
-       Ratio textbox height / conversation height.
-       0.1 means that the textbox's height is 10% of the conversation's height.
-     -->
-     <field name="_TEXTBOX_RATIO" readonly="true">0.1</field>
-
-
-     <method name="calculateTextboxDefaultHeight">
-      <body>
-      <![CDATA[
-        let totalSpace = parseInt(window.getComputedStyle(this)
-                                        .getPropertyValue("height"));
-        let textbox = this.editor;
-        let textboxStyle = window.getComputedStyle(textbox);
-        let lineHeight = parseInt(textboxStyle.getPropertyValue("line-height"));
-
-        // Compute the overhead size.
-        let textboxHeight = textbox.clientHeight;
-        let deckHeight = textbox.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;
-        textbox.defaultHeight = numberOfLines * lineHeight;
-
-        // set minimum height (in case the user moves the splitter)
-        textbox.parentNode.minHeight =
-          lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD;
-      ]]>
-      </body>
-     </method>
-
-     <method name="initTextboxFormat">
-      <body>
-      <![CDATA[
-        let textbox = this.editor;
-
-        const {MessageFormat} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-        const {InlineSpellChecker} = ChromeUtils.import("resource://gre/modules/InlineSpellChecker.jsm");
-        MessageFormat.registerTextbox(textbox);
-
-        // Init the textbox size
-        this.calculateTextboxDefaultHeight();
-        textbox.parentNode.height = textbox.defaultHeight +
-                                    this._TEXTBOX_VERTICAL_OVERHEAD;
-        textbox.style.overflowY = "hidden";
-
-        this._spellchecker = new InlineSpellChecker(textbox.editor);
-        if (Services.prefs.getBoolPref("mail.spellcheck.inline")) {
-          textbox.setAttribute("spellcheck", "true");
-          this._spellchecker.enabled = true;
-        } else {
-          textbox.removeAttribute("spellcheck");
-          this._spellchecker.enabled = false;
-        }
-      ]]>
-      </body>
-     </method>
-
-     <method name="inputKeyPress">
-      <parameter name="event"/>
-      <body>
-      <![CDATA[
-        var inputBox = this.editor;
-        let text = 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 = document.createEvent("KeyboardEvent");
-          newEvent.initKeyEvent("keypress", event.bubbles, event.cancelable, null,
-                                event.ctrlKey, event.altKey, event.shiftKey, event.metaKey,
-                                event.keyCode, event.charCode);
-          event.preventDefault();
-          event.stopPropagation();
-          // Keyboard events must be sent to the focused element for bubbling to work.
-          this.browser.focus();
-          this.browser.dispatchEvent(newEvent);
-          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) &&
-            inputBox.selectionStart == inputBox.selectionEnd) {
-          this.browser.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 = !(inputBox.selectionEnd - 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 = 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;
-                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 &&
-                    inputBox.value == this._beforeTabComplete) {
-                  // Nothing left to undo or to cycle through.
-                  delete this._completions;
-                }
-                return;
-              }
-            }
-
-            // Full undo.
-            inputBox.selectionStart = 0;
-            this.addString(this._beforeTabComplete);
-            delete this._completions;
+        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;
           }
         }
+      }
 
-        // 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)) {
+      let msg = Cc["@mozilla.org/txttohtmlconv;1"]
+        .getService(Ci.mozITXTToHTMLConv)
+        .scanTXT(aMsg, 0);
+
+      if (account.HTMLEnabled) {
+        msg = msg.replace(/\n/g, "<br/>");
+        if (Services.prefs.getBoolPref("messenger.conversations.sendFormat")) {
+          let style = MessageFormat.getMessageStyle();
+          let proto = this._conv.account.protocol.id;
+          if (proto == "prpl-msn") {
+            if ("color" in style)
+              msg = "<font color=\"" + style.color + "\">" + msg + "</font>";
+            if ("fontFamily" in style)
+              msg = "<font face=\"" + style.fontFamily + "\">" + msg + "</font>";
+            // MSN doesn't support font size info in messages...
+          } else if (proto == "prpl-aim" || proto == "prpl-icq") {
+            let styleAttributes = "";
+            if ("color" in style)
+              styleAttributes += " color=\"" + style.color + "\"";
+            if ("fontFamily" in style)
+              styleAttributes += " face=\"" + style.fontFamily + "\"";
+            if ("fontSize" in style) {
+              let size = style.fontSize - style.defaultFontSize;
+              if (size < -4)
+                size = 1;
+              else if (size < 0)
+                size = 2;
+              else if (size < 3)
+                size = 3;
+              else if (size < 7)
+                size = 4;
+              else if (size < 15)
+                size = 5;
+              else if (size < 25)
+                size = 6;
+              else
+                size = 7;
+              styleAttributes += " size=\"" + size + "\""
+                + " style=\"font-size: " + style.fontSize + "px;\"";
+            }
+            if (styleAttributes)
+              msg = "<font" + styleAttributes + ">" + msg + "</font>";
+          } else {
+            let styleProperties = [];
+            if ("color" in style)
+              styleProperties.push("color: " + style.color);
+            if ("fontFamily" in style)
+              styleProperties.push("font-family: " + style.fontFamily);
+            if ("fontSize" in style)
+              styleProperties.push("font-size: " + style.fontSize + "px");
+            style = styleProperties.join("; ");
+            if (style)
+              msg = "<span style=\"" + style + "\">" + msg + "</span>";
+          }
+        }
+        this._conv.sendMsg(msg);
+      } else {
+        msg = account.HTMLEscapePlainText ? msg : aMsg;
+
+        if (account.noNewlines) {
+          // 'Illegal operation on WrappedNative prototype object' if the this
+          // object is not specified (since this._conv implements nsIClassInfo)
+          msg.split("\n").forEach(this._conv.sendMsg, this._conv);
+        } else {
+          this._conv.sendMsg(msg);
+        }
+      }
+      // 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.height) -
+        this._TEXTBOX_VERTICAL_OVERHEAD;
+    }
+
+    calculateTextboxDefaultHeight() {
+      let totalSpace = parseInt(window.getComputedStyle(this).getPropertyValue("height"));
+      let textboxStyle = window.getComputedStyle(this.inputBox);
+      let lineHeight = parseInt(textboxStyle.getPropertyValue("line-height"));
+
+      // 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.minHeight = lineHeight + this._TEXTBOX_VERTICAL_OVERHEAD;
+    }
+
+    initTextboxFormat() {
+      MessageFormat.registerTextbox(this.inputBox);
+
+      // Init the textbox size
+      this.calculateTextboxDefaultHeight();
+      this.inputBox.parentNode.height = this.inputBox.defaultHeight +
+        this._TEXTBOX_VERTICAL_OVERHEAD;
+      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 = document.createEvent("KeyboardEvent");
+        newEvent.initKeyEvent("keypress", event.bubbles, event.cancelable, null,
+          event.ctrlKey, event.altKey, event.shiftKey, event.metaKey,
+          event.keyCode, event.charCode);
+        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();
 
-          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;
-            }
-
-            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, inputBox.selectionStart);
-          let words = this._beforeTabComplete.match(/\S*?([\w-]+)?$/);
-          let word = words[0];
-          if (!word)
-            return;
-          let isFirstWord = inputBox.selectionStart == word.length;
-
-          // Check if we are completing a command.
-          let completingCommand = isFirstWord && word[0] == "/";
-          if (completingCommand) {
-            for (let cmd of Services.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());
-
-/*
-            // Add recently parted nicks.
-            const kIncludeNickTimespan = 300000;
-            let cutoffTime = Date.now() - kIncludeNickTimespan;
-            let partedNicks = Object.keys(this.partedBuddies);
-            let j = partedNicks.length - 1;
-            while (j >= 0 &&
-                   this.partedBuddies[partedNicks[j]].partTime > cutoffTime)
-              --j;
-            ++j; // Index of the first nick we want to keep.
-            if (partedNicks.length > j)
-              completions = completions.concat(partedNicks.slice(j));
-*/
-
-            let outgoingNick = this._conv.nick;
-            completions = completions.filter(c => c != outgoingNick);
-
+          // 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 wordStart = 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));
-                }
+            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));
               }
-            }
-          }
-
-          // 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(inputBox.selectionStart - word.length);
-          for (let completion of matchingCompletions) {
-            if (wholeWord.lastIndexOf(completion, 0) == 0) {
-              let moveCursor = completion.length - word.length;
-              inputBox.selectionStart += moveCursor;
-              let separator = text.substring(inputBox.selectionStart,
-                                             inputBox.selectionStart + 2);
-              if (separator == ": " || separator == ", ") {
-                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;
+              if (this._completions.length == 1 &&
+                this.inputBox.value == this._beforeTabComplete) {
+                // Nothing left to undo or to cycle through.
+                delete this._completions;
               }
               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);
-            }
-          }
+          // Full undo.
+          this.inputBox.selectionStart = 0;
+          this.addString(this._beforeTabComplete);
+          delete this._completions;
+          return;
+        }
+      }
 
-          // 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);
+      // 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();
 
-          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.
-          inputBox.selectionStart -= word.length;
-          this._completionsStart = inputBox.selectionStart;
-
-          if (secondNick) {
-            // Replace the trailing colon with a comma before the completed nick.
-            inputBox.selectionStart -= 2;
-            completion = ", " + completion;
+        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.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");
-        }
-      ]]>
-      </body>
-     </method>
-
-     <field name="_pendingValueChangedCall">false</field>
-     <method name="inputValueChanged">
-       <body>
-       <![CDATA[
-         // 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);
-       ]]>
-       </body>
-     </method>
-
-     <method name="delayedInputValueChanged">
-       <body>
-       <![CDATA[
-         this._pendingValueChangedCall = false;
-
-         // By the time this function is executed, the conversation may have
-         // been closed.
-         if (!this._conv)
-           return;
-
-         let inputBox = this.editor;
-         let text = 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.
-         let charCounter = this.getElt("charCounter");
-         if (left == Ci.prplIConversation.NO_TYPING_LIMIT || !text.length) {
-           charCounter.setAttribute("value", "");
-           inputBox.removeAttribute("invalidInput");
-         } else {
-           // 200 is a 'magic' constant to avoid showing big numbers.
-           charCounter.setAttribute("value", (left < 200 ? left : ""));
-
-           if (left >= 0)
-             inputBox.removeAttribute("invalidInput");
-           else if (left < 0)
-             inputBox.setAttribute("invalidInput", "true");
-         }
-       ]]>
-       </body>
-     </method>
-
-     <method name="resetInput">
-      <body>
-      <![CDATA[
-        var inputBox = this.editor;
-        inputBox.value = "";
-        this.getElt("charCounter").setAttribute("value", "");
-        inputBox.removeAttribute("invalidInput");
-
-        this._statusText = "";
-        this.displayStatusText();
-
-        let overflow = "";
-        const {TextboxSize} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-        if (TextboxSize.autoResize) {
-          let currHeight = parseInt(inputBox.parentNode.height);
-          if (inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD > currHeight)
-            inputBox.defaultHeight = currHeight - this._TEXTBOX_VERTICAL_OVERHEAD;
-          this.getElt("conv-bottom").height =
-            inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD;
-          overflow = "hidden";
-        }
-
-        inputBox.style.overflowY = overflow;
-      ]]>
-      </body>
-     </method>
-
-     <method name="inputExpand">
-      <parameter name="event"/>
-      <body>
-      <![CDATA[
-        let input = this.editor;
-
-        // This feature has been disabled, or the user is currently dragging
-        // the splitter and the textbox has received an overflow event
-        const {TextboxSize} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-        if (!TextboxSize.autoResize ||
-            this.getElt("splitter-bottom").getAttribute("state") == "dragging") {
-          input.style.overflowY = "";
+          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;
         }
 
-        // 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 topBox = this.getElt("conv-top");
-        let topBoxStyle = window.getComputedStyle(topBox);
-        let topMinSize = parseInt(topBoxStyle.getPropertyValue("min-height"));
-        let topSize = parseInt(topBoxStyle.getPropertyValue("height"));
-        let deck = input.parentNode;
-        let oldDeckHeight = parseInt(deck.height);
-        let newDeckHeight =
-          parseInt(input.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD;
+        let completions = [];
+        let firstWordSuffix = " ";
+        let secondNick = false;
 
-        if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) {
-          // Hide a possible vertical scrollbar.
-          input.style.overflowY = "hidden";
-          deck.height = newDeckHeight;
-        } else {
-          input.style.overflowY = "";
-          // Set it to the maximum possible value.
-          deck.height = oldDeckHeight + (topSize - topMinSize);
+        // 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;
         }
-      ]]>
-      </body>
-     </method>
-
-     <method name="onConvResize">
-      <body>
-      <![CDATA[
-        let splitter = this.getElt("splitter-bottom");
-        let textbox = this.editor;
-
-        if (!splitter.hasAttribute("state")) {
-          this.calculateTextboxDefaultHeight();
-          textbox.parentNode.height = textbox.defaultHeight +
-                                      this._TEXTBOX_VERTICAL_OVERHEAD;
-        } else {
-          // Used in case the browser is already on its min-height, resize the
-          // textbox to avoid hiding the status bar.
-          let convTop = this.getElt("conv-top");
-          let convTopStyle = window.getComputedStyle(convTop);
-          let convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
-          let convTopMinHeight =
-            parseInt(convTopStyle.getPropertyValue("min-height"));
+        let isFirstWord = this.inputBox.selectionStart == word.length;
 
-          if (convTopHeight == convTopMinHeight) {
-            textbox.parentNode.height = parseInt(textbox.parentNode.minHeight);
-            convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
-            textbox.parentNode.height = parseInt(textbox.parentNode.minHeight) +
-                                        (convTopHeight - convTopMinHeight);
+        // Check if we are completing a command.
+        let completingCommand = isFirstWord && word[0] == "/";
+        if (completingCommand) {
+          for (let cmd of Services.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);
           }
-        }
-        const {TextboxSize} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-        if (TextboxSize.autoResize)
-          this.inputExpand();
-      ]]>
-      </body>
-     </method>
-
-     <method name="_onTextboxUnderflow">
-      <parameter name="event"/>
-      <body>
-      <![CDATA[
-        const {TextboxSize} = ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
-        if (TextboxSize.autoResize)
-          this.style.overflowY = "hidden";
-      ]]>
-      </body>
-     </method>
+        } else {
+          // If it's not a command, the only thing we can complete is a nick.
+          if (!this._conv.isChat) {
+            return;
+          }
 
-     <method name="browserKeyPress">
-     <parameter name="event"/>
-      <body>
-      <![CDATA[
-        var accelKeyPressed = AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
+          firstWordSuffix = ": ";
+          completions = Array.from(this.buddies.keys());
 
-        // 118 is the decimal code for "v" character, 13 keyCode for "return" key
-        if (((accelKeyPressed && event.charCode != 118) || event.altKey) &&
-            event.keyCode != 13)
-          return;
+          let outgoingNick = this._conv.nick;
+          completions = completions.filter(c => c != outgoingNick);
 
-        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.editor.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();
+          // 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));
+              }
+            }
           }
         }
 
-        // Returns for Ctrl+V
-        if (accelKeyPressed)
+        // 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;
-
-        // resend the event
-        let clonedEvent = new KeyboardEvent("keypress", event);
-        this.editor.dispatchEvent(clonedEvent);
-      ]]>
-      </body>
-     </method>
+        }
 
-     <method name="browserDblClick">
-     <parameter name="event"/>
-      <body>
-      <![CDATA[
-        if (!Services.prefs.getBoolPref("messenger.conversations.doubleClickToReply"))
-          return;
-
-        for (let node = event.target; node; node = node.parentNode) {
-          if (node._originalMsg) {
-            let msg = node._originalMsg;
-            let actions = msg.getActions();
-            if (actions.length >= 1) {
-              actions[0].run();
-              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;
             }
-            if (msg.system || msg.outgoing || !msg.incoming || msg.error ||
-                !this._conv.isChat)
-              return;
-            this.addPrompt(msg.who + ": ");
             return;
           }
         }
-      ]]>
-      </body>
-     </method>
+
+        // 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];
 
-     <!-- Replace the current selection in the editor by the given string -->
-     <method name="addString">
-       <parameter name="aString"/>
-       <body>
-       <![CDATA[
-         var editor = this.editor;
-         var length = (aString != "")
-                      ? aString.length
-                      : 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);
+          }
 
-         var cursorPosition = editor.selectionStart + length;
-
-         editor.value = editor.value.substr(0, editor.selectionStart) + aString +
-                        editor.value.substr(editor.selectionEnd);
-         editor.selectionStart = editor.selectionEnd = cursorPosition;
-         this.inputValueChanged();
-       ]]>
-       </body>
-     </method>
+          // 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);
+          }
+        }
 
-     <method name="addPrompt">
-       <parameter name="aPrompt"/>
-       <body>
-       <![CDATA[
-         let editor = this.editor;
-         let currentEditorValue = editor.value;
-         if (!currentEditorValue.startsWith(aPrompt))
-           editor.value = aPrompt + currentEditorValue;
-         editor.focus();
-         this.inputValueChanged();
-       ]]>
-       </body>
-     </method>
+        // 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);
+          }
+        }
 
-     <!-- Update the participant count of a chat conversation -->
-     <method name="updateParticipantCount">
-       <body>
-       <![CDATA[
-         document.getElementById("participantCount").value = this.buddies.size;
-       ]]>
-       </body>
-     </method>
+        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;
 
-     <!-- Set the attributes (flags) of a chat buddy -->
-     <method name="setBuddyAttributes">
-       <parameter name="aItem"/>
-       <body>
-       <![CDATA[
-         var buddy = aItem.chatBuddy;
-         var image;
-         if (!buddy.noFlags) {
-           if (buddy.op)
-             image = "operator";
-           else if (buddy.halfOp)
-             image = "half-operator";
-           else if (buddy.voiced)
-             image = "voice";
-           else if (buddy.founder)
-             image = "founder";
-         }
-         if (image)
-           aItem.firstChild.setAttribute("src", "chrome://messenger/skin/" + image + ".png");
-         else
-           aItem.firstChild.removeAttribute("src");
-       ]]>
-       </body>
-     </method>
+          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;
+      }
 
-     <!-- compute color for a nick -->
-     <method name="_computeColor">
-       <parameter name="aName"/>
-       <body>
-       <![CDATA[
-         // 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 Math.round(res) % 360;
-       ]]>
-       </body>
-     </method>
+      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;
+      }
 
-     <method name="_isBuddyActive">
-       <parameter name="aBuddyName"/>
-       <body>
-       <![CDATA[
-         return Object.prototype.hasOwnProperty.call(this._activeBuddies, aBuddyName);
-       ]]>
-       </body>
-     </method>
+      this._pendingValueChangedCall = true;
+      Services.tm.mainThread.dispatch(this.delayedInputValueChanged.bind(this),
+        Ci.nsIEventTarget.DISPATCH_NORMAL);
+    }
+
+    delayedInputValueChanged() {
+      this._pendingValueChangedCall = false;
 
-     <!-- Create a buddy item to add in the visible list of participants -->
-     <method name="createBuddy">
-       <parameter name="aBuddy"/>
-       <body>
-       <![CDATA[
-         var 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?!");
+      // 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));
+      }
 
-         this.trackNick(name);
-
-         let image = document.createXULElement("image");
-         let label = document.createXULElement("label");
-         label.setAttribute("value", name);
-         label.setAttribute("flex", "1");
-         label.setAttribute("crop", "end");
+      // 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 : ""));
 
-         // Fix insertBuddy below if you change the DOM makeup!
-         var item = document.createXULElement("richlistitem");
-         item.chatBuddy = aBuddy;
-         item.appendChild(image);
-         item.appendChild(label);
-         this.setBuddyAttributes(item);
+        if (left >= 0) {
+          this.inputBox.removeAttribute("invalidInput");
+        } else if (left < 0) {
+          this.inputBox.setAttribute("invalidInput", "true");
+        }
+      }
+    }
 
-         var color = this._computeColor(name);
-         var 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);
+    resetInput() {
+      this.inputBox.value = "";
+      this.charCounter.setAttribute("value", "");
+      this.inputBox.removeAttribute("invalidInput");
+
+      this._statusText = "";
+      this.displayStatusText();
 
-         return item;
-       ]]>
-       </body>
-     </method>
-
-     <!-- Insert item at the right position -->
-     <method name="insertBuddy">
-       <parameter name="aListItem"/>
-       <body>
-       <![CDATA[
-         var nicklist = document.getElementById("nicklist");
-         var nick = aListItem.querySelector("label").value.toLowerCase();
+      if (TextboxSize.autoResize) {
+        let currHeight = parseInt(this.inputBox.parentNode.height);
+        if (this.inputBox.defaultHeight + this._TEXTBOX_VERTICAL_OVERHEAD > currHeight) {
+          this.inputBox.defaultHeight = currHeight - this._TEXTBOX_VERTICAL_OVERHEAD;
+        }
+        this.convBottom.height = this.inputBox.defaultHeight +
+          this._TEXTBOX_VERTICAL_OVERHEAD;
+        this.inputBox.style.overflowY = "hidden";
+      }
+    }
 
-         // Look for the place of the nick in the list
-         var start = 0;
-         var end = nicklist.itemCount;
-         while (start < end) {
-           var middle = start + Math.floor((end - start) / 2);
-           // .firstChild.nextSibling gets us to the label. We can't use
-           // .label since the XBL binding might not be attached yet.
-           if (nick < nicklist.getItemAtIndex(middle)
-                              .firstChild.nextSibling
-                              .getAttribute("value").toLowerCase())
-             end = middle;
-           else
-             start = middle + 1;
-         }
+    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 = parseInt(deck.height);
+      let newDeckHeight =
+        parseInt(this.inputBox.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD;
 
-         // Now insert the element
-         if (end == nicklist.itemCount)
-           nicklist.appendChild(aListItem);
-         else
-           nicklist.insertBefore(aListItem, nicklist.getItemAtIndex(end));
-       ]]>
-       </body>
-     </method>
-
-     <!-- Update a buddy in the visible list of participants -->
-     <method name="updateBuddy">
-       <parameter name="aBuddy"/>
-       <parameter name="aOldName"/>
-       <body>
-       <![CDATA[
-         var name = aBuddy.name;
-         if (!name)
-           throw new Error("The empty string isn't a valid nick.");
+      if (!topMinSize || topSize - topMinSize > newDeckHeight - oldDeckHeight) {
+        // Hide a possible vertical scrollbar.
+        this.inputBox.style.overflowY = "hidden";
+        deck.height = newDeckHeight;
+      } else {
+        this.inputBox.style.overflowY = "";
+        // Set it to the maximum possible value.
+        deck.height = oldDeckHeight + (topSize - topMinSize);
+      }
+    }
 
-         if (!aOldName) {
-           // 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;
-         }
+    onConvResize() {
+      if (!this.splitter.hasAttribute("state")) {
+        this.calculateTextboxDefaultHeight();
+        this.inputBox.parentNode.height = this.inputBox.defaultHeight +
+          this._TEXTBOX_VERTICAL_OVERHEAD;
+      } 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 (this._isBuddyActive(aOldName)) {
-           delete this._activeBuddies[aOldName];
-           this._activeBuddies[aBuddy.name] = true;
-         }
-
-         this.trackNick(name);
+        if (convTopHeight == convTopMinHeight) {
+          this.inputBox.parentNode.height = parseInt(this.inputBox.parentNode.minHeight);
+          convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
+          this.inputBox.parentNode.height = parseInt(this.inputBox.parentNode.minHeight) +
+            (convTopHeight - convTopMinHeight);
+        }
+      }
+      if (TextboxSize.autoResize) {
+        this.inputExpand();
+      }
+    }
 
-         if (!this._isConversationSelected)
-           return;
+    _onTextboxUnderflow(event) {
+      if (TextboxSize.autoResize) {
+        this.style.overflowY = "hidden";
+      }
+    }
 
-         // 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);
+    browserKeyPress(event) {
+      let accelKeyPressed = AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;
 
-         if (this.buddies.has(name))
-           throw new Error("Updating a chat buddy to an already existing one: " + name);
+      // 118 is the decimal code for "v" character, 13 keyCode for "return" key
+      if (((accelKeyPressed && event.charCode != 118) || event.altKey) &&
+        event.keyCode != 13) {
+        return;
+      }
 
-         let item = this.buddies.get(aOldName);
-         item.chatBuddy = aBuddy;
-         this.buddies.delete(aOldName);
-         this.buddies.set(name, item);
-         item.querySelector("label").value = name;
+      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;
+      }
 
-         // Move this item to the right position if its name changed
-         item.remove();
-         this.insertBuddy(item);
-       ]]>
-       </body>
-     </method>
+      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();
+        }
+      }
 
-     <method name="removeBuddy">
-       <parameter name="aName"/>
-       <body>
-       <![CDATA[
-         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];
-       ]]>
-       </body>
-     </method>
+      // 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;
 
-     <field name="_nickEscape">/[[\]{}()*+?.\\^$|]/g</field>
-     <method name="trackNick">
-       <parameter name="aNick"/>
-       <body>
-       <![CDATA[
-         if ("_showNickList" in this) {
-           this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true;
-           delete this._showNickRegExp;
-         }
-       ]]>
-       </body>
-     </method>
+      for (let node = event.target; node; node = node.parentNode) {
+        if (node._originalMsg) {
+          let msg = node._originalMsg;
+          let actions = msg.getActions();
+          if (actions.length >= 1) {
+            actions[0].run();
+            return;
+          }
+          if (msg.system || msg.outgoing || !msg.incoming || msg.error ||
+              !this._conv.isChat) {
+            return;
+          }
+          this.addPrompt(msg.who + ": ");
+          return;
+        }
+      }
+    }
 
-     <method name="getShowNickModifier">
-       <body>
-       <![CDATA[
-         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;
-             }
+    /**
+     * 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;
+    }
 
-             // 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);
-       ]]>
-       </body>
-     </method>
+    /**
+     * Set the attributes (flags) of a chat buddy
+     *
+     * @param {Object} aItem
+     */
+    setBuddyAttributes(aItem) {
+      let buddy = aItem.chatBuddy;
+      let image;
+      if (!buddy.noFlags) {
+        if (buddy.op) {
+          image = "operator";
+        } else if (buddy.halfOp) {
+          image = "half-operator";
+        } else if (buddy.voiced) {
+          image = "voice";
+        } else if (buddy.founder) {
+          image = "founder";
+        }
+      }
+      if (image) {
+        aItem.firstChild.setAttribute("src", "chrome://messenger/skin/" + image + ".png");
+      } else {
+        aItem.firstChild.removeAttribute("src");
+      }
+    }
 
-     <method name="updateTopic">
-       <body>
-       <![CDATA[
-          let cti = document.getElementById("conv-top-info");
-          if (this._conv.topicSettable)
-            cti.setAttribute("topicEditable", "true");
-          else
-            cti.removeAttribute("topicEditable");
+    /**
+     * 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;
+    }
 
-          var topic = this._conv.topic;
-          if (topic) {
-            cti.setAttribute("statusTooltiptext", topic);
-            cti.removeAttribute("noTopic");
-          } else {
-            topic = this._conv.noTopicString;
-            cti.setAttribute("noTopic", "true");
-            cti.setAttribute("statusTooltiptext", topic);
-          }
-          cti.setAttribute("statusMessage", topic);
-          cti.setAttribute("statusMessageWithDash", " - " + topic);
-          cti.removeAttribute("userIcon");
-       ]]>
-       </body>
-     </method>
+    _isBuddyActive(aBuddyName) {
+      return Object.prototype.hasOwnProperty.call(this._activeBuddies, aBuddyName);
+    }
 
-     <method name="focus">
-       <body>
-       <![CDATA[
-         this.editor.focus();
+    /**
+     * 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?!");
+      }
 
-         if (!this.loaded)
-           return;
+      this.trackNick(name);
+
+      let image = document.createXULElement("image");
+      let label = document.createXULElement("label");
+      label.setAttribute("value", name);
+      label.setAttribute("flex", "1");
+      label.setAttribute("crop", "end");
 
-         if (this.tab) {
-           this.tab.removeAttribute("unread");
-           this.tab.removeAttribute("attention");
-         }
-         this._conv.markAsRead();
-       ]]>
-       </body>
-     </method>
+      // 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);
 
-     <field name="_currentTypingName">""</field>
-     <method name="updateTyping">
-       <body>
-       <![CDATA[
-          let typingState = this._conv.typingState;
-          let cti = document.getElementById("conv-top-info");
-          cti.removeAttribute("typing");
+      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;
+    }
 
-          let name = this._currentTypingName;
-          if (!this._currentTypingName)
-            name = this._conv.title; // .replace(/^([a-zA-Z0-9.]+)[@\s].*/, "$1");
-          if (typingState == Ci.prplIConvIM.TYPING) {
-            cti.setAttribute("typing", "active");
-            let typingMsg = this.bundle.formatStringFromName("chat.contactIsTyping",
-                                                             [name], 1);
-            cti.setAttribute("statusTypeTooltiptext", typingMsg);
-            cti.setAttribute("statusTooltiptext", typingMsg);
-            cti.setAttribute("statusMessage",
-                             this.bundle.GetStringFromName("chat.isTyping"));
-          } else if (typingState == Ci.prplIConvIM.TYPED) {
-            cti.setAttribute("typing", "paused");
-            let typedMsg = this.bundle.formatStringFromName("chat.contactHasStoppedTyping",
-                                                            [name], 1);
-            cti.setAttribute("statusTypeTooltiptext", typedMsg);
-            cti.setAttribute("statusTooltiptext", typedMsg);
-            cti.setAttribute("statusMessage",
-                             this.bundle.GetStringFromName("chat.hasStoppedTyping"));
-          }
-       ]]>
-       </body>
-     </method>
+    /**
+     * 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).firstChild.nextSibling
+                          .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));
+      }
+    }
 
-    <method name="switchingToPanel">
-      <body>
-      <![CDATA[
-        if (this._visibleTimer)
-          return;
+    /**
+     * 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.");
+      }
 
-        // 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(function() {
-          this._wasVisible = true;
-          delete this._visibleTimer;
+      if (!aOldName) {
+        // 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;
+      }
 
-          // 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();
-        }.bind(this), 1000);
-        this.browser.isActive = true;
-      ]]>
-      </body>
-    </method>
+      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);
+      }
 
-    <method name="switchingAwayFromPanel">
-      <parameter name="aHidden"/>
-      <body>
-        <![CDATA[
-          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.browser.removeUnreadRuler();
+      if (this.buddies.has(name)) {
+        throw new Error("Updating a chat buddy to an already existing one: " + name);
+      }
 
-          if (aHidden)
-            this.browser.isActive = false;
-        ]]>
-      </body>
-    </method>
+      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);
+    }
 
-     <method name="getElt">
-       <parameter name="aAnonId"/>
-       <body>
-       <![CDATA[
-         return document.getAnonymousElementByAttribute(this, "anonid", aAnonId);
-       ]]>
-       </body>
-     </method>
-
-     <method name="updateConvStatus">
-       <body>
-       <![CDATA[
-          let cti = document.getElementById("conv-top-info");
-          cti.setAttribute("prplIcon",
-                           this._conv.account.protocol.iconBaseURI + "icon.png");
+    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];
+      }
+    }
 
-          if (this._conv.isChat) {
-            this.updateTopic();
-            cti.setAttribute("status", "chat");
-            cti.setAttribute("displayName", this._conv.title);
-          } else {
-            let displayName = this._conv.title;
-            let statusText = "";
-            let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN;
+    trackNick(aNick) {
+      if ("_showNickList" in this) {
+        this._showNickList[aNick.replace(this._nickEscape, "\\$&")] = true;
+        delete this._showNickRegExp;
+      }
+    }
 
-            let buddy = this._conv.buddy;
-            if (!buddy || !buddy.account.connected) {
-              cti.removeAttribute("userIcon");
-            } else {
-              displayName = buddy.displayName;
-              statusText = buddy.statusText;
-              statusType = buddy.statusType;
-              cti.setAttribute("userIcon", buddy.buddyIconFilename);
+    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;
             }
+          }
 
-            cti.setAttribute("displayName", displayName);
-            if (statusText)
-              statusText = " - " + statusText;
-            cti.setAttribute("statusMessageWithDash", statusText);
-            let statusString = Status.toLabel(statusType);
-            cti.setAttribute("statusMessage", statusString + statusText);
-            cti.setAttribute("status", Status.toAttribute(statusType));
-            cti.setAttribute("statusTypeTooltiptext", statusString);
-            cti.setAttribute("statusTooltiptext", statusString + statusText);
-            cti.removeAttribute("topicEditable");
-            cti.removeAttribute("noTopic");
-            this.updateTyping();
+          // 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;
           }
-       ]]>
-       </body>
-     </method>
+        }
+        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);
+    }
 
-     <method name="showParticipants">
-       <body>
-       <![CDATA[
-         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();
-         }
-       ]]>
-       </body>
-     </method>
+    updateTopic() {
+      let cti = document.getElementById("conv-top-info");
+      if (this._conv.topicSettable) {
+        cti.setAttribute("topicEditable", "true");
+      } else {
+        cti.removeAttribute("topicEditable");
+      }
 
-     <method name="initConversationUI">
-       <body>
-       <![CDATA[
-         if (this._conv.isChat) {
-           this.updateTopic();
-           this.setAttribute("chat", "true");
-           let cti = document.getElementById("conv-top-info");
-           cti.setAttribute("displayName", this._conv.title);
-           cti.setAttribute("status", "chat");
+      let topic = this._conv.topic;
+      if (topic) {
+        cti.setAttribute("statusTooltiptext", topic);
+        cti.removeAttribute("noTopic");
+      } else {
+        topic = this._conv.noTopicString;
+        cti.setAttribute("noTopic", "true");
+        cti.setAttribute("statusTooltiptext", topic);
+      }
+      cti.setAttribute("statusMessage", topic);
+      cti.setAttribute("statusMessageWithDash", " - " + topic);
+      cti.removeAttribute("userIcon");
+    }
 
-           this._activeBuddies = {};
-           this.showParticipants();
+    focus() {
+      this.inputBox.focus();
 
-           if (Services.prefs.getBoolPref("messenger.conversations.showNicks"))
-             this.browser.addTextModifier(this.getShowNickModifier());
-         }
-
-         if (this.tab)
-           this.tab.setAttribute("label", this._conv.title);
-
-         this.findbar.browser = this.browser;
+      if (!this.loaded) {
+        return;
+      }
 
-         this.updateConvStatus();
-         this.initTextboxFormat();
-       ]]>
-       </body>
-     </method>
+      if (this.tab) {
+        this.tab.removeAttribute("unread");
+        this.tab.removeAttribute("attention");
+      }
+      this._conv.markAsRead();
+    }
 
-     <!-- nsIObserver implementation -->
-     <method name="observe">
-       <parameter name="aSubject"/>
-       <parameter name="aTopic"/>
-       <parameter name="aData"/>
-       <body>
-       <![CDATA[
-         if (aTopic == "conversation-loaded") {
-           if (aSubject != this.browser)
-             return;
-
-           this.browser.progressBar = this.getElt("browserProgress");
+    updateTyping() {
+      let typingState = this._conv.typingState;
+      let cti = document.getElementById("conv-top-info");
+      cti.removeAttribute("typing");
 
-           // 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, "conversation-loaded");
-           return;
-         }
+      let name = this._currentTypingName;
+      if (!this._currentTypingName) {
+        name = this._conv.title; // .replace(/^([a-zA-Z0-9.]+)[@\s].*/, "$1");
+      }
+      if (typingState == Ci.prplIConvIM.TYPING) {
+        cti.setAttribute("typing", "active");
+        let typingMsg = this.bundle.formatStringFromName("chat.contactIsTyping",
+          [name], 1);
+        cti.setAttribute("statusTypeTooltiptext", typingMsg);
+        cti.setAttribute("statusTooltiptext", typingMsg);
+        cti.setAttribute("statusMessage",
+          this.bundle.GetStringFromName("chat.isTyping"));
+      } else if (typingState == Ci.prplIConvIM.TYPED) {
+        cti.setAttribute("typing", "paused");
+        let typedMsg = this.bundle.formatStringFromName("chat.contactHasStoppedTyping",
+          [name], 1);
+        cti.setAttribute("statusTypeTooltiptext", typedMsg);
+        cti.setAttribute("statusTooltiptext", typedMsg);
+        cti.setAttribute("statusMessage",
+          this.bundle.GetStringFromName("chat.hasStoppedTyping"));
+      }
+    }
 
-         switch (aTopic) {
-         case "new-text":
-           if (this.loaded)
-             this.addMsg(aSubject);
-           break;
-
-         case "status-text-changed":
-           this._statusText = aData || "";
-           this.displayStatusText();
-           break;
-
-         case "replying-to-prompt":
-           this.addPrompt(aData);
-           break;
+    switchingToPanel() {
+      if (this._visibleTimer) {
+        return;
+      }
 
-         case "target-prpl-conversation-changed":
-         case "update-conv-title":
-           if (this.tab)
-               this.tab.setAttribute("label", this.conv.title);
-           // Update the status too.
-         case "update-buddy-status":
-         case "update-buddy-icon":
-         case "update-conv-chatleft":
-           if (this.tab && this._isConversationSelected)
-             this.updateConvStatus();
-           break;
+      // 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;
 
-         case "update-typing":
-           if (this.tab && this._isConversationSelected) {
-             this._currentTypingName = aData;
-             this.updateConvStatus();
-           }
-           break;
+        // 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();
+      }
 
-         case "chat-buddy-add":
-           if (!this._isConversationSelected)
-             break;
-           aSubject.QueryInterface(Ci.nsISimpleEnumerator);
-           while (aSubject.hasMoreElements())
-             this.insertBuddy(this.createBuddy(aSubject.getNext()));
-           this.updateParticipantCount();
-           break;
+      if (aHidden) {
+        this.convBrowser.isActive = false;
+      }
+    }
+
+    updateConvStatus() {
+      let cti = document.getElementById("conv-top-info");
+      cti.setAttribute("prplIcon",
+        this._conv.account.protocol.iconBaseURI + "icon.png");
 
-         case "chat-buddy-remove":
-           aSubject.QueryInterface(Ci.nsISimpleEnumerator);
-           if (!this._isConversationSelected) {
-             while (aSubject.hasMoreElements()) {
-               let name = aSubject.getNext().QueryInterface(Ci.nsISupportsString).toString();
-               if (this._isBuddyActive(name))
-                 delete this._activeBuddies[name];
-             }
-             break;
-           }
-           while (aSubject.hasMoreElements()) {
-             let nick = aSubject.getNext();
-             nick.QueryInterface(Ci.nsISupportsString);
-             this.removeBuddy(nick.toString());
-           }
-           this.updateParticipantCount();
-           break;
+      if (this._conv.isChat) {
+        this.updateTopic();
+        cti.setAttribute("status", "chat");
+        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 || !buddy.account.connected) {
+          cti.removeAttribute("userIcon");
+        } else {
+          displayName = buddy.displayName;
+          statusText = buddy.statusText;
+          statusType = buddy.statusType;
+          cti.setAttribute("userIcon", buddy.buddyIconFilename);
+        }
 
-         case "chat-buddy-update":
-           this.updateBuddy(aSubject, aData);
-           break;
-
-         case "chat-update-topic":
-           if (this._isConversationSelected)
-             this.updateTopic();
-           break;
-         }
-       ]]>
-       </body>
-     </method>
+        cti.setAttribute("displayName", displayName);
+        if (statusText) {
+          statusText = " - " + statusText;
+        }
+        cti.setAttribute("statusMessageWithDash", statusText);
+        let statusString = Status.toLabel(statusType);
+        cti.setAttribute("statusMessage", statusString + statusText);
+        cti.setAttribute("status", Status.toAttribute(statusType));
+        cti.setAttribute("statusTypeTooltiptext", statusString);
+        cti.setAttribute("statusTooltiptext", statusString + statusText);
+        cti.removeAttribute("topicEditable");
+        cti.removeAttribute("noTopic");
+        this.updateTyping();
+      }
+    }
 
-     <property name="_isConversationSelected">
-       <getter>
-       <![CDATA[
-         // TB-only: returns true if the conversation binding 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"));
-       ]]>
-       </getter>
-     </property>
+    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();
+      }
+    }
 
-     <property name="convId">
-       <getter>
-         <![CDATA[
-           return this._conv.id;
-         ]]>
-       </getter>
-     </property>
+    initConversationUI() {
+      if (this._conv.isChat) {
+        this.updateTopic();
+        this.setAttribute("chat", "true");
+        let cti = document.getElementById("conv-top-info");
+        cti.setAttribute("displayName", this._conv.title);
+        cti.setAttribute("status", "chat");
+
+        this._activeBuddies = {};
+        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;
 
-     <property name="conv">
-       <getter>
-         <![CDATA[
-           return this._conv;
-         ]]>
-       </getter>
-       <setter>
-         <![CDATA[
-           if (this._conv && val)
-             throw new Error("imconversation 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 val;
-           }
-           this._conv = val;
-           this._conv.addObserver(this);
-           this.browser.init(this._conv);
-           this.initConversationUI();
-           return val;
-         ]]>
-       </setter>
-     </property>
+      this.updateConvStatus();
+      this.initTextboxFormat();
+    }
+
+    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;
+    }
 
-     <field name="_editor">null</field>
-     <property name="editor">
-       <getter>
-         <![CDATA[
-          if (!this._editor)
-            this._editor = this.getElt("inputBox");
-          return this._editor;
-         ]]>
-       </getter>
-     </property>
+    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 val;
+      }
+      this._conv = val;
+      this._conv.addObserver(this.observer);
+      this.convBrowser.init(this._conv);
+      this.initConversationUI();
+      return val;
+    }
 
-     <property name="spellchecker" onget="return this._spellchecker;"/>
-     <property name="browser" onget="return this.getElt('browser');"/>
-     <property name="contentWindow" onget="return this.browser.contentWindow;"/>
-     <property name="findbar" onget="return this.getElt('FindToolbar');"/>
+    get contentWindow() {
+      return this.convBrowser.contentWindow;
+    }
 
-     <property name="bundle">
-       <getter>
-         <![CDATA[
-          if (!this._bundle) {
-            this._bundle =
-              Services.strings.createBundle("chrome://messenger/locale/chat.properties");
-          }
-          return this._bundle;
-         ]]>
-       </getter>
-     </property>
-    </implementation>
-  </binding>
-</bindings>
+    get bundle() {
+      if (!this._bundle) {
+        this._bundle =
+          Services.strings.createBundle("chrome://messenger/locale/chat.properties");
+      }
+      return this._bundle;
+    }
+  }
+
+  customElements.define("chat-conversation", MozChatConversation);
+}
--- a/mail/components/im/content/chat-imconv.js
+++ b/mail/components/im/content/chat-imconv.js
@@ -251,17 +251,17 @@
       // 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.inputField.dispatchEvent(clonedEvent);
+        this.convView.editor.dispatchEvent(clonedEvent);
         event.preventDefault();
       }
     }
     disconnectedCallback() {
       if (this.conv) {
         this.conv.removeObserver(this.observer);
         delete this.conv;
       }
--- a/mail/components/im/content/chat-messenger.js
+++ b/mail/components/im/content/chat-messenger.js
@@ -1,13 +1,13 @@
 /* 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 */
+/* global MozElements MozXULElement */
 /* import-globals-from ../../../../../toolkit/content/globalOverlay.js */
 
 // This file is loaded in messenger.xul.
 /* globals fixIterator, MailToolboxCustomizeDone, Notifications, openIMAccountMgr,
    PROTO_TREE_VIEW, Services, Status, statusSelector, ZoomManager */
 
 var {Notifications} = ChromeUtils.import("resource:///modules/chatNotifications.jsm");
 var { Services: imServices } = ChromeUtils.import("resource:///modules/imServices.jsm");
@@ -316,28 +316,28 @@ var chatTabType = {
   },
   onEvent(aEvent, aTab) {},
   getBrowser(aTab) {
     let panel = document.getElementById("conversationsDeck").selectedPanel;
     if (panel == document.getElementById("logDisplay")) {
       if (document.getElementById("logDisplayDeck").selectedPanel ==
           document.getElementById("logDisplayBrowserBox"))
         return document.getElementById("conv-log-browser");
-    } else if (panel && panel.localName == "imconversation") {
+    } else if (panel && panel.localName == "chat-conversation") {
       return panel.browser;
     }
     return null;
   },
   getFindbar(aTab) {
     let panel = document.getElementById("conversationsDeck").selectedPanel;
     if (panel == document.getElementById("logDisplay")) {
       if (document.getElementById("logDisplayDeck").selectedPanel ==
           document.getElementById("logDisplayBrowserBox"))
         return document.getElementById("log-findbar");
-    } else if (panel && panel.localName == "imconversation") {
+    } else if (panel && panel.localName == "chat-conversation") {
       return panel.findbar;
     }
     return null;
   },
 
   saveTabState(aTab) {},
 };
 
@@ -448,17 +448,17 @@ var chatHandler = {
     }
     gChatTab.title = title;
     document.getElementById("tabmail").setTabTitle(gChatTab);
   },
 
   onConvResize() {
     let convDeck = document.getElementById("conversationsDeck");
     let panel = convDeck.selectedPanel;
-    if (panel && panel.localName == "imconversation")
+    if (panel && panel.localName == "chat-conversation")
       panel.onConvResize();
   },
 
   setStatusMenupopupCommand(aEvent) {
     let target = aEvent.originalTarget;
     if (target.getAttribute("id") == "imStatusShowAccounts" ||
         target.getAttribute("id") == "appmenu_imStatusShowAccounts") {
       openIMAccountMgr();
@@ -728,17 +728,17 @@ var chatHandler = {
           this._pendingSearchTerm = item.searchTerm || undefined;
           this._showLogList(aSimilarLogs, aLog);
         });
       });
     } else if (item.localName == "richlistitem" && item.getAttribute("is") == "chat-imconv") {
       let convDeck = document.getElementById("conversationsDeck");
       if (!item.convView) {
         // Create new conversation binding.
-        let conv = document.createXULElement("imconversation");
+        let conv = document.createXULElement("chat-conversation");
         convDeck.appendChild(conv);
         conv.conv = item.conv;
         conv.tab = item;
         conv.setAttribute("contentcontextmenu", "chatConversationContextMenu");
         conv.setAttribute("contenttooltip", "imTooltip");
         item.convView = conv;
         document.getElementById("contextSplitter").hidden = false;
         document.getElementById("contextPane").hidden = false;
@@ -871,17 +871,17 @@ var chatHandler = {
   addBuddy() {
      this._openDialog("addbuddy");
   },
   joinChat() {
     this._openDialog("joinchat");
   },
 
   _colorCache: {},
-  // Duplicated code from imconversation.xml :-(
+  // 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
--- a/mail/components/im/content/chat.css
+++ b/mail/components/im/content/chat.css
@@ -1,18 +1,14 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 @namespace html url(http://www.w3.org/1999/xhtml);
 
-imconversation {
-  -moz-binding: url("chrome://messenger/content/chat/imconversation.xml#conversation");
-}
-
 richlistitem[is="chat-group"] {
   -moz-box-align: center;
 }
 
 richlistitem[is="chat-contact"] {
   -moz-box-align: center;
 }
 
--- a/mail/components/im/jar.mn
+++ b/mail/components/im/jar.mn
@@ -12,19 +12,19 @@ messenger.jar:
     content/messenger/chat/joinchat.js                   (content/joinchat.js)
     content/messenger/chat/joinchat.xul                  (content/joinchat.xul)
     content/messenger/chat/imAccounts.css                (content/imAccounts.css)
     content/messenger/chat/imAccounts.js                 (content/imAccounts.js)
     content/messenger/chat/imAccounts.xul                (content/imAccounts.xul)
     content/messenger/chat/imAccountWizard.xul           (content/imAccountWizard.xul)
     content/messenger/chat/imAccountWizard.js            (content/imAccountWizard.js)
     content/messenger/chat/imContextMenu.js              (content/imContextMenu.js)
+    content/messenger/chat/chat-conversation.js          (content/chat-conversation.js)
     content/messenger/chat/imStatusSelector.js           (content/imStatusSelector.js)
     content/messenger/chat/chat-contact.js               (content/chat-contact.js)
-    content/messenger/chat/imconversation.xml            (content/imconversation.xml)
     content/messenger/chat/chat-group.js                 (content/chat-group.js)
     content/messenger/chat/chat-imconv.js                (content/chat-imconv.js)
     content/messenger/chat/chat-conversation-info.js     (content/chat-conversation-info.js)
     content/messenger/chat/toolbarbutton-badge-button.js (content/toolbarbutton-badge-button.js)
 % skin messenger-messagestyles classic/1.0 %skin/classic/messenger/messages/
 	skin/classic/messenger/messages/mail/Bitmaps/minus-hover.png          (messages/mail/Bitmaps/minus-hover.png)
 	skin/classic/messenger/messages/mail/Bitmaps/minus.png                (messages/mail/Bitmaps/minus.png)
 	skin/classic/messenger/messages/mail/Bitmaps/plus-hover.png           (messages/mail/Bitmaps/plus-hover.png)