Bug 1519091 - convert convbrowser from xbl binding into a custom element (customized built in, extending <browser>), name it conversation-browser. r=florian DONTBUILD
authorMagnus Melin <mkmelin+mozilla@iki.fi>
Sun, 20 Jan 2019 22:41:07 +0200
changeset 34255 bf2126d1ba5c199f4f3c92baac8bd519bd828905
parent 34254 0befd65ff82b2f4309a3e6ebdc4a900ea6717c07
child 34256 611baef2655fa4f370f2494b0126bfbbf738327f
push id389
push userclokep@gmail.com
push dateMon, 18 Mar 2019 19:01:53 +0000
reviewersflorian
bugs1519091, 1441935, 1520908
Bug 1519091 - convert convbrowser from xbl binding into a custom element (customized built in, extending <browser>), name it conversation-browser. r=florian DONTBUILD Adjust to changes from bug 1441935 - Convert <browser> XBL binding to a Custom Element The code had some odd things: * multiple autoscrollEnable, or rather one of that and one autoScrollEnabled (capital S), now renamed to convScrollEnabled * mDragDropHandler - not hooked up to anything disablefastfind is no longer supported in core - bug 1520908 handles that.
chat/content/convbrowser.xml
chat/content/conversation-browser.js
chat/content/jar.mn
mail/base/content/customElements.js
mail/components/im/content/chat-messenger.inc.xul
mail/components/im/content/chat-messenger.js
mail/components/im/content/chat.css
mail/components/im/content/imconversation.xml
mail/components/im/messages/bubbles/Footer.html
mail/components/preferences/aboutPreferences.xul
mail/components/preferences/chat.js
mail/components/preferences/messagestyle.js
rename from chat/content/convbrowser.xml
rename to chat/content/conversation-browser.js
--- a/chat/content/convbrowser.xml
+++ b/chat/content/conversation-browser.js
@@ -1,1358 +1,726 @@
-<?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/. -->
-
-<bindings id="browserBindings"
-          xmlns="http://www.mozilla.org/xbl"
-          xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
-
-  <binding id="browser" role="outerdoc">
-    <implementation type="application/javascript"
-                    implements="nsIWebProgressListener, nsIController,
-                                nsISelectionListener, nsIObserver">
-      <property name="autoscrollEnabled">
-        <getter>
-          <![CDATA[
-            if (this.getAttribute("autoscroll") == "false")
-              return false;
+/* This Source Code Form is subject to the terms of the Mozilla Public
+  * License, v. 2.0. If a copy of the MPL was not distributed with this
+  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-            var enabled = true;
-            try {
-              enabled = Services.prefs.getBoolPref("general.autoScroll");
-            }
-            catch(ex) {
-            }
+"use strict";
 
-            return enabled;
-          ]]>
-        </getter>
-      </property>
-
-      <field name="_theme">null</field>
-
-      <property name="theme"
-                readonly="true"
-                onget="return this._theme || (this._theme = getCurrentTheme());"/>
-
-      <field name="_conv">null</field>
+/* global MozXULElement */
+/* global initHTMLDocument serializeSelection getCurrentTheme isNextMessage getHTMLForMessage insertHTMLForMessage */
+/* global smileTextNode */
+/* global cleanupImMarkup */
 
-      <field name="_loadState">0</field>
-
-      <method name="init">
-        <parameter name="aConversation"/>
-        <body>
-          <![CDATA[
-            // Magic Copy may be initialized if the convbrowser is already
-            // displaying a conversation.
-            this.uninitMagicCopy();
-
-            this._conv = aConversation;
-
-            // init is called when the message style preview is
-            // reloaded so we need to reset _theme.
-            this._theme = null;
-
-            // Prevent ongoing asynchronous message display from continuing.
-            this._messageDisplayPending = false;
+(function () { // Make <browser> defined now. It's lazily defined.
+  delete document.createElement("browser");
+})();
 
-            // _loadState is 0 while loading conv.html and 1 while
-            // loading the real conversation HTML.
-            this._loadState = 0;
-
-            this.docShell.charset = "UTF-8";
-            const URI = "chrome://chat/content/conv.html";
-            const loadURIOptions = {
-              triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
-            };
-            this.webNavigation.loadURI(URI, loadURIOptions);
-            this.addProgressListener(this);
+/**
+ * The chat conversation browser, i.e. the main content on the chat tab.
+ * @augments {MozBrowser}
+ */
+class MozConversationBrowser extends customElements.get("browser") {
+  constructor() {
+    super();
 
-            if (this._scrollingView)
-              this._autoScrollPopup.hidePopup();
-          ]]>
-        </body>
-      </method>
-
-      <property name="currentURI"
-                onget="return this.webNavigation.currentURI;"
-                readonly="true"/>
-
-      <field name="_docShell">null</field>
+    // Make sure to load URLs externally.
+    this.addEventListener("click", (event) => {
+      // Right click should open the context menu.
+      if (event.button == 2)
+        return;
 
-      <property name="docShell"
-                readonly="true">
-        <getter>
-          <![CDATA[
-            if (this._docShell) {
-              return this._docShell;
-            }
-            if (!this.frameLoader) {
-              return null;
-            }
-            this._docShell = this.frameLoader.docShell;
-            return this._docShell;
-          ]]>
-        </getter>
-      </property>
+      // The 'click' event is fired even when the link is
+      // activated with the keyboard.
 
-      <property name="messageManager"
-                readonly="true">
-        <getter>
-          <![CDATA[
-            if (!this.frameLoader) {
-              return null;
-            }
-            return this.frameLoader.messageManager;
-          ]]>
-        </getter>
-      </property>
-
-      <field name="_webNavigation">null</field>
-
-      <property name="webNavigation"
-                onget="return this._webNavigation || (this._webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation));"
-                readonly="true"/>
-
-      <field name="_finder">null</field>
+      // The event target may be a descendant of the actual link.
+      let url;
+      for (let elem = event.target; elem; elem = elem.parentNode) {
+        if (elem instanceof HTMLAnchorElement) {
+          url = elem.href;
+          if (url)
+            break;
+        }
+      }
+      if (!url) {
+        return;
+      }
 
-      <property name="finder" readonly="true">
-        <getter><![CDATA[
-          if (!this._finder) {
-            if (!this.docShell)
-              return null;
+      let uri = Services.io.newURI(url);
 
-            const { Finder } = ChromeUtils.import("resource://gre/modules/Finder.jsm", null);
-            this._finder = new Finder(this.docShell);
-          }
-          return this._finder;
-        ]]></getter>
-      </property>
-
-      <field name="_fastFind">null</field>
-      <property name="fastFind"
-                readonly="true">
-        <getter>
-        <![CDATA[
-          if (!this._fastFind) {
-            if (!("@mozilla.org/typeaheadfind;1" in Cc))
-              return null;
+      // http and https are the only schemes that are both
+      // allowed by our IM filters and exposed.
+      if (!uri.schemeIs("http") && !uri.schemeIs("https"))
+        return;
 
-            if (!this.docShell)
-              return null;
-
-            this._fastFind = Cc["@mozilla.org/typeaheadfind;1"]
-                               .createInstance(Ci.nsITypeAheadFind);
-            this._fastFind.init(this.docShell);
-          }
-          return this._fastFind;
-        ]]>
-        </getter>
-      </property>
+      event.preventDefault();
+      event.stopPropagation();
 
-      <field name="_lastSearchString">null</field>
-      <field name="_lastSearchHighlight">false</field>
-
-      <property name="webProgress"
-                readonly="true"
-                onget="return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebProgress);"/>
-
-      <field name="_contentWindow">null</field>
-
-      <property name="contentWindow"
-                readonly="true"
-                onget="return this._contentWindow || (this._contentWindow = this.docShell.domWindow);"/>
+      // loadURI can throw if the default browser is misconfigured.
+      Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+        .getService(Ci.nsIExternalProtocolService)
+        .loadURI(uri);
+    });
 
-      <property name="contentWindowAsCPOW"
-                readonly="true"
-                onget="return this.contentWindow;"/>
-
-      <property name="contentDocument"
-                onget="return this.webNavigation.document;"
-                readonly="true"/>
-
-      <property name="markupDocumentViewer"
-                onget="return this.docShell.contentViewer;"
-                readonly="true"/>
-
-      <property name="contentChatNode"
-                onget="return this.contentDocument.getElementById('Chat');"
-                readonly="true"/>
-
-      <property name="fullZoom">
-        <getter><![CDATA[
-          return this.markupDocumentViewer.fullZoom;
-        ]]></getter>
-        <setter><![CDATA[
-          this.markupDocumentViewer.fullZoom = val;
-        ]]></setter>
-      </property>
-
-      <property name="textZoom">
-        <getter><![CDATA[
-          return this.markupDocumentViewer.textZoom;
-        ]]></getter>
-        <setter><![CDATA[
-          this.markupDocumentViewer.textZoom = val;
-        ]]></setter>
-      </property>
-
-      <property name="isSyntheticDocument">
-        <getter><![CDATA[
-          return this.contentDocument.mozSyntheticDocument;
-        ]]></getter>
-      </property>
+    this.addEventListener("keypress", (event) => {
+      switch(event.keyCode) {
+        case KeyEvent.DOM_VK_PAGE_UP: {
+          if (event.shiftKey) {
+            this.contentWindow.scrollByPages(-1);
+          } else if (event.altKey) {
+            this.scrollToPreviousSection();
+          }
+          break;
+        }
+        case KeyEvent.DOM_VK_PAGE_DOWN: {
+          if (event.shiftKey) {
+            this.contentWindow.scrollByPages(1);
+          } else if (event.altKey) {
+            this.scrollToNextSection();
+          }
+          break;
+        }
+        case KeyEvent.DOM_VK_HOME: {
+          this.scrollToPreviousSection();
+          event.preventDefault();
+          break;
+        }
+        case KeyEvent.DOM_VK_END: {
+          this.scrollToNextSection();
+          event.preventDefault();
+          break;
+        }
+      }
+    });
+  }
 
-      <method name="enableMagicCopy">
-        <body>
-          <![CDATA[
-          this.contentWindow.controllers.insertControllerAt(0, this);
-          this.autoCopyEnabled = Services.clipboard.supportsSelectionClipboard() &&
-            Services.prefs.getBoolPref("clipboard.autocopy");
-          if (this.autoCopyEnabled) {
-            this.contentWindow.getSelection().addSelectionListener(this);
-          }
-          ]]>
-        </body>
-      </method>
+  connectedCallback() {
+    super.connectedCallback();
+
+    if (!("cleanupImMarkup" in window))
+      ChromeUtils.import("resource:///modules/imContentSink.jsm");
+    if (!("smileImMarkup" in window))
+      ChromeUtils.import("resource:///modules/imSmileys.jsm");
+    if (!("getCurrentTheme" in window))
+      ChromeUtils.import("resource:///modules/imThemes.jsm");
+
+    this._theme = null;
 
-      <method name="disableMagicCopy">
-        <body>
-          <![CDATA[
-          this.contentWindow.controllers.removeController(this);
-          if (this.autoCopyEnabled) {
-             this.contentWindow.getSelection().removeSelectionListener(this);
-          }
-          ]]>
-        </body>
-      </method>
+    this._conv = null;
+
+    this._loadState = 0;
+
+    this.autoCopyEnabled = false;
 
-      <field name="autoCopyEnabled">false</field>
-      <field name="magicCopyPref" readonly="true">"messenger.conversations.selections.magicCopyEnabled"</field>
-      <property name="magicCopyEnabled"
-                onget="return Services.prefs.getBoolPref(this.magicCopyPref);"
-                readonly="true"/>
-      <field name="magicCopyInitialized">false</field>
+    this.magicCopyPref = "messenger.conversations.selections.magicCopyEnabled";
 
-      <method name="initMagicCopy">
-        <body>
-          <![CDATA[
-          if (this.magicCopyInitialized)
-            return;
-          Services.prefs.addObserver(this.magicCopyPref, this);
-          this.magicCopyInitialized = true;
-          if (this.magicCopyEnabled)
-            this.enableMagicCopy();
-          ]]>
-        </body>
-      </method>
+    this.magicCopyInitialized = false;
+
+    this._destroyed = false;
 
-      <method name="uninitMagicCopy">
-        <body>
-          <![CDATA[
-          if (!this.magicCopyInitialized)
-            return;
-          Services.prefs.removeObserver(this.magicCopyPref, this);
-          if (this.magicCopyEnabled)
-            this.disableMagicCopy();
-          this.magicCopyInitialized = false;
-          ]]>
-        </body>
-      </method>
+    // Makes the chat browser scroll to the bottom automatically when we append
+    // a new message. This behavior gets disabled when the user scrolls up to
+    // look at the history, and we re-enable it when the user scrolls to
+    // (within 10px) of the bottom.
+    this._convScrollEnabled = true;
+
+    this._textModifiers = [smileTextNode];
 
-      <method name="addProgressListener">
-        <parameter name="aListener"/>
-        <body>
-          <![CDATA[
-            this.webProgress.addProgressListener(aListener, Ci.nsIWebProgress.NOTIFY_ALL);
-          ]]>
-        </body>
-      </method>
+    // These variables are reset in onStateChange:
+    this._lastMessage = null;
+    this._lastMessageIsContext = true;
+    this._firstNonContextElt = null;
+    this._messageDisplayPending = false;
+    this._pendingMessages = [];
+    this._nextPendingMessageIndex = 0;
+    this._pendingMessagesDisplayed = 0;
+    this._displayPendingMessagesCalls = 0;
+    this._sessions = [];
 
-      <method name="removeProgressListener">
-        <parameter name="aListener"/>
-        <body>
-          <![CDATA[
-            this.webProgress.removeProgressListener(aListener);
-         ]]>
-        </body>
-      </method>
+    this.progressBar = null;
 
-      <field name="mDestroyed">false</field>
-
-      <constructor>
-        <![CDATA[
-          this.addEventListener("scroll", this.browserScroll);
-          this.addEventListener("resize", this.browserResize);
+    this.addEventListener("scroll", this.browserScroll);
+    this.addEventListener("resize", this.browserResize);
 
-          if (!("cleanupImMarkup" in window))
-            ChromeUtils.import("resource:///modules/imContentSink.jsm");
-          if (!("smileImMarkup" in window))
-            ChromeUtils.import("resource:///modules/imSmileys.jsm");
-          if (!("getCurrentTheme" in window))
-            ChromeUtils.import("resource:///modules/imThemes.jsm");
+    // @implements {nsIWebProgressListener}
+    this.progressListener = {
+      onStateChange: (progress, request, stateFlags, status) => {
+        if (!((stateFlags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT) &&
+              (stateFlags & Ci.nsIWebProgressListener.STATE_STOP))) {
+          return;
+        }
+        if (!this._loadState) {
+          initHTMLDocument(this._conv, this.theme, this.contentDocument);
+          this._loadState = 1;
+          this._exposeMethodsToContent();
+          return;
+        }
+        this.removeProgressListener(this.progressListener);
 
-          this.onContentElementLoad = this._onContentElementLoad.bind(this);
-        ]]>
-      </constructor>
-
-      <destructor>
-        <![CDATA[
-          this.destroy();
-        ]]>
-      </destructor>
+        this.initMagicCopy();
 
-      <!-- This is necessary because the destructor doesn't always get called when
-           we are removed from a tabbrowser. This will be explicitly called by tabbrowser -->
-      <method name="destroy">
-        <body>
-          <![CDATA[
-            if (this.mDestroyed)
-              return;
-            this.mDestroyed = true;
-            this._messageDisplayPending = false;
-
-            if (this.mDragDropHandler)
-              this.mDragDropHandler.detach();
-            this.mDragDropHandler = null;
-
-            this._fastFind = null;
+        // We need to reset these variables here to avoid a race
+        // condition if we are starting to display a new conversation
+        // but the display of the previous conversation wasn't finished.
+        // This can happen if the user quickly changes the selected
+        // conversation in the log viewer.
+        this._lastMessage = null;
+        this._lastMessageIsContext = true;
+        this._firstNonContextElt = null;
+        this._messageDisplayPending = false;
+        this._pendingMessages = [];
+        this._nextPendingMessageIndex = 0;
+        this._pendingMessagesDisplayed = 0;
+        this._displayPendingMessagesCalls = 0;
+        this._sessions = [];
+        if (this.progressBar)
+          this.progressBar.hidden = true;
 
-            if (this._autoScrollNeedsCleanup) {
-              // we polluted the global scope, so clean it up
-              this._autoScrollPopup.remove();
-            }
-
-            this.uninitMagicCopy();
-
-            if (this.contentChatNode) {
-              // Remove the listener only if the conversation was initialized.
-              this.contentChatNode
-                  .removeEventListener("load", this.onContentElementLoad, true);
-            }
-          ]]>
-        </body>
-      </method>
+        this.onChatNodeContentLoad = this.onContentElementLoad.bind(this);
+        this.contentChatNode.addEventListener("load", this.onChatNodeContentLoad, true);
 
-      <field name="_autoScrollEnabled">true</field>
-
-      <method name="_updateAutoScrollEnabled">
-        <body>
-          <![CDATA[
-            // Enable auto-scroll if the scrollbar is at the bottom.
-            let body = this.contentDocument.querySelector("body");
-            this._autoScrollEnabled =
-              body.scrollHeight <= body.scrollTop + body.clientHeight + 10;
-            return this._autoScrollEnabled;
-          ]]>
-         </body>
-      </method>
+        // Notify observers to get the conversation shown.
+        Services.obs.notifyObservers(this, "conversation-loaded");
+      },
+      onProgressChange(progress, request, curSelf, maxSelf, curTotal, maxTotal) { },
+      onLocationChange(aprogress, request, location) { },
+      onStatusChange(progress, request, status, message) { },
+      onSecurityChange(progress, request, state) { },
+      QueryInterface: ChromeUtils.generateQI([
+        Ci.nsIWebProgressListener,
+        Ci.nsISupportsWeakReference],
+      ),
+    };
 
-      <method name="autoScrollEnabled">
-        <body>
-          <![CDATA[
-            return this._autoScrollEnabled || this._updateAutoScrollEnabled();
-          ]]>
-         </body>
-      </method>
-
-      <method name="_scrollToElement">
-        <parameter name="aElt"/>
-        <body>
-          <![CDATA[
-            aElt.scrollIntoView(true);
-            this._scrollingIntoView = true;
-          ]]>
-         </body>
-      </method>
-
-      <field name="_textModifiers">[smileTextNode]</field>
+    // @implements {nsIObserver}
+    this.prefObserver = (subject, topic, data) => {
+      if (this.magicCopyEnabled) {
+        this.enableMagicCopy();
+      } else {
+        this.disableMagicCopy();
+      }
+    };
 
-      <method name="addTextModifier">
-        <parameter name="aModifier"/>
-        <body>
-          <![CDATA[
-            if (!this._textModifiers.includes(aModifier))
-              this._textModifiers.push(aModifier);
-          ]]>
-        </body>
-      </method>
-
-      <!-- These variables are reset in onStateChange. -->
-      <field name="_lastMessage">null</field>
-      <field name="_lastMessageIsContext">true</field>
-      <field name="_firstNonContextElt">null</field>
+    // @implements {nsIController}
+    this.conversationController = {
+      supportsCommand(command) {
+        return command == "cmd_copy" || command == "cmd_cut";
+      },
+      isCommandEnabled(command) {
+        return command == "cmd_copy" &&
+          !this.contentWindow.getSelection().isCollapsed;
+      },
+      doCommand(command) {
+        let selection = this.contentWindow.getSelection();
+        if (selection.isCollapsed)
+          return;
 
-      <field name="_pendingMessages">[]</field>
-      <field name="_messageDisplayPending">false</field>
-      <method name="appendMessage">
-        <parameter name="aMsg"/>
-        <parameter name="aContext"/>
-        <parameter name="aFirstUnread"/>
-        <body>
-          <![CDATA[
-            this._pendingMessages.push({msg: aMsg, context: aContext,
-                                        firstUnread: aFirstUnread});
-            this.delayedDisplayPendingMessages();
-          ]]>
-        </body>
-      </method>
-
-      <method name="delayedDisplayPendingMessages">
-        <body>
-          <![CDATA[
-            if (this._messageDisplayPending)
-              return;
-            this._messageDisplayPending = true;
-            this.contentWindow.messageInsertPending = true;
-            requestIdleCallback(this.displayPendingMessages.bind(this));
-          ]]>
-        </body>
-      </method>
-
-      <field name="progressBar">null</field>
+        Cc["@mozilla.org/widget/clipboardhelper;1"]
+          .getService(Ci.nsIClipboardHelper)
+          .copyString(serializeSelection(selection));
+      },
+      onEvent(command) { },
+      QueryInterface: ChromeUtils.generateQI([Ci.nsIController]),
+    };
 
-      <!-- getNextPendingMessage and getPendingMessagesCount are the
-           only 2 methods accessing the this._pendingMessages array
-           directly during the chunked display of messages. It is
-           possible to override these 2 methods to replace the array
-           with something else. The log viewer for example uses an
-           enumerator that creates message objects lazily to avoid
-           jank when displaying lots of messages. -->
-      <!-- This variable is reset in onStateChange. -->
-      <field name="_nextPendingMessageIndex">0</field>
-      <method name="getNextPendingMessage">
-        <body>
-          <![CDATA[
-            let length = this._pendingMessages.length;
-            if (this._nextPendingMessageIndex == length)
-              return null;
+    // @implements {nsISelectionListener}
+    this.chatSelectionListener = {
+      notifySelectionChanged(document, selection, reason) {
+        if (!(reason & Ci.nsISelectionListener.MOUSEUP_REASON ||
+              reason & Ci.nsISelectionListener.SELECTALL_REASON ||
+              reason & Ci.nsISelectionListener.KEYPRESS_REASON))
+          return; // we are still dragging, don't bother with the selection
 
-            let result = this._pendingMessages[this._nextPendingMessageIndex++];
-
-            if (this._nextPendingMessageIndex == length) {
-              this._pendingMessages = [];
-              this._nextPendingMessageIndex = 0;
-            }
+        Cc["@mozilla.org/widget/clipboardhelper;1"]
+          .getService(Ci.nsIClipboardHelper)
+          .copyStringToClipboard(serializeSelection(selection),
+                                 Ci.nsIClipboard.kSelectionClipboard);
+      },
+      QueryInterface: ChromeUtils.generateQI([Ci.nsISelectionListener]),
+    };
+  }
 
-            return result;
-          ]]>
-        </body>
-      </method>
-      <method name="getPendingMessagesCount">
-        <body>
-          <![CDATA[
-            return this._pendingMessages.length;
-          ]]>
-        </body>
-      </method>
+  init(conversation) {
+    // Magic Copy may be initialized if the convbrowser is already
+    // displaying a conversation.
+    this.uninitMagicCopy();
 
-      <!-- These variables are reset in onStateChange. -->
-      <field name="_pendingMessagesDisplayed">0</field>
-      <field name="_displayPendingMessagesCalls">0</field>
-      <method name="displayPendingMessages">
-        <parameter name="timing"/>
-        <body>
-          <![CDATA[
-            if (!this._messageDisplayPending)
-              return;
+    this._conv = conversation;
 
-            let max = this.getPendingMessagesCount();
-            do {
-              // One message takes less than 2ms on average.
-              let msg = this.getNextPendingMessage();
-              if (!msg)
-                break;
-              this.displayMessage(msg.msg, msg.context,
-                                  ++this._pendingMessagesDisplayed < max,
-                                  msg.firstUnread);
-            } while (timing.timeRemaining() > 2);
+    // init is called when the message style preview is
+    // reloaded so we need to reset _theme.
+    this._theme = null;
+
+    // Prevent ongoing asynchronous message display from continuing.
+    this._messageDisplayPending = false;
 
-            let event = document.createEvent("UIEvents");
-            event.initUIEvent("MessagesDisplayed", false, false, window, 0);
-            if (this._pendingMessagesDisplayed < max) {
-              if (this.progressBar) {
-                // Show progress bar if after the third call (ca. 120ms)
-                // less than half the messages have been displayed.
-                if (++this._displayPendingMessagesCalls > 2 &&
-                    max > 2 * this._pendingMessagesDisplayed)
-                  this.progressBar.hidden = false;
-                this.progressBar.max = max;
-                this.progressBar.value = this._pendingMessagesDisplayed;
-              }
-              requestIdleCallback(this.displayPendingMessages.bind(this));
-              this.dispatchEvent(event);
-              return;
-            }
-            this.contentWindow.messageInsertPending = false;
-            this._messageDisplayPending = false;
-            this._pendingMessagesDisplayed = 0;
-            this._displayPendingMessagesCalls = 0;
-            if (this.progressBar)
-              this.progressBar.hidden = true;
-            this.dispatchEvent(event);
-          ]]>
-        </body>
-      </method>
+    // _loadState is 0 while loading conv.html and 1 while
+    // loading the real conversation HTML.
+    this._loadState = 0;
+
+    this.docShell.charset = "UTF-8";
+    const URI = "chrome://chat/content/conv.html";
+    const loadURIOptions = {
+      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+    };
+    this.webNavigation.loadURI(URI, loadURIOptions);
+    this.addProgressListener(this.progressListener);
+  }
 
-      <!-- This is reset in onStateChange. -->
-      <field name="_sessions">[]</field>
-      <method name="displayMessage">
-        <parameter name="aMsg"/>
-        <parameter name="aContext"/>
-        <parameter name="aNoAutoScroll"/>
-        <parameter name="aFirstUnread"/>
-        <body>
-          <![CDATA[
-            let doc = this.contentDocument;
+  get theme() {
+    return this._theme || (this._theme = getCurrentTheme());
+  }
+
+  get contentDocument() {
+    return this.webNavigation.document;
+  }
 
-            if (aMsg.noLog && aMsg.notification &&
-                aMsg.who == "sessionstart") {
-              // New session log.
-              if (this._lastMessage) {
-                let ruler = doc.createElement("hr");
-                ruler.className = "sessionstart-ruler";
-                this.contentChatNode.appendChild(ruler);
-                this._sessions.push(ruler);
-                // Close any open bubble.
-                this._lastMessage = null;
-              }
-              // Suppress this message unless it was an error message.
-              if (!aMsg.error)
-                return;
-            }
+  get contentChatNode() {
+    return this.contentDocument.getElementById("Chat");
+  }
 
-            let cs = Cc["@mozilla.org/txttohtmlconv;1"]
-                       .getService(Ci.mozITXTToHTMLConv);
-            /*
-             * kStructPhrase creates tags for plaintext-markup like *bold*,
-             * /italics/, etc. We always use this; the content filter will
-             * filter it out if the user does not want styling.
-             */
-            let csFlags = cs.kStructPhrase;
-            // Automatically find and link freetext URLs
-            if (!aMsg.noLinkification)
-              csFlags |= cs.kURLs;
-
-            if (aFirstUnread)
-              this.setUnreadRuler();
+  get magicCopyEnabled() {
+    return Services.prefs.getBoolPref(this.magicCopyPref);
+  }
 
-            // Right trim before displaying. This removes any OTR related
-            // whitespace when the extension isn't enabled.
-            let msg = aMsg.displayMessage.trimRight();
-
-            // The slash of a leading '/me' should not be used to
-            // format as italic, so we remove the '/me' text before
-            // scanning the HTML, and we add it back later.
-            let meRegExp = /^((<[^>]+>)*)\/me /;
-            let me = false;
-            if (meRegExp.test(msg)) {
-              me = true;
-              msg = msg.replace(meRegExp, "$1");
-            }
-
-            msg = cs.scanHTML(msg.replace(/&/g, "&amp;"), csFlags)
-                    .replace(/&amp;/g, "&");
-
-            if (me)
-              msg = msg.replace(/^((<[^>]+>)*)/, "$1/me ");
+  enableMagicCopy() {
+    this.contentWindow.controllers.insertControllerAt(0, this.chatController);
+    this.autoCopyEnabled = Services.clipboard.supportsSelectionClipboard() &&
+      Services.prefs.getBoolPref("clipboard.autocopy");
+    if (this.autoCopyEnabled) {
+      this.contentWindow.getSelection().addSelectionListener(this.chatSelectionListener);
+    }
+  }
 
-            aMsg.message = cleanupImMarkup(msg.replace(/\r?\n/g, "<br/>"),
-                                           null, this._textModifiers);
+  disableMagicCopy() {
+    this.contentWindow.controllers.removeController(this.chatController);
+    if (this.autoCopyEnabled) {
+      this.contentWindow.getSelection().removeSelectionListener(this.chatSelectionListener);
+    }
+  }
 
-            let next = (aContext == this._lastMessageIsContext || aMsg.system) &&
-                       isNextMessage(this.theme, aMsg, this._lastMessage);
-            let newElt;
-            if (next && aFirstUnread) {
-              // If there wasn't an unread ruler, this would be a Next message.
-              // Therefore, save that version for later.
-              let html = getHTMLForMessage(aMsg, this.theme, next, aContext);
-              let ruler = doc.getElementById("unread-ruler");
-              ruler.nextMsgHtml = html;
-              ruler._originalMsg = aMsg;
-
-              // Remember where the Next message(s) would have gone.
-              let insert = doc.getElementById("insert");
-              if (!insert) {
-                insert = doc.createElement("div");
-                ruler.parentNode.insertBefore(insert, ruler);
-              }
-              insert.id = "insert-before";
+  initMagicCopy() {
+    if (this.magicCopyInitialized)
+      return;
+    Services.prefs.addObserver(this.magicCopyPref, this.prefObserver);
+    this.magicCopyInitialized = true;
+    if (this.magicCopyEnabled)
+      this.enableMagicCopy();
+  }
 
-              next = false;
-              html = getHTMLForMessage(aMsg, this.theme, next, aContext);
-              newElt = insertHTMLForMessage(aMsg, html, doc, next);
-              let marker = doc.createElement("div");
-              marker.id = "end-of-split-block";
-              newElt.parentNode.appendChild(marker);
+  uninitMagicCopy() {
+    if (!this.magicCopyInitialized)
+      return;
+    Services.prefs.removeObserver(this.magicCopyPref, this.prefObserver);
+    if (this.magicCopyEnabled)
+      this.disableMagicCopy();
+    this.magicCopyInitialized = false;
+  }
 
-              // Bracket the place where additional Next messages will be added,
-              // if that's not after the end-of-split-block element.
-              insert = doc.getElementById("insert");
-              if (insert) {
-                marker = doc.createElement("div");
-                marker.id = "next-messages-start";
-                insert.parentNode.insertBefore(marker, insert);
-                marker = doc.createElement("div");
-                marker.id = "next-messages-end";
-                insert.parentNode.insertBefore(marker, insert.nextSibling);
-              }
-            }
-            else {
-              let html = getHTMLForMessage(aMsg, this.theme, next, aContext);
-              newElt = insertHTMLForMessage(aMsg, html, doc, next);
-            }
+  destroy() {
+    super.destroy();
+    if (this._destroyed)
+      return;
+    this._destroyed = true;
+    this._messageDisplayPending = false;
 
-            if (!aNoAutoScroll) {
-              newElt.getBoundingClientRect(); // avoid ireflow bugs
-              if (this.autoScrollEnabled())
-                this._scrollToElement(newElt);
-            }
-            this._lastElement = newElt;
-            this._lastMessage = aMsg;
-            if (!aContext && !this._firstNonContextElt && !aMsg.system)
-              this._firstNonContextElt = newElt;
-            this._lastMessageIsContext = aContext;
-          ]]>
-        </body>
-      </method>
+    this.uninitMagicCopy();
 
-      <method name="setUnreadRuler">
-        <body>
-        <![CDATA[
-          // Remove any existing ruler (occurs when the window has lost focus).
-          this.removeUnreadRuler();
+    if (this.contentChatNode) {
+      // Remove the listener only if the conversation was initialized.
+      this.contentChatNode
+          .removeEventListener("load", this.onChatNodeContentLoad, true);
+    }
+  }
 
-          let ruler = this.contentDocument.createElement("hr");
-          ruler.id = "unread-ruler";
-          this.contentChatNode.appendChild(ruler);
-       ]]>
-        </body>
-      </method>
+  _updateConvScrollEnabled() {
+    // Enable auto-scroll if the scrollbar is at the bottom.
+    let body = this.contentDocument.querySelector("body");
+    this._convScrollEnabled =
+      body.scrollHeight <= body.scrollTop + body.clientHeight + 10;
+    return this._convScrollEnabled;
+  }
 
-      <method name="removeUnreadRuler">
-        <body>
-        <![CDATA[
-          let doc = this.contentDocument;
-          let ruler = doc.getElementById("unread-ruler");
-          if (!ruler)
-            return;
-
-          // If a message block was split by the ruler, rejoin it.
-          let moveTo = doc.getElementById("insert-before");
-          if (moveTo) {
-            // Protect an existing insert node.
-            let actualInsert = doc.getElementById("insert");
-            if (actualInsert)
-              actualInsert.id = "actual-insert";
+  convScrollEnabled() {
+    return this._convScrollEnabled || this._updateConvScrollEnabled();
+  }
 
-            // Add first message following the ruler as a Next type message.
-            // Replicates the relevant parts of insertHTMLForMessage().
-            let range = doc.createRange();
-            let moveToParent = moveTo.parentNode;
-            range.selectNode(moveToParent);
-            let documentFragment = range.createContextualFragment(ruler.nextMsgHtml);
-            for (let root = documentFragment.firstChild; root; root = root.nextSibling)
-              root._originalMsg = ruler._originalMsg;
-            moveToParent.insertBefore(documentFragment, moveTo);
+  _scrollToElement(aElt) {
+    aElt.scrollIntoView(true);
+    this._scrollingIntoView = true;
+  }
 
-            // If this added an insert node, insert the next messages there.
-            let insert = doc.getElementById("insert");
-            if (insert) {
-              moveTo.remove();
-              moveTo = insert;
-              moveToParent = moveTo.parentNode;
-            }
+  _exposeMethodsToContent() {
+    // Expose scrollToElement and convScrollEnabled to the message styles.
+    this.contentWindow.scrollToElement = this._scrollToElement.bind(this);
+    this.contentWindow.convScrollEnabled = this.convScrollEnabled.bind(this);
+  }
 
-            // Move remaining messages from the message block following the ruler.
-            let nextMessagesStart = doc.getElementById("next-messages-start");
-            if (nextMessagesStart) {
-              range = doc.createRange();
-              range.setStartAfter(nextMessagesStart);
-              range.setEndBefore(doc.getElementById("next-messages-end"));
-              moveToParent.insertBefore(range.extractContents(), moveTo);
-            }
-            moveTo.remove();
+  addTextModifier(aModifier) {
+    if (!this._textModifiers.includes(aModifier))
+      this._textModifiers.push(aModifier);
+  }
 
-            // Restore existing insert node.
-            if (actualInsert)
-              actualInsert.id = "insert";
-
-            // Delete surplus message block.
-            range = doc.createRange();
-            range.setStartAfter(ruler);
-            range.setEndAfter(doc.getElementById("end-of-split-block"));
-            range.deleteContents();
-          }
-          ruler.remove();
-        ]]>
-        </body>
-      </method>
+  appendMessage(aMsg, aContext, aFirstUnread) {
+    this._pendingMessages.push({
+      msg: aMsg,
+      context: aContext,
+      firstUnread: aFirstUnread,
+    });
+    this.delayedDisplayPendingMessages();
+  }
 
-      <method name="_getSections">
-        <body>
-        <![CDATA[
-          // If a section is displayed below this point, we assume not enough of
-          // it is visible, so we must scroll to it.
-          // The 3/4 constant is arbitrary, but it has to be greater than 1/2.
-          this._maximalSectionOffset = Math.round(this.clientHeight * 3 / 4);
-
-          // Get list of current section elements.
-          let sectionElements = [];
-          if (this._firstNonContextElt)
-            sectionElements.push(this._firstNonContextElt);
-          let ruler = this.contentDocument.getElementById("unread-ruler");
-          if (ruler)
-            sectionElements.push(ruler);
-          sectionElements = sectionElements.concat(this._sessions);
+  delayedDisplayPendingMessages() {
+    if (this._messageDisplayPending)
+      return;
+    this._messageDisplayPending = true;
+    this.contentWindow.messageInsertPending = true;
+    requestIdleCallback(this.displayPendingMessages.bind(this));
+  }
 
-          // Return ordered array of sections with entries
-          // [Y, scrollY such that Y is centered]
-          let sections = [];
-          let maxY = this.contentWindow.scrollMaxY;
-          for (let i = 0; i < sectionElements.length; ++i) {
-            let y = sectionElements[i].offsetTop;
-            // The section is unnecessary if close to top/bottom of conversation.
-            if (y < this._maximalSectionOffset || maxY < y)
-              continue;
-            sections.push([y, y - Math.round(this.clientHeight / 2)]);
-          }
-          sections.sort((a, b) => a[0] - b[0]);
-          return sections;
-        ]]>
-        </body>
-      </method>
+  // getNextPendingMessage and getPendingMessagesCount are the
+  // only 2 methods accessing the this._pendingMessages array
+  // directly during the chunked display of messages. It is
+  // possible to override these 2 methods to replace the array
+  // with something else. The log viewer for example uses an
+  // enumerator that creates message objects lazily to avoid
+  // jank when displaying lots of messages.
+  getNextPendingMessage() {
+    let length = this._pendingMessages.length;
+    if (this._nextPendingMessageIndex == length)
+      return null;
 
-      <method name="scrollToPreviousSection">
-        <body>
-        <![CDATA[
-          let sections = this._getSections();
-          let y = this.contentWindow.scrollY;
-          let newY = 0;
-          for (let i = sections.length - 1; i >= 0; --i) {
-            let section = sections[i];
-            if (y > section[0]) {
-              newY = section[1];
-              break;
-            }
-          }
-          this.contentWindow.scrollTo(0, newY);
-          this._updateAutoScrollEnabled();
-        ]]>
-        </body>
-      </method>
+    let result = this._pendingMessages[this._nextPendingMessageIndex++];
+
+    if (this._nextPendingMessageIndex == length) {
+      this._pendingMessages = [];
+      this._nextPendingMessageIndex = 0;
+    }
+
+    return result;
+  }
+
+  getPendingMessagesCount() {
+    return this._pendingMessages.length;
+  }
 
-      <method name="scrollToNextSection">
-        <body>
-        <![CDATA[
-          let sections = this._getSections();
-          let y = this.contentWindow.scrollY;
-          let newY = this.contentWindow.scrollMaxY;
-          for (let i = 0; i < sections.length; ++i) {
-            let section = sections[i];
-            if (y + this._maximalSectionOffset < section[0]) {
-              newY = section[1];
-              break;
-            }
-          }
-          this.contentWindow.scrollTo(0, newY);
-          this._updateAutoScrollEnabled();
-        ]]>
-        </body>
-      </method>
+  displayPendingMessages(timing) {
+    if (!this._messageDisplayPending)
+      return;
 
-      <method name="browserScroll">
-        <parameter name="event"/>
-        <body>
-          <![CDATA[
-            if (this._scrollingIntoView) {
-              // We have explicitly requested a scrollIntoView, ignore the event.
-              this._scrollingIntoView = false;
-              this._lastScrollHeight = this.scrollHeight;
-              this._lastScrollWidth = this.scrollWidth;
-              return;
-            }
-
-            if (!("_lastScrollHeight" in this) ||
-                this._lastScrollHeight != this.scrollHeight ||
-                this._lastScrollWidth != this.scrollWidth) {
-              // Ensure scroll events triggered by a change of the
-              // content area size (eg. resizing the window or moving the
-              // textbox splitter) don't affect the auto-scroll behavior.
-              this._lastScrollHeight = this.scrollHeight;
-              this._lastScrollWidth = this.scrollWidth;
-              return;
-            }
+    let max = this.getPendingMessagesCount();
+    do {
+      // One message takes less than 2ms on average.
+      let msg = this.getNextPendingMessage();
+      if (!msg)
+        break;
+      this.displayMessage(msg.msg, msg.context,
+        ++this._pendingMessagesDisplayed < max,
+        msg.firstUnread);
+    } while (timing.timeRemaining() > 2);
 
-            // If images higher than one line of text load they will trigger a
-            // scroll event, which shouldn't disable auto-scroll while messages
-            // are being appended without being scrolled.
-            if (this._messageDisplayPending) {
-              return;
-            }
-
-            // Enable or disable auto-scroll based on the scrollbar position.
-            this._updateAutoScrollEnabled();
-          ]]>
-         </body>
-      </method>
-
-      <method name="browserResize">
-        <parameter name="event"/>
-        <body>
-          <![CDATA[
-            if (this._autoScrollEnabled && this._lastElement) {
-              // The content area was resized and auto-scroll is enabled,
-              // make sure the last inserted element is still visible
-              this._scrollToElement(this._lastElement);
-            }
-          ]]>
-        </body>
-      </method>
+    let event = document.createEvent("UIEvents");
+    event.initUIEvent("MessagesDisplayed", false, false, window, 0);
+    if (this._pendingMessagesDisplayed < max) {
+      if (this.progressBar) {
+        // Show progress bar if after the third call (ca. 120ms)
+        // less than half the messages have been displayed.
+        if (++this._displayPendingMessagesCalls > 2 &&
+          max > 2 * this._pendingMessagesDisplayed)
+          this.progressBar.hidden = false;
+        this.progressBar.max = max;
+        this.progressBar.value = this._pendingMessagesDisplayed;
+      }
+      requestIdleCallback(this.displayPendingMessages.bind(this));
+      this.dispatchEvent(event);
+      return;
+    }
+    this.contentWindow.messageInsertPending = false;
+    this._messageDisplayPending = false;
+    this._pendingMessagesDisplayed = 0;
+    this._displayPendingMessagesCalls = 0;
+    if (this.progressBar)
+      this.progressBar.hidden = true;
+    this.dispatchEvent(event);
+  }
 
-      <!-- This field holds a bound version of _onContentElementLoad, so it can
-           be used as an event listener on the content 'Chat' node. -->
-      <field name="onContentElementLoad">null</field>
-      <method name="_onContentElementLoad">
-        <parameter name="event"/>
-        <body>
-          <![CDATA[
-            if (event.target.localName == "img" &&
-                this._autoScrollEnabled && !this._messageDisplayPending &&
-                this._lastElement) {
-              // An image loaded while auto-scroll is enabled and no further
-              // messages are currently being appended. So we need to scroll
-              // the last element fully back into view.
-              this._scrollToElement(this._lastElement);
-            }
-          ]]>
-        </body>
-      </method>
-
-      <!-- nsIObserver implementation -->
-      <method name="observe">
-        <parameter name="aSubject"/>
-        <parameter name="aTopic"/>
-        <parameter name="aData"/>
-        <body>
-        <![CDATA[
-          if (aTopic != "nsPref:changed")
-            throw "Bad notification";
+  displayMessage(aMsg, aContext, aNoAutoScroll, aFirstUnread) {
+    let doc = this.contentDocument;
 
-          if (this.magicCopyEnabled)
-            this.enableMagicCopy();
-          else
-            this.disableMagicCopy();
-        ]]>
-        </body>
-      </method>
-
-      <!-- nsIController implementation -->
-      <method name="supportsCommand">
-        <parameter name="aCommand"/>
-        <body>
-          <![CDATA[
-            return aCommand == "cmd_copy" || aCommand == "cmd_cut";
-          ]]>
-        </body>
-      </method>
+    if (aMsg.noLog && aMsg.notification &&
+        aMsg.who == "sessionstart") {
+      // New session log.
+      if (this._lastMessage) {
+        let ruler = doc.createElement("hr");
+        ruler.className = "sessionstart-ruler";
+        this.contentChatNode.appendChild(ruler);
+        this._sessions.push(ruler);
+        // Close any open bubble.
+        this._lastMessage = null;
+      }
+      // Suppress this message unless it was an error message.
+      if (!aMsg.error)
+        return;
+    }
 
-      <method name="isCommandEnabled">
-        <parameter name="aCommand"/>
-        <body>
-          <![CDATA[
-            return aCommand == "cmd_copy" &&
-                   !this.contentWindow.getSelection().isCollapsed;
-          ]]>
-        </body>
-      </method>
-
-      <method name="doCommand">
-        <parameter name="aCommand"/>
-        <body>
-          <![CDATA[
-            let selection = this.contentWindow.getSelection();
-            if (selection.isCollapsed)
-              return;
+    let cs = Cc["@mozilla.org/txttohtmlconv;1"]
+      .getService(Ci.mozITXTToHTMLConv);
 
-            Cc["@mozilla.org/widget/clipboardhelper;1"]
-              .getService(Ci.nsIClipboardHelper)
-              .copyString(serializeSelection(selection));
-          ]]>
-        </body>
-      </method>
+     // kStructPhrase creates tags for plaintext-markup like *bold*,
+     // /italics/, etc. We always use this; the content filter will
+     // filter it out if the user does not want styling.
+    let csFlags = cs.kStructPhrase;
+    // Automatically find and link freetext URLs
+    if (!aMsg.noLinkification)
+      csFlags |= cs.kURLs;
 
-      <method name="onEvent">
-        <parameter name="aCommand"/>
-      </method>
+    if (aFirstUnread)
+      this.setUnreadRuler();
 
-      <!-- nsISelectionListener implementation -->
-      <method name="notifySelectionChanged">
-        <parameter name="aDocument"/>
-        <parameter name="aSelection"/>
-        <parameter name="aReason"/>
-        <body>
-          <![CDATA[
-            if (!(aReason & Ci.nsISelectionListener.MOUSEUP_REASON   ||
-                  aReason & Ci.nsISelectionListener.SELECTALL_REASON ||
-                  aReason & Ci.nsISelectionListener.KEYPRESS_REASON))
-              return; // we are still dragging, don't bother with the selection
+    // Right trim before displaying. This removes any OTR related
+    // whitespace when the extension isn't enabled.
+    let msg = aMsg.displayMessage.trimRight();
 
-            Cc["@mozilla.org/widget/clipboardhelper;1"]
-              .getService(Ci.nsIClipboardHelper)
-              .copyStringToClipboard(serializeSelection(aSelection),
-                                             Ci.nsIClipboard.kSelectionClipboard);
-          ]]>
-        </body>
-      </method>
+    // The slash of a leading '/me' should not be used to
+    // format as italic, so we remove the '/me' text before
+    // scanning the HTML, and we add it back later.
+    let meRegExp = /^((<[^>]+>)*)\/me /;
+    let me = false;
+    if (meRegExp.test(msg)) {
+      me = true;
+      msg = msg.replace(meRegExp, "$1");
+    }
 
-      <method name="_exposeMethodsToContent">
-        <body>
-          <![CDATA[
-            // Expose scrollToElement and autoScrollEnabled to the
-            // message styles.
-            this.contentWindow.scrollToElement = this._scrollToElement.bind(this);
-            this.contentWindow.autoScrollEnabled = this.autoScrollEnabled.bind(this);
-          ]]>
-        </body>
-      </method>
+    msg = cs.scanHTML(msg.replace(/&/g, "FROM-DTD-amp"), csFlags)
+      .replace(/FROM-DTD-amp/g, "&");
 
-      <!-- nsIWebProgressListener implementation -->
-      <method name="onStateChange">
-        <parameter name="aProgress"/>
-        <parameter name="aRequest"/>
-        <parameter name="aStateFlags"/>
-        <parameter name="aStatus"/>
-        <body>
-          <![CDATA[
-            const WPL = Ci.nsIWebProgressListener;
-            if ((aStateFlags & WPL.STATE_IS_DOCUMENT) &&
-                (aStateFlags & WPL.STATE_STOP)) {
-              if (!this._loadState) {
-                try {
-                  initHTMLDocument(this._conv, this.theme, this.contentDocument);
-                } catch(e) {
-                  Cu.reportError(e);
-                }
-                this._loadState = 1;
+    if (me)
+      msg = msg.replace(/^((<[^>]+>)*)/, "$1/me ");
 
-                this._exposeMethodsToContent();
+    aMsg.message = cleanupImMarkup(msg.replace(/\r?\n/g, "<br/>"),
+      null, this._textModifiers);
 
-                // 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. See bug 1161930.
-                this.contentWindow.document.documentElement.setAttribute("disablefastfind", "true");
-                return;
-              }
-              this.removeProgressListener(this);
-
-              this.initMagicCopy();
-
-              // We need to reset these variables here to avoid a race
-              // condition if we are starting to display a new conversation
-              // but the display of the previous conversation wasn't finished.
-              // This can happen if the user quickly changes the selected
-              // conversation in the log viewer.
-              this._lastMessage = null;
-              this._lastMessageIsContext = true;
-              this._firstNonContextElt = null;
-              this._messageDisplayPending = false;
-              this._pendingMessages = [];
-              this._nextPendingMessageIndex = 0;
-              this._pendingMessagesDisplayed = 0;
-              this._displayPendingMessagesCalls = 0;
-              this._sessions = [];
-              if (this.progressBar)
-                this.progressBar.hidden = true;
+    let next = (aContext == this._lastMessageIsContext || aMsg.system) &&
+      isNextMessage(this.theme, aMsg, this._lastMessage);
+    let newElt;
+    if (next && aFirstUnread) {
+      // If there wasn't an unread ruler, this would be a Next message.
+      // Therefore, save that version for later.
+      let html = getHTMLForMessage(aMsg, this.theme, next, aContext);
+      let ruler = doc.getElementById("unread-ruler");
+      ruler.nextMsgHtml = html;
+      ruler._originalMsg = aMsg;
 
-              this.contentChatNode
-                  .addEventListener("load", this.onContentElementLoad, true);
-
-              Services.obs.notifyObservers(this, "conversation-loaded");
-            }
-          ]]>
-        </body>
-      </method>
+      // Remember where the Next message(s) would have gone.
+      let insert = doc.getElementById("insert");
+      if (!insert) {
+        insert = doc.createElement("div");
+        ruler.parentNode.insertBefore(insert, ruler);
+      }
+      insert.id = "insert-before";
 
-      <method name="onProgressChange">
-        <parameter name="aProgress"/>
-        <parameter name="aRequest"/>
-        <parameter name="aCurSelf"/>
-        <parameter name="aMaxSelf"/>
-        <parameter name="aCurTotal"/>
-        <parameter name="aMaxTotal"/>
-      </method>
-
-      <method name="onLocationChange">
-        <parameter name="aProgress"/>
-        <parameter name="aRequest"/>
-        <parameter name="aLocation"/>
-      </method>
+      next = false;
+      html = getHTMLForMessage(aMsg, this.theme, next, aContext);
+      newElt = insertHTMLForMessage(aMsg, html, doc, next);
+      let marker = doc.createElement("div");
+      marker.id = "end-of-split-block";
+      newElt.parentNode.appendChild(marker);
 
-      <method name="onStatusChange">
-        <parameter name="aProgress"/>
-        <parameter name="aRequest"/>
-        <parameter name="aStatus"/>
-        <parameter name="aMessage"/>
-      </method>
-
-      <method name="onSecurityChange">
-        <parameter name="aProgress"/>
-        <parameter name="aRequest"/>
-        <parameter name="aState"/>
-      </method>
-
-
-      <!-- autoscroll stuff -->
-      <field name="_AUTOSCROLL_SPEED">3</field>
-      <field name="_AUTOSCROLL_SNAP">10</field>
-      <field name="_scrollingView">null</field>
-      <field name="_autoScrollTimer">null</field>
-      <field name="_startX">null</field>
-      <field name="_startY">null</field>
-      <field name="_screenX">null</field>
-      <field name="_screenY">null</field>
-      <field name="_autoScrollPopup">null</field>
-      <field name="_autoScrollNeedsCleanup">false</field>
+      // Bracket the place where additional Next messages will be added,
+      // if that's not after the end-of-split-block element.
+      insert = doc.getElementById("insert");
+      if (insert) {
+        marker = doc.createElement("div");
+        marker.id = "next-messages-start";
+        insert.parentNode.insertBefore(marker, insert);
+        marker = doc.createElement("div");
+        marker.id = "next-messages-end";
+        insert.parentNode.insertBefore(marker, insert.nextSibling);
+      }
+    } else {
+      let html = getHTMLForMessage(aMsg, this.theme, next, aContext);
+      newElt = insertHTMLForMessage(aMsg, html, doc, next);
+    }
 
-      <method name="stopScroll">
-        <body>
-          <![CDATA[
-            if (this._scrollingView) {
-              this._scrollingView = null;
-              window.removeEventListener("mousemove", this, true);
-              window.removeEventListener("mousedown", this, true);
-              window.removeEventListener("mouseup", this, true);
-              window.removeEventListener("contextmenu", this, true);
-              clearInterval(this._autoScrollTimer);
-            }
-         ]]>
-       </body>
-      </method>
+    if (!aNoAutoScroll) {
+      newElt.getBoundingClientRect(); // avoid ireflow bugs
+      if (this.convScrollEnabled())
+        this._scrollToElement(newElt);
+    }
+    this._lastElement = newElt;
+    this._lastMessage = aMsg;
+    if (!aContext && !this._firstNonContextElt && !aMsg.system)
+      this._firstNonContextElt = newElt;
+    this._lastMessageIsContext = aContext;
+  }
 
-      <method name="_createAutoScrollPopup">
-        <body>
-          <![CDATA[
-            const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
-            var popup = document.createElementNS(XUL_NS, "popup");
-            popup.className = "autoscroller";
-            return popup;
-          ]]>
-        </body>
-      </method>
+  setUnreadRuler() {
+    // Remove any existing ruler (occurs when the window has lost focus).
+    this.removeUnreadRuler();
+
+    let ruler = this.contentDocument.createElement("hr");
+    ruler.id = "unread-ruler";
+    this.contentChatNode.appendChild(ruler);
+  }
 
-      <method name="startScroll">
-        <parameter name="event"/>
-        <body>
-          <![CDATA[
-            if (!this._autoScrollPopup) {
-              if (this.hasAttribute("autoscrollpopup")) {
-                // our creator provided a popup to share
-                this._autoScrollPopup = document.getElementById(this.getAttribute("autoscrollpopup"));
-              }
-              else {
-                // we weren't provided a popup; we have to use the global scope
-                this._autoScrollPopup = this._createAutoScrollPopup();
-                document.documentElement.appendChild(this._autoScrollPopup);
-                this._autoScrollNeedsCleanup = true;
-              }
-            }
-
-            this._autoScrollPopup.addEventListener("popuphidden", this, true);
-
-            // we need these attributes so themers don't need to create per-platform packages
-            if (screen.colorDepth > 8) { // need high color for transparency
-              // Exclude second-rate platforms
-              this._autoScrollPopup.setAttribute("transparent", !/BeOS|OS\/2|Photon/.test(navigator.appVersion));
-              // Enable translucency on Windows and Mac
-              this._autoScrollPopup.setAttribute("translucent", /Win|Mac/.test(navigator.platform));
-            }
+  removeUnreadRuler() {
+    let doc = this.contentDocument;
+    let ruler = doc.getElementById("unread-ruler");
+    if (!ruler)
+      return;
 
-            this._scrollingView = event.originalTarget.ownerDocument.defaultView;
-            if (this._scrollingView.scrollMaxX > 0) {
-              this._autoScrollPopup.setAttribute("scrolldir", this._scrollingView.scrollMaxY > 0 ? "NSEW" : "EW");
-            }
-            else if (this._scrollingView.scrollMaxY > 0) {
-              this._autoScrollPopup.setAttribute("scrolldir", "NS");
-            }
-            else {
-              this._scrollingView = null; // abort scrolling
-              return;
-            }
+    // If a message block was split by the ruler, rejoin it.
+    let moveTo = doc.getElementById("insert-before");
+    if (moveTo) {
+      // Protect an existing insert node.
+      let actualInsert = doc.getElementById("insert");
+      if (actualInsert)
+        actualInsert.id = "actual-insert";
 
-            document.popupNode = null;
-            this._autoScrollPopup.openPopupAtScreen(event.screenX, event.screenY);
-            this._ignoreMouseEvents = true;
-            this._startX = event.screenX;
-            this._startY = event.screenY;
-            this._screenX = event.screenX;
-            this._screenY = event.screenY;
-
-            window.addEventListener("mousemove", this, true);
-            window.addEventListener("mousedown", this, true);
-            window.addEventListener("mouseup", this, true);
-            window.addEventListener("contextmenu", this, true);
-
-            this._scrollErrorX = 0;
-            this._scrollErrorY = 0;
+      // Add first message following the ruler as a Next type message.
+      // Replicates the relevant parts of insertHTMLForMessage().
+      let range = doc.createRange();
+      let moveToParent = moveTo.parentNode;
+      range.selectNode(moveToParent);
+      // eslint-disable-next-line no-unsanitized/method
+      let documentFragment = range.createContextualFragment(ruler.nextMsgHtml);
+      for (let root = documentFragment.firstChild; root; root = root.nextSibling)
+        root._originalMsg = ruler._originalMsg;
+      moveToParent.insertBefore(documentFragment, moveTo);
 
-            this._autoScrollTimer = setInterval(function(self) { self.autoScrollLoop(); },
-                                                20, this);
-         ]]>
-        </body>
-      </method>
-
-      <method name="_roundToZero">
-        <parameter name="num"/>
-        <body>
-          <![CDATA[
-            if (num > 0)
-              return Math.floor(num);
-            return Math.ceil(num);
-          ]]>
-        </body>
-      </method>
+      // If this added an insert node, insert the next messages there.
+      let insert = doc.getElementById("insert");
+      if (insert) {
+        moveTo.remove();
+        moveTo = insert;
+        moveToParent = moveTo.parentNode;
+      }
 
-      <method name="_accelerate">
-        <parameter name="curr"/>
-        <parameter name="start"/>
-        <body>
-          <![CDATA[
-            const kSpeed = 12;
-            var val = (curr - start) / kSpeed;
+      // Move remaining messages from the message block following the ruler.
+      let nextMessagesStart = doc.getElementById("next-messages-start");
+      if (nextMessagesStart) {
+        range = doc.createRange();
+        range.setStartAfter(nextMessagesStart);
+        range.setEndBefore(doc.getElementById("next-messages-end"));
+        moveToParent.insertBefore(range.extractContents(), moveTo);
+      }
+      moveTo.remove();
 
-            if (val > 1)
-              return val * Math.sqrt(val) - 1;
-            if (val < -1)
-              return val * Math.sqrt(-val) + 1;
-            return 0;
-          ]]>
-        </body>
-      </method>
-
-      <method name="autoScrollLoop">
-        <body>
-          <![CDATA[
-            var x = this._accelerate(this._screenX, this._startX);
-            var y = this._accelerate(this._screenY, this._startY);
+      // Restore existing insert node.
+      if (actualInsert)
+        actualInsert.id = "insert";
 
-            var desiredScrollX = this._scrollErrorX + x;
-            var actualScrollX = this._roundToZero(desiredScrollX);
-            this._scrollErrorX = (desiredScrollX - actualScrollX);
-            var desiredScrollY = this._scrollErrorY + y;
-            var actualScrollY = this._roundToZero(desiredScrollY);
-            this._scrollErrorY = (desiredScrollY - actualScrollY);
-
-            this._scrollingView.scrollBy(actualScrollX, actualScrollY);
-          ]]>
-        </body>
-      </method>
-
-      <method name="isAutoscrollBlocker">
-        <parameter name="node"/>
-        <body>
-          <![CDATA[
-            var mmPaste = false;
-            var mmScrollbarPosition = false;
+      // Delete surplus message block.
+      range = doc.createRange();
+      range.setStartAfter(ruler);
+      range.setEndAfter(doc.getElementById("end-of-split-block"));
+      range.deleteContents();
+    }
+    ruler.remove();
+  }
 
-            try {
-              mmPaste = Services.prefs.getBoolPref("middlemouse.paste");
-            }
-            catch (ex) {
-            }
-
-            try {
-              mmScrollbarPosition = Services.prefs.getBoolPref("middlemouse.scrollbarPosition");
-            }
-            catch (ex) {
-            }
+  _getSections() {
+    // If a section is displayed below this point, we assume not enough of
+    // it is visible, so we must scroll to it.
+    // The 3/4 constant is arbitrary, but it has to be greater than 1/2.
+    this._maximalSectionOffset = Math.round(this.clientHeight * 3 / 4);
 
-            while (node) {
-              if ((node instanceof HTMLAnchorElement || node instanceof HTMLAreaElement) && node.hasAttribute("href"))
-                return true;
-
-              if (mmPaste && (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement))
-                return true;
-
-              if (node instanceof XULElement && mmScrollbarPosition
-                  && (node.localName == "scrollbar" || node.localName == "scrollcorner"))
-                return true;
-
-              node = node.parentNode;
-            }
-            return false;
-          ]]>
-        </body>
-      </method>
+    // Get list of current section elements.
+    let sectionElements = [];
+    if (this._firstNonContextElt)
+      sectionElements.push(this._firstNonContextElt);
+    let ruler = this.contentDocument.getElementById("unread-ruler");
+    if (ruler)
+      sectionElements.push(ruler);
+    sectionElements = sectionElements.concat(this._sessions);
 
-      <method name="handleEvent">
-        <parameter name="aEvent"/>
-        <body>
-          <![CDATA[
-            if (this._scrollingView) {
-              switch(aEvent.type) {
-                case "mousemove": {
-                  this._screenX = aEvent.screenX;
-                  this._screenY = aEvent.screenY;
-
-                  var x = this._screenX - this._startX;
-                  var y = this._screenY - this._startY;
-
-                  if ((x > this._AUTOSCROLL_SNAP || x < -this._AUTOSCROLL_SNAP) ||
-                      (y > this._AUTOSCROLL_SNAP || y < -this._AUTOSCROLL_SNAP))
-                    this._ignoreMouseEvents = false;
-                  break;
-                }
-                case "mouseup":
-                case "mousedown":
-                case "contextmenu": {
-                  if (!this._ignoreMouseEvents)
-                    this._autoScrollPopup.hidePopup();
-                  this._ignoreMouseEvents = false;
-                  break;
-                }
-                case "popuphidden": {
-                  this._autoScrollPopup.removeEventListener("popuphidden", this, true);
-                  this.stopScroll();
-                  break;
-                }
-              }
-            }
-          ]]>
-        </body>
-      </method>
+    // Return ordered array of sections with entries
+    // [Y, scrollY such that Y is centered]
+    let sections = [];
+    let maxY = this.contentWindow.scrollMaxY;
+    for (let i = 0; i < sectionElements.length; ++i) {
+      let y = sectionElements[i].offsetTop;
+      // The section is unnecessary if close to top/bottom of conversation.
+      if (y < this._maximalSectionOffset || maxY < y)
+        continue;
+      sections.push([y, y - Math.round(this.clientHeight / 2)]);
+    }
+    sections.sort((a, b) => a[0] - b[0]);
+    return sections;
+  }
 
-      <method name="swapDocShells">
-        <parameter name="aOtherBrowser"/>
-        <body>
-        <![CDATA[
-          aOtherBrowser.destroy();
-
-          // Remove the progress listener.
-          this.removeProgressListener(this);
-
-          // We need to swap fields that are tied to our docshell or related to
-          // the loaded page.
-          // Fields which are built as a result of notifications (pageshow/hide,
-          // DOMLinkAdded/Removed, onStateChange) should not be swapped here,
-          // because these notifications are dispatched again once the docshells
-          // are swapped (pageshow/hide, DOMLinkAdded/Removed) or because we
-          // set the missing fields below (onStateChange).
-          var fieldsToSwap = ["_docShell", "_fastFind", "_contentWindow",
-                              "_webNavigation", "_theme", "_autoScrollEnabled",
-                              "_lastElement", "_lastMessage", "_lastMessageIsContext",
-                              "_firstNonContextElt"];
+  scrollToPreviousSection() {
+    let sections = this._getSections();
+    let y = this.contentWindow.scrollY;
+    let newY = 0;
+    for (let i = sections.length - 1; i >= 0; --i) {
+      let section = sections[i];
+      if (y > section[0]) {
+        newY = section[1];
+        break;
+      }
+    }
+    this.contentWindow.scrollTo(0, newY);
+  }
 
-          var ourFieldValues = {};
-          var otherFieldValues = {};
-          for (var field of fieldsToSwap) {
-            ourFieldValues[field] = this[field];
-            otherFieldValues[field] = aOtherBrowser[field];
-          }
-          this.swapFrameLoaders(aOtherBrowser);
-          for (var field of fieldsToSwap) {
-            this[field] = otherFieldValues[field];
-            aOtherBrowser[field] = ourFieldValues[field];
-          }
-
-          this._exposeMethodsToContent();
-
-          this.initMagicCopy();
-
-          // The listener from the other browser is gone and we need a new one.
-          this.contentChatNode
-              .addEventListener("load", this.onContentElementLoad, true);
-        ]]>
-        </body>
-      </method>
-    </implementation>
+  scrollToNextSection() {
+    let sections = this._getSections();
+    let y = this.contentWindow.scrollY;
+    let newY = this.contentWindow.scrollMaxY;
+    for (let i = 0; i < sections.length; ++i) {
+      let section = sections[i];
+      if (y + this._maximalSectionOffset < section[0]) {
+        newY = section[1];
+        break;
+      }
+    }
+    this.contentWindow.scrollTo(0, newY);
+  }
 
-    <handlers>
-      <handler event="click">
-        <![CDATA[
-          // Right click should open the context menu.
-          if (event.button == 2)
-            return;
-
-          // The 'click' event is fired even when the link is
-          // activated with the keyboard.
-
-          // The event target may be a descendant of the actual link.
-          let url;
-          for (let elem = event.target; elem; elem = elem.parentNode) {
-            if (elem instanceof HTMLAnchorElement) {
-              url = elem.href;
-              if (url)
-                break;
-            }
-          }
-          if (url) {
-            let uri = Services.io.newURI(url);
+  browserScroll(event) {
+    if (this._scrollingIntoView) {
+      // We have explicitly requested a scrollIntoView, ignore the event.
+      this._scrollingIntoView = false;
+      this._lastScrollHeight = this.scrollHeight;
+      this._lastScrollWidth = this.scrollWidth;
+      return;
+    }
 
-            // http and https are the only schemes that are both
-            // allowed by our IM filters and exposed.
-            if (!uri.schemeIs("http") && !uri.schemeIs("https"))
-              return;
-
-            event.preventDefault();
-            event.stopPropagation();
-
-            // loadURI can throw if the default browser is misconfigured.
-            Cc["@mozilla.org/uriloader/external-protocol-service;1"]
-              .getService(Ci.nsIExternalProtocolService)
-              .loadURI(uri);
-          }
-        ]]>
-      </handler>
-
-      <handler event="keypress" modifiers="shift" keycode="VK_PAGE_UP"
-               action="this.docShell.QueryInterface(Ci.nsITextScroll).scrollByPages(-1);"/>
-
-      <handler event="keypress" modifiers="shift" keycode="VK_PAGE_DOWN"
-               action="this.docShell.QueryInterface(Ci.nsITextScroll).scrollByPages(1);"/>
+    if (!("_lastScrollHeight" in this) ||
+      this._lastScrollHeight != this.scrollHeight ||
+      this._lastScrollWidth != this.scrollWidth) {
+      // Ensure scroll events triggered by a change of the
+      // content area size (eg. resizing the window or moving the
+      // textbox splitter) don't affect the auto-scroll behavior.
+      this._lastScrollHeight = this.scrollHeight;
+      this._lastScrollWidth = this.scrollWidth;
+    }
 
-      <handler event="keypress" modifiers="alt" keycode="VK_PAGE_UP"
-               action="this.scrollToPreviousSection();"/>
-
-      <handler event="keypress" modifiers="alt" keycode="VK_PAGE_DOWN"
-               action="this.scrollToNextSection();"/>
-
-      <handler event="keypress" keycode="VK_HOME"
-               action="this.scrollToPreviousSection(); event.preventDefault();"/>
+    // If images higher than one line of text load they will trigger a
+    // scroll event, which shouldn't disable auto-scroll while messages
+    // are being appended without being scrolled.
+    if (this._messageDisplayPending) {
+      return;
+    }
 
-      <handler event="keypress" keycode="VK_END"
-               action="this.scrollToNextSection(); event.preventDefault();"/>
-
-      <handler event="keypress" keycode="VK_F7" group="system">
-        <![CDATA[
-          if (event.defaultPrevented || !event.isTrusted)
-            return;
-
-          var isEnabled = Services.prefs.getBoolPref("accessibility.browsewithcaret_shortcut.enabled");
-          if (!isEnabled)
-            return;
+    // Enable or disable auto-scroll based on the scrollbar position.
+    this._updateConvScrollEnabled();
+  }
 
-          // Toggle browse with caret mode
-          try {
-            var browseWithCaretOn = Services.prefs.getBoolPref("accessibility.browsewithcaret");
-            Services.prefs.setBoolPref("accessibility.browsewithcaret", !browseWithCaretOn);
-          } catch (ex) {
-          }
-        ]]>
-      </handler>
+  browserResize(event) {
+    if (this._convScrollEnabled && this._lastElement) {
+      // The content area was resized and auto-scroll is enabled,
+      // make sure the last inserted element is still visible
+      this._scrollToElement(this._lastElement);
+    }
+  }
 
-      <handler event="mousedown" phase="capturing">
-        <![CDATA[
-          if (!this._scrollingView && event.button == 1) {
-            if (!this.autoscrollEnabled  ||
-                this.isAutoscrollBlocker(event.originalTarget))
-              return;
-
-            this.startScroll(event);
-          }
-        ]]>
-      </handler>
-    </handlers>
-  </binding>
-</bindings>
+  onContentElementLoad(event) {
+    if (event.target.localName == "img" &&
+      this._convScrollEnabled && !this._messageDisplayPending &&
+      this._lastElement) {
+      // An image loaded while auto-scroll is enabled and no further
+      // messages are currently being appended. So we need to scroll
+      // the last element fully back into view.
+      this._scrollToElement(this._lastElement);
+    }
+  }
+}
+customElements.define("conversation-browser", MozConversationBrowser, { extends: "browser" });
--- a/chat/content/jar.mn
+++ b/chat/content/jar.mn
@@ -5,10 +5,10 @@
 chat.jar:
 % content chat %content/chat/
 	content/chat/accounts.css
 *	content/chat/account.xml
 	content/chat/browserRequest.js
 	content/chat/browserRequest.xul
 	content/chat/imAccountOptionsHelper.js
 *	content/chat/imtooltip.xml
-	content/chat/convbrowser.xml
+	content/chat/conversation-browser.js
 	content/chat/conv.html
--- a/mail/base/content/customElements.js
+++ b/mail/base/content/customElements.js
@@ -2,15 +2,16 @@
  * 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";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 for (let script of [
+  "chrome://chat/content/conversation-browser.js",
   "chrome://messenger/content/mailWidgets.js",
   "chrome://messenger/content/generalBindings.js",
   "chrome://messenger/content/statuspanel.js",
   "chrome://messenger/content/foldersummary.js",
 ]) {
   Services.scriptloader.loadSubScript(script, window);
 }
--- a/mail/components/im/content/chat-messenger.inc.xul
+++ b/mail/components/im/content/chat-messenger.inc.xul
@@ -142,17 +142,17 @@
                       <hbox id="noPreviousConvBox" class="im-placeholder-box" align="top">
                         <vbox id="noPreviousConvInnerBox" class="im-placeholder-innerbox" flex="1">
                           <description id="noPreviousConvDesc"
                                        class="im-placeholder-desc">&chat.noPreviousConv.description;</description>
                         </vbox>
                       </hbox>
                     </vbox>
                     <vbox flex="1" id="logDisplayBrowserBox">
-                      <browser id="conv-log-browser" type="content" browser-type="conversation"
+                      <browser id="conv-log-browser" is="conversation-browser" type="content"
                                contextmenu="chatConversationContextMenu" flex="1"
                                tooltip="imTooltip"/>
                       <html:progress id="log-browserProgress" max="100" hidden="true"/>
                       <findbar id="log-findbar" browserid="conv-log-browser"/>
                     </vbox>
                   </deck>
                   <button id="goToConversation" hidden="true"
                           oncommand="chatHandler.showCurrentConversation();"/>
--- a/mail/components/im/content/chat-messenger.js
+++ b/mail/components/im/content/chat-messenger.js
@@ -396,17 +396,17 @@ var chatHandler = {
     document.getElementById("logDisplayDeck").selectedPanel =
       document.getElementById("logDisplayBrowserBox");
   },
   _showLog(aConversation, aSearchTerm) {
     if (!aConversation)
       return;
     this._showLogPanel();
     let browser = document.getElementById("conv-log-browser");
-    browser._autoScrollEnabled = false;
+    browser._convScrollEnabled = false;
     if (this._pendingLogBrowserLoad) {
       browser._conv = aConversation;
       return;
     }
     browser.init(aConversation);
     this._pendingLogBrowserLoad = true;
     if (aSearchTerm)
       this._pendingSearchTerm = aSearchTerm;
--- a/mail/components/im/content/chat.css
+++ b/mail/components/im/content/chat.css
@@ -38,20 +38,16 @@ imconv {
 .convUnreadTargetedCount[value="0"] {
   display: none;
 }
 
 tooltip[type="im"] {
   -moz-binding: url("chrome://chat/content/imtooltip.xml#tooltip");
 }
 
-browser[browser-type="conversation"] {
-  -moz-binding: url("chrome://chat/content/convbrowser.xml#browser");
-}
-
 #contextPaneFlexibleBox {
   overflow: hidden;
 }
 
 #contextPane:not([chat]) > vbox > .conv-chat {
   display: none;
 }
 
--- a/mail/components/im/content/imconversation.xml
+++ b/mail/components/im/content/imconversation.xml
@@ -16,17 +16,18 @@
           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"
+              <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">
@@ -1627,17 +1628,17 @@
        <getter>
          <![CDATA[
            return this._conv;
          ]]>
        </getter>
        <setter>
          <![CDATA[
            if (this._conv && val)
-             throw "Already initialized";
+             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;
--- a/mail/components/im/messages/bubbles/Footer.html
+++ b/mail/components/im/messages/bubbles/Footer.html
@@ -1,15 +1,15 @@
 <!-- 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/. -->
 
 <script type="application/javascript">
-// chat/content/convbrowser.xml
-/* globals autoScrollEnabled, scrollToElement */
+// See chat/content/conversation-browser.js _exposeMethodsToContent
+/* globals convScrollEnabled, scrollToElement */
 
 /* [pseudo_color, pseudo_background, bubble_borders] */
 const elements_lightness = [[75, 94, 80], [75, 94, 80], [70, 93, 75], [65, 92, 70], [55, 90, 65], [48, 90, 60], [44, 86, 50], [44, 88, 60], [45, 88, 70], [45, 90, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [45, 92, 70], [60, 92, 70], [70, 93, 75], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80], [75, 94, 80]];
 
 const bubble_background = "hsl(#, 100%, 97%)";
 const bubble_borders = "hsl(#, 100%, #%)";
 const pseudo_color = "hsl(#, 100%, #%)";
 const pseudo_background = "hsl(#, 100%, #%)";
@@ -117,17 +117,17 @@ function handleLastMessage(aUpdateTextOn
       p.style.lineHeight = (margin + shadow) + "px";
     p.setAttribute("class", "interval");
     text = prettyPrintTime(interval, true);
     margin = 0;
   }
   p.textContent = text;
   if (!aUpdateTextOnly) {
     p.style.marginTop = (margin - shadow) + "px";
-    if (autoScrollEnabled())
+    if (convScrollEnabled())
       scrollToElement(p);
   }
 
   var next = timebeforetextdisplay * 1000 - intervalInMs;
   if (next <= 0) {
     if (intervalInMs > kMsPerDay)
       next = kMsPerHour - intervalInMs % kMsPerHour;
     else
--- a/mail/components/preferences/aboutPreferences.xul
+++ b/mail/components/preferences/aboutPreferences.xul
@@ -37,16 +37,17 @@
                 src="chrome://messenger/locale/preferences/preferences.properties"/>
   <linkset>
     <link rel="localization" href="branding/brand.ftl"/>
     <link rel="localization" href="messenger/preferences/preferences.ftl"/>
     <link rel="localization" href="messenger/preferences/fonts.ftl"/>
     <link rel="localization" href="messenger/preferences/languages.ftl"/>
   </linkset>
 
+  <script type="application/javascript" src="chrome://messenger/content/customElements.js"/>
   <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/>
   <script type="application/javascript" src="chrome://communicator/content/contentAreaClick.js"/>
   <script type="application/javascript" src="chrome://messenger/content/preferences/preferences.js"/>
   <script type="application/javascript" src="chrome://messenger/content/preferences/subdialogs.js"/>
 
 #include general.inc.xul
 #include display.inc.xul
 #include compose.inc.xul
--- a/mail/components/preferences/chat.js
+++ b/mail/components/preferences/chat.js
@@ -45,21 +45,20 @@ var gChatPane = {
       return;
     }
     if (this.mTabBox.selectedIndex != 1) {
       return;
     }
 
     window.removeEventListener("paneSelected", this.paneSelectionChanged);
 
-    let browser = document.createElement("browser");
+    let browser = document.createElement("browser", { is: "conversation-browser" });
     browser.setAttribute("id", "previewbrowser");
     browser.setAttribute("type", "content");
     browser.setAttribute("flex", "1");
-    browser.setAttribute("style", "-moz-binding: url(chrome://chat/content/convbrowser.xml#browser)");
     browser.setAttribute("tooltip", "aHTMLTooltip");
     previewDeck.appendChild(browser);
     previewObserver.load();
   },
 
   updateDisabledState() {
     let checked = document.getElementById("messenger.status.reportIdle").value;
     document.querySelectorAll(".idle-reporting-enabled").forEach(e => {
--- a/mail/components/preferences/messagestyle.js
+++ b/mail/components/preferences/messagestyle.js
@@ -167,18 +167,18 @@ var previewObserver = {
     if (aTopic != "conversation-loaded" || aSubject != this.browser)
       return;
 
     // We want to avoid the convbrowser trying to scroll to the last
     // added message, as that causes the entire pref pane to jump up
     // (bug 1179943). Therefore, we override the method convbrowser
     // uses to determine if it should scroll, as well as its
     // mirror in the contentWindow (that messagestyle JS can call).
-    this.browser.autoScrollEnabled = () => false;
-    this.browser.contentWindow.autoScrollEnabled = () => false;
+    this.browser.convScrollEnabled = () => false;
+    this.browser.contentWindow.convScrollEnabled = () => false;
 
     // Display all queued messages. Use a timeout so that message text
     // modifiers can be added with observers for this notification.
     setTimeout(function() {
       for (let message of previewObserver.conv.messages)
         aSubject.appendMessage(message, false);
     }, 0);