mail/components/im/content/imconversation.xml
author Florian Quèze <florian@queze.net>
Thu, 17 Jan 2019 23:52:22 +0100
changeset 34240 4f66ed336bcf89fd9e5dfc0a7d23ab2c268a27e8
parent 34136 3df2a9ba3c3933f9e48286fe70858a1708a70d3f
child 34255 bf2126d1ba5c199f4f3c92baac8bd519bd828905
permissions -rw-r--r--
Bug 1520908 - prevent opening the find bar when typing a slash while a chat conversation is focused. r=nhnt11

<?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/. -->


<!DOCTYPE bindings [
  <!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd">
  %chatDTD;
]>

<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">

  <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" type="content" browser-type="conversation" flex="1"
                           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">
          <xul:textbox anonid="inputBox" class="conv-textbox" multiline="true" 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);

       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"]});

       var browser = this.browser;
       browser.addEventListener("keypress", this.browserKeyPress.bind(this));
       browser.addEventListener("dblclick", this.browserDblClick.bind(this));
       Services.obs.addObserver(this, "conversation-loaded");
      ]]>
     </constructor>

     <destructor>
      <![CDATA[
        this.destroy();
      ]]>
     </destructor>

     <!-- 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();

           if ("MessageFormat" in window) {
             let textbox = this.editor;
             MessageFormat.unregisterTextbox(textbox);
             TextboxSpellChecker.unregisterTextbox(textbox);
           }
         ]]>
       </body>
     </method>

     <method name="_forgetConv">
       <parameter name="aShouldClose"/>
       <body>
        <![CDATA[
           this._conv.removeObserver(this);
           delete this._conv;
           this.browser.destroy();
           this.findbar.destroy();
        ]]>
       </body>
     </method>

     <method name="close">
       <body>
        <![CDATA[
           this._forgetConv(true);
        ]]>
       </body>
     </method>

     <field name="loaded">false</field>

     <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>

     <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>

     <method name="addMsg">
      <parameter name="aMsg"/>
      <body>
      <![CDATA[
        if (!this.loaded)
          throw "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;
        }

        // 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;
          }
          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.inputField.clientHeight;
        let deckHeight = textbox.parentNode.boxObject.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;

        if (!("MessageFormat" in window))
          ChromeUtils.import("resource:///modules/imTextboxUtils.jsm");
        MessageFormat.registerTextbox(textbox);

        // Init the textbox size
        this.calculateTextboxDefaultHeight();
        textbox.parentNode.height = textbox.defaultHeight +
                                    this._TEXTBOX_VERTICAL_OVERHEAD;
        textbox.inputField.style.overflowY = "hidden";

        // Delay the initialization of the spellchecker until after
        // the checkbox is initialized, otherwise the spellchecker is
        // broken in conversations added to the window before it is
        // visible (bug 295).
        TextboxSpellChecker.registerTextbox(textbox);
      ]]>
      </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;
            return;
          }
        }

        // Tab complete.
        // Keep the default behavior of the tab key if the input box
        // is empty or a modifier is used.
        if (event.keyCode == KeyEvent.DOM_VK_TAB &&
            text.length != 0 && noSelection &&
            !event.altKey && !event.ctrlKey && !event.metaKey &&
            (!event.shiftKey || this._completions)) {
          event.preventDefault();

          if (this._completions) {
            // Tab has been pressed more than once.
            if (this._completions.length == 1)
              return;
            if (this._shouldListCompletionsLater) {
              this._conv.systemMessage(this._shouldListCompletionsLater);
              delete this._shouldListCompletionsLater;
            }

            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);

            // 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));
                }
              }
            }
          }

          // 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;
              }
              return;
            }
          }

          // We have possible completions!
          this._completions = matchingCompletions.sort();
          this._completionsIndex = 0;
          // Save now the first and last completions in alphabetical order,
          // as we will need them to find a common prefix. However they may
          // not be the first and last completions in the list of completions
          // actually exposed to the user, as if there are active nicks
          // they will be moved to the beginning of the list.
          let firstCompletion = this._completions[0];
          let lastCompletion = this._completions.slice(-1)[0];

          let preferredNick = false;
          if (this._conv.isChat && !completingCommand) {
            // If there are active nicks, prefer those.
            let activeCompletions = this._completions.filter(c =>
              this.buddies.has(c) &&
              !this.buddies.get(c).hasAttribute("inactive"));
            if (activeCompletions.length == 1)
              preferredNick = true;
            if (activeCompletions.length) {
              // Move active nicks to the front of the queue.
              activeCompletions.reverse();
              activeCompletions.forEach(function(c) {
                this._completions.splice(this._completions.indexOf(c), 1);
                this._completions.unshift(c);
              }, this);
            }

            // If one of the completions is the sender of the last ping,
            // take it, if it was less than an hour ago.
            if (this._lastPing && this.buddies.has(this._lastPing) &&
                this._completions.includes(this._lastPing) &&
                (Date.now() / 1000 - this._lastPingTime) < 3600) {
              preferredNick = true;
              this._completionsIndex = this._completions.indexOf(this._lastPing);
            }
          }

          // Display the possible completions in a system message.
          delete this._shouldListCompletionsLater;
          if (this._completions.length > 1) {
            let completionsList = this._completions.join(" ");
            if (preferredNick) {
              // If we have a preferred nick (which is completed as a whole
              // even if there are alternatives), only show the list of
              // completions on the next <tab> press.
              this._shouldListCompletionsLater = completionsList;
            } else {
              this._conv.systemMessage(completionsList);
            }
          }

          let suffix = (isFirstWord ? firstWordSuffix : "");
          this._completions = this._completions.map(c => c + suffix);

          let completion;
          if (this._completions.length == 1 || preferredNick) {
            // Only one possible completion? Apply it! :-)
            completion = this._completions[this._completionsIndex++];
            this._completionsIndex %= this._completions.length;
          } else {
            // We have several possible completions, attempt to find a common prefix.
            let maxLength = Math.min(firstCompletion.length, lastCompletion.length);
            let i = 0;
            while (i < maxLength && firstCompletion[i] == lastCompletion[i])
              ++i;

            if (i) {
              completion = firstCompletion.substring(0, i);
            } else {
              // Include this case so that secondNick is applied anyway,
              // in case a completion is added by another tab press.
              completion = word;
            }
          }

          // Always replace what the user typed as its upper/lowercase may
          // not be correct.
          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;
          }

          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 = "";
        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.inputField.style.overflowY = overflow;
      ]]>
      </body>
     </method>

     <method name="inputExpand">
      <parameter name="event"/>
      <body>
      <![CDATA[
        let textbox = this.editor;
        let input = textbox.inputField;

        // 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.getElt("splitter-bottom").getAttribute("state") == "dragging") {
          input.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 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 = textbox.parentNode;
        let oldDeckHeight = parseInt(deck.height);
        let newDeckHeight =
          parseInt(input.scrollHeight) + this._TEXTBOX_VERTICAL_OVERHEAD;

        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);
        }
      ]]>
      </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"));

          if (convTopHeight == convTopMinHeight) {
            textbox.parentNode.height = parseInt(textbox.parentNode.minHeight);
            convTopHeight = parseInt(convTopStyle.getPropertyValue("height"));
            textbox.parentNode.height = parseInt(textbox.parentNode.minHeight) +
                                        (convTopHeight - convTopMinHeight);
          }
        }

        if (TextboxSize.autoResize)
          this.inputExpand();
      ]]>
      </body>
     </method>

     <method name="_onTextboxUnderflow">
      <parameter name="event"/>
      <body>
      <![CDATA[
        if (TextboxSize.autoResize)
          this.inputField.style.overflowY = "hidden";
      ]]>
      </body>
     </method>

     <method name="browserKeyPress">
     <parameter name="event"/>
      <body>
      <![CDATA[
        var accelKeyPressed = AppConstants.platform == "macosx" ? event.metaKey : event.ctrlKey;

        // 118 is the decimal code for "v" character, 13 keyCode for "return" key
        if (((accelKeyPressed && event.charCode != 118) || event.altKey) &&
            event.keyCode != 13)
          return;

        if (event.charCode == 0 &&  // it's not a character, it's a command key
            (event.keyCode != 13 && // Return
             event.keyCode != 8 &&  // Backspace
             event.keyCode != 46))  // Delete
          return;

        if (accelKeyPressed ||
            !Services.prefs.getBoolPref("accessibility.typeaheadfind")) {
          this.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();
          }
        }

        // Returns for Ctrl+V
        if (accelKeyPressed)
          return;

        // resend the event
        let clonedEvent = new KeyboardEvent("keypress", event);
        this.editor.inputField.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 (msg.system || msg.outgoing || !msg.incoming || msg.error ||
                !this._conv.isChat)
              return;
            this.addPrompt(msg.who + ": ");
            return;
          }
        }
      ]]>
      </body>
     </method>

     <!-- 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;

         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>

     <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>

     <!-- Update the participant count of a chat conversation -->
     <method name="updateParticipantCount">
       <body>
       <![CDATA[
         document.getElementById("participantCount").value = this.buddies.size;
       ]]>
       </body>
     </method>

     <!-- 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>

     <!-- 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>

     <method name="_isBuddyActive">
       <parameter name="aBuddyName"/>
       <body>
       <![CDATA[
         return Object.prototype.hasOwnProperty.call(this._activeBuddies, aBuddyName);
       ]]>
       </body>
     </method>

     <!-- Add a buddy in the visible list of participants -->
     <method name="addBuddy">
       <parameter name="aBuddy"/>
       <body>
       <![CDATA[
         var name = aBuddy.name;
         if (!name)
           throw "The empty string isn't a valid nick.";
         if (this.buddies.has(name))
           throw "Adding chat buddy " + name + " twice?!";

         this.trackNick(name);

         let image = document.createElement("image");
         let label = document.createElement("label");
         label.setAttribute("value", name);
         label.setAttribute("flex", "1");
         label.setAttribute("crop", "end");

         var item = document.createElement("richlistitem");
         item.chatBuddy = aBuddy;
         item.appendChild(image);
         item.appendChild(label);
         this.setBuddyAttributes(item);

         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);

         // Insert item at the right position
         this.addNick(item);
       ]]>
       </body>
     </method>

     <method name="addNick">
       <parameter name="aListItem"/>
       <body>
       <![CDATA[
         var nicklist = document.getElementById("nicklist");
         var nick = aListItem.querySelector("label").value.toLowerCase();

         // 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);
           if (nick < nicklist.getItemAtIndex(middle).label.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));
       ]]>
       </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 "The empty string isn't a valid nick.";

         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;
         }

         this.trackNick(name);

         if (!this._isConversationSelected)
           return;

         // Is aOldName is not null, then we are renaming the buddy
         if (!this.buddies.has(aOldName))
           throw "Updating a chat buddy that does not exist: " + aOldName;

         if (this.buddies.has(name))
           throw "Updating a chat buddy to an already existing one: " + name;

         let item = this.buddies.get(aOldName);
         item.chatBuddy = aBuddy;
         this.buddies.delete(aOldName);
         this.buddies.set(name, item);
         item.querySelector("label").value = name;

         // Move this item to the right position if its name changed
         item.remove();
         this.addNick(item);
       ]]>
       </body>
     </method>

     <method name="removeBuddy">
       <parameter name="aName"/>
       <body>
       <![CDATA[
         if (!this.buddies.has(aName))
           throw "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>

     <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>

     <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;
             }

             // 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>

     <method name="updateTopic">
       <body>
       <![CDATA[
          let cti = document.getElementById("conv-top-info");
          if (this._conv.topicSettable)
            cti.setAttribute("topicEditable", "true");
          else
            cti.removeAttribute("topicEditable");

          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>

     <method name="focus">
       <body>
       <![CDATA[
         this.editor.focus();

         if (!this.loaded)
           return;

         if (this.tab) {
           this.tab.removeAttribute("unread");
           this.tab.removeAttribute("attention");
         }
         this._conv.markAsRead();
       ]]>
       </body>
     </method>

     <field name="_currentTypingName">""</field>
     <method name="updateTyping">
       <body>
       <![CDATA[
          let typingState = this._conv.typingState;
          let cti = document.getElementById("conv-top-info");
          cti.removeAttribute("typing");
          cti.removeAttribute("typed");

          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", "true");
            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("typed", "true");

            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>

    <method name="switchingToPanel">
      <body>
      <![CDATA[
        if (this._visibleTimer)
          return;

        // Start a timer to detect if the tab has been visible to the
        // user for long enough to actually be seen (as opposed to the
        // tab only being visible "accidentally in passing").
        delete this._wasVisible;
        this._visibleTimer = setTimeout(function() {
          this._wasVisible = true;
          delete this._visibleTimer;

          // Porting note: For TB, we also need to update the conv title
          // and reset the unread flag. In IB, this is done by tabbrowser.
          this.tab.update();
        }.bind(this), 1000);
      ]]>
      </body>
    </method>

    <method name="switchingAwayFromPanel">
      <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();
        ]]>
      </body>
    </method>

     <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");

          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);
            }

            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();
          }
       ]]>
       </body>
     </method>

     <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();
           let nicks = fixIterator(this.conv.getParticipants());
           for (let n of nicks)
             this.addBuddy(n);
           this.updateParticipantCount();
         }
       ]]>
       </body>
     </method>

     <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");

           this._activeBuddies = {};
           this.showParticipants();

           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 (!("Status" in window))
           ChromeUtils.import("resource:///modules/imStatusUtils.jsm");
         this.updateConvStatus();
         this.initTextboxFormat();
       ]]>
       </body>
     </method>

     <!-- 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");

           // 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;
         }

         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;

         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;

         case "update-typing":
           if (this.tab && this._isConversationSelected) {
             this._currentTypingName = aData;
             this.updateConvStatus();
           }
           break;

         case "chat-buddy-add":
           if (!this._isConversationSelected)
             break;
           aSubject.QueryInterface(Ci.nsISimpleEnumerator);
           while (aSubject.hasMoreElements())
             this.addBuddy(aSubject.getNext());
           this.updateParticipantCount();
           break;

         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;

         case "chat-buddy-update":
           this.updateBuddy(aSubject, aData);
           break;

         case "chat-update-topic":
           if (this._isConversationSelected)
             this.updateTopic();
           break;
         }
       ]]>
       </body>
     </method>

     <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>

     <property name="convId">
       <getter>
         <![CDATA[
           return this._conv.id;
         ]]>
       </getter>
     </property>

     <property name="conv">
       <getter>
         <![CDATA[
           return this._conv;
         ]]>
       </getter>
       <setter>
         <![CDATA[
           if (this._conv && val)
             throw "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>

     <field name="_editor">null</field>
     <property name="editor">
       <getter>
         <![CDATA[
          if (!this._editor)
            this._editor = this.getElt("inputBox");
          return this._editor;
         ]]>
       </getter>
     </property>

     <property name="browser" onget="return this.getElt('browser');"/>
     <property name="contentWindow" onget="return this.browser.contentWindow;"/>
     <property name="findbar" onget="return this.getElt('FindToolbar');"/>

     <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>

  <binding id="conv-info-large"
           extends="chrome://messenger/content/toolbar.xml#toolbar">
    <content>
      <xul:stack anonid="statusImageStack" class="statusImageStack">
        <!-- The box around the user icon is a workaround for bug 955673. -->
        <xul:box class="userIconHolder" xbl:inherits="userIcon">
          <xul:image anonid="userIcon" class="userIcon" mousethrough="always"
                     xbl:inherits="src=userIcon"/>
        </xul:box>
        <xul:image anonid="statusTypeIcon" class="statusTypeIcon"
                   xbl:inherits="status,typing,typed,tooltiptext=statusTypeTooltiptext"/>
      </xul:stack>
      <xul:stack class="displayNameAndstatusMessageStack"
                 mousethrough="always" flex="1">
        <xul:hbox align="center" flex="1">
          <xul:description anonid="displayName" class="displayName" flex="1"
                           crop="end" xbl:inherits="value=displayName"/>
          <xul:image class="prplIcon" anonid="targetPrplIcon"
                     xbl:inherits="src=prplIcon"/>
        </xul:hbox>
        <xul:description anonid="statusMessage" class="statusMessage"
                         xbl:inherits="value=statusMessage,tooltiptext=statusTooltiptext,editable=topicEditable,editing,noTopic"
                         mousethrough="never" crop="end" flex="100000"/>
      </xul:stack>
    </content>
    <implementation>
     <constructor>
      <![CDATA[
        this.topic
            .addEventListener("click", this.startEditTopic.bind(this));
        // Cancel any ongoing edit if the binding changes.
        this.removeAttribute("editing");
      ]]>
     </constructor>

     <property name="topic">
       <getter>
         <![CDATA[
           return document.getAnonymousElementByAttribute(this, "anonid",
                                                          "statusMessage");
         ]]>
       </getter>
     </property>

     <method name="finishEditTopic">
       <parameter name="aSave"/>
       <body>
       <![CDATA[
         if (!this.hasAttribute("editing"))
           return;

         let convBinding =
           document.getElementById("conversationsDeck").selectedPanel;

         let elt = this.topic;
         if (aSave) {
           // apply the new topic only if it is different from the current one
           if (elt.value != elt.getAttribute("value"))
             convBinding._conv.topic = elt.value;
         }
         this.removeAttribute("editing");
         elt.removeEventListener("keypress", this._topicKeyPress, true);
         delete this._topicKeyPress;
         elt.removeEventListener("blur", this._topicBlur);
         delete this._topicBlur;

         // After removing the "editing" attribute, the focus is on an element
         // that can't receive keyboard events, so move it to somewhere else.
         convBinding.editor.focus();
       ]]>
       </body>
     </method>

     <method name="topicKeyPress">
       <parameter name="aEvent"/>
       <body>
       <![CDATA[
         switch (aEvent.keyCode) {
           case aEvent.DOM_VK_RETURN:
             this.finishEditTopic(true);
             break;

           case aEvent.DOM_VK_ESCAPE:
             this.finishEditTopic(false);
             aEvent.stopPropagation();
             aEvent.preventDefault();
             break;
         }
       ]]>
       </body>
     </method>

     <method name="topicBlur">
       <parameter name="aEvent"/>
       <body>
       <![CDATA[
         if (aEvent.originalTarget == this.topic.inputField)
           this.finishEditTopic(true);
       ]]>
       </body>
     </method>

     <method name="startEditTopic">
       <body>
       <![CDATA[
          let elt = this.topic;
          if (!elt.hasAttribute("editable") || this.hasAttribute("editing"))
            return;

          this.setAttribute("editing", "true");
          this._topicKeyPress = this.topicKeyPress.bind(this);
          elt.addEventListener("keypress", this._topicKeyPress);
          this._topicBlur = this.topicBlur.bind(this);
          elt.addEventListener("blur", this._topicBlur);
          // force binding attachmant by forcing layout
          elt.getBoundingClientRect();
          if (this.hasAttribute("noTopic"))
            elt.value = "";
          elt.select();
       ]]>
       </body>
     </method>
    </implementation>
  </binding>
</bindings>