Bug 1547835 - Show release notes on HTML about:addons details r=aswan,flod,rpl,kmag
authorMark Striemer <mstriemer@mozilla.com>
Wed, 15 May 2019 19:27:37 +0000
changeset 474102 b7d165b8966e6a9ca0576fa6ee7c49a52d604d4c
parent 474101 6d47ff50d4ac244474605754fab7a39bb3180b40
child 474103 2925cfa3e8c3577f427c28e1c36ee6fb69393b94
push id36022
push userncsoregi@mozilla.com
push dateThu, 16 May 2019 21:55:16 +0000
treeherdermozilla-central@96802be91766 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, flod, rpl, kmag
bugs1547835
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1547835 - Show release notes on HTML about:addons details r=aswan,flod,rpl,kmag Differential Revision: https://phabricator.services.mozilla.com/D30428
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/aboutaddonsCommon.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/content/extensions.xml
toolkit/mozapps/extensions/content/updateinfo.xsl
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/jar.mn
toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
toolkit/mozapps/extensions/test/browser/browser_html_updates.js
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -384,16 +384,18 @@ remove-addon-button = Remove
 disable-addon-button = Disable
 enable-addon-button = Enable
 expand-addon-button = More Options
 preferences-addon-button =
     { PLATFORM() ->
         [windows] Options
        *[other] Preferences
     }
+details-addon-button = Details
+release-notes-addon-button = Release Notes
 
 addons-enabled-heading = Enabled
 addons-disabled-heading = Disabled
 
 ask-to-activate-button = Ask to Activate
 always-activate-button = Always Activate
 never-activate-button = Never Activate
 
@@ -442,8 +444,11 @@ install-update-button = Update
 addon-badge-private-browsing-allowed =
     .title = Allowed in private windows
 addon-detail-private-browsing-help = When allowed, the extension will have access to your online activities while private browsing. <a data-l10n-name="learn-more">Learn more</a>
 addon-detail-private-browsing-allow = Allow
 addon-detail-private-browsing-disallow = Don’t Allow
 
 available-updates-heading = Available Updates
 recent-updates-heading = Recent Updates
+
+release-notes-loading = Loading…
+release-notes-error = Sorry, but there was an error loading the release notes.
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -356,8 +356,18 @@ panel-item-separator[hidden] {
   color: var(--in-content-link-color-hover) !important;
   text-decoration: underline;
 }
 
 .button-link:active {
   color: var(--in-content-link-color-active) !important;
   text-decoration: none;
 }
+
+.deck-tab-group {
+  border-bottom: 1px solid var(--grey-90-a20);
+  border-top: 1px solid var(--grey-90-a20);
+  margin-top: 8px;
+  /* Pull the buttons flush with the side of the card */
+  margin-inline: calc(var(--card-padding) * -1);
+  font-size: 0;
+  line-height: 0;
+}
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -80,80 +80,89 @@
       </div>
       <div class="disco-description-statistics">
         <five-star-rating></five-star-rating>
         <span class="disco-user-count"></span>
       </div>
     </template>
 
     <template name="addon-details">
-      <div class="addon-detail-description"></div>
-      <div class="addon-detail-contribute">
-        <label data-l10n-id="detail-contributions-description"></label>
-        <button
-          class="addon-detail-contribute-button"
-          action="contribute"
-          data-l10n-id="detail-contributions-button"
-          data-l10n-attrs="accesskey">
-        </button>
-      </div>
-      <div class="addon-detail-row addon-detail-row-updates">
-        <label data-l10n-id="addon-detail-updates-label"></label>
-        <div>
-          <button class="button-link" data-l10n-id="addon-detail-update-check-label" action="update-check" hidden></button>
-          <label>
-            <input type="radio" name="autoupdate" value="1"/>
-            <span data-l10n-id="addon-detail-updates-radio-default"></span>
-          </label>
-          <label>
-            <input type="radio" name="autoupdate" value="2"/>
-            <span data-l10n-id="addon-detail-updates-radio-on"></span>
-          </label>
-          <label>
-            <input type="radio" name="autoupdate" value="0"/>
-            <span data-l10n-id="addon-detail-updates-radio-off"></span>
-          </label>
-        </div>
+      <div class="deck-tab-group">
+        <named-deck-button deck="details-deck" name="details" data-l10n-id="details-addon-button"></named-deck-button>
+        <named-deck-button deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button"></named-deck-button>
       </div>
-      <div class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing">
-        <label data-l10n-id="detail-private-browsing-label"></label>
-        <div>
-          <label>
-            <input type="radio" name="private-browsing" value="1"/>
-            <span data-l10n-id="addon-detail-private-browsing-allow"></span>
-          </label>
-          <label>
-            <input type="radio" name="private-browsing" value="0"/>
-            <span data-l10n-id="addon-detail-private-browsing-disallow"></span>
-          </label>
-        </div>
-      </div>
-      <div class="addon-detail-row addon-detail-help-row" data-l10n-id="addon-detail-private-browsing-help">
-        <a target="_blank" data-l10n-name="learn-more"></a>
-      </div>
-      <div class="addon-detail-row addon-detail-row-author">
-        <label data-l10n-id="addon-detail-author-label"></label>
-      </div>
-      <div class="addon-detail-row addon-detail-row-version">
-        <label data-l10n-id="addon-detail-version-label"></label>
-      </div>
-      <div class="addon-detail-row addon-detail-row-lastUpdated">
-        <label data-l10n-id="addon-detail-last-updated-label"></label>
-      </div>
-      <div class="addon-detail-row addon-detail-row-homepage">
-        <label data-l10n-id="addon-detail-homepage-label"></label>
-        <a target="_blank"></a>
-      </div>
-      <div class="addon-detail-row addon-detail-row-rating">
-        <label data-l10n-id="addon-detail-rating-label"></label>
-        <div class="addon-detail-rating">
-          <five-star-rating></five-star-rating>
-          <a target="_blank"></a>
-        </div>
-      </div>
+      <named-deck id="details-deck">
+        <section name="details">
+          <div class="addon-detail-description"></div>
+          <div class="addon-detail-contribute">
+            <label data-l10n-id="detail-contributions-description"></label>
+            <button
+              class="addon-detail-contribute-button"
+              action="contribute"
+              data-l10n-id="detail-contributions-button"
+              data-l10n-attrs="accesskey">
+            </button>
+          </div>
+          <div class="addon-detail-row addon-detail-row-updates">
+            <label data-l10n-id="addon-detail-updates-label"></label>
+            <div>
+              <button class="button-link" data-l10n-id="addon-detail-update-check-label" action="update-check" hidden></button>
+              <label>
+                <input type="radio" name="autoupdate" value="1"/>
+                <span data-l10n-id="addon-detail-updates-radio-default"></span>
+              </label>
+              <label>
+                <input type="radio" name="autoupdate" value="2"/>
+                <span data-l10n-id="addon-detail-updates-radio-on"></span>
+              </label>
+              <label>
+                <input type="radio" name="autoupdate" value="0"/>
+                <span data-l10n-id="addon-detail-updates-radio-off"></span>
+              </label>
+            </div>
+          </div>
+          <div class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing">
+            <label data-l10n-id="detail-private-browsing-label"></label>
+            <div>
+              <label>
+                <input type="radio" name="private-browsing" value="1"/>
+                <span data-l10n-id="addon-detail-private-browsing-allow"></span>
+              </label>
+              <label>
+                <input type="radio" name="private-browsing" value="0"/>
+                <span data-l10n-id="addon-detail-private-browsing-disallow"></span>
+              </label>
+            </div>
+          </div>
+          <div class="addon-detail-row addon-detail-help-row" data-l10n-id="addon-detail-private-browsing-help">
+            <a target="_blank" data-l10n-name="learn-more"></a>
+          </div>
+          <div class="addon-detail-row addon-detail-row-author">
+            <label data-l10n-id="addon-detail-author-label"></label>
+          </div>
+          <div class="addon-detail-row addon-detail-row-version">
+            <label data-l10n-id="addon-detail-version-label"></label>
+          </div>
+          <div class="addon-detail-row addon-detail-row-lastUpdated">
+            <label data-l10n-id="addon-detail-last-updated-label"></label>
+          </div>
+          <div class="addon-detail-row addon-detail-row-homepage">
+            <label data-l10n-id="addon-detail-homepage-label"></label>
+            <a target="_blank"></a>
+          </div>
+          <div class="addon-detail-row addon-detail-row-rating">
+            <label data-l10n-id="addon-detail-rating-label"></label>
+            <div class="addon-detail-rating">
+              <five-star-rating></five-star-rating>
+              <a target="_blank"></a>
+            </div>
+          </div>
+        </section>
+        <update-release-notes name="release-notes"></update-release-notes>
+      </named-deck>
     </template>
 
     <template name="five-star-rating">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/rating-star.css">
       <span class="rating-star"></span>
       <span class="rating-star"></span>
       <span class="rating-star"></span>
       <span class="rating-star"></span>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -679,30 +679,133 @@ class FiveStarRating extends HTMLElement
     }
     document.l10n.setAttributes(this, "five-star-rating", {
       rating: this.rating,
     });
   }
 }
 customElements.define("five-star-rating", FiveStarRating);
 
+class UpdateReleaseNotes extends HTMLElement {
+  connectedCallback() {
+    this.addEventListener("click", this);
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener("click", this);
+  }
+
+  handleEvent(e) {
+    // We used to strip links, but ParserUtils.parseFragment() leaves them in,
+    // so just make sure we open them using the null principal in a new tab.
+    if (e.type == "click" && e.target.localName == "a" && e.target.href) {
+      e.preventDefault();
+      e.stopPropagation();
+      windowRoot.ownerGlobal.openWebLinkIn(e.target.href, "tab");
+    }
+  }
+
+  async loadForUri(uri) {
+    // Can't load the release notes without a URL to load.
+    if (!uri || !uri.spec) {
+      this.setErrorMessage();
+      this.dispatchEvent(new CustomEvent("release-notes-error"));
+      return;
+    }
+
+    // Don't try to load for the same update a second time.
+    if (this.url == uri.spec) {
+      this.dispatchEvent(new CustomEvent("release-notes-cached"));
+      return;
+    }
+
+    // Store the URL to skip the network if loaded again.
+    this.url = uri.spec;
+
+    // Set the loading message before hitting the network.
+    this.setLoadingMessage();
+    this.dispatchEvent(new CustomEvent("release-notes-loading"));
+
+    try {
+      // loadReleaseNotes will fetch and sanitize the release notes.
+      let fragment = await loadReleaseNotes(uri);
+      this.textContent = "";
+      this.appendChild(fragment);
+      this.dispatchEvent(new CustomEvent("release-notes-loaded"));
+    } catch (e) {
+      this.setErrorMessage();
+      this.dispatchEvent(new CustomEvent("release-notes-error"));
+    }
+  }
+
+  setMessage(id) {
+    this.textContent = "";
+    let message = document.createElement("p");
+    document.l10n.setAttributes(message, id);
+    this.appendChild(message);
+  }
+
+  setLoadingMessage() {
+    this.setMessage("release-notes-loading");
+  }
+
+  setErrorMessage() {
+    this.setMessage("release-notes-error");
+  }
+}
+customElements.define("update-release-notes", UpdateReleaseNotes);
+
+
 class AddonDetails extends HTMLElement {
   connectedCallback() {
     if (this.children.length == 0) {
       this.render();
     }
+    this.deck.addEventListener("view-changed", this);
+  }
+
+  disconnectedCallback() {
+    this.deck.removeEventListener("view-changed", this);
+  }
+
+  handleEvent(e) {
+    if (e.type == "view-changed" && e.target == this.deck) {
+      if (this.deck.selectedViewName == "release-notes") {
+        let releaseNotes = this.querySelector("update-release-notes");
+        let uri = this.releaseNotesUri;
+        if (uri) {
+          releaseNotes.loadForUri(uri);
+        }
+      }
+    }
+  }
+
+  get releaseNotesUri() {
+    return this.addon.updateInstall ?
+      this.addon.updateInstall.releaseNotesURI : this.addon.releaseNotesURI;
   }
 
   setAddon(addon) {
     this.addon = addon;
   }
 
   update() {
     let {addon} = this;
 
+    // Hide tab buttons that won't have any content.
+    let getButtonByName =
+      name => this.tabGroup.querySelector(`[name="${name}"]`);
+    let notesBtn = getButtonByName("release-notes");
+    notesBtn.hidden = !this.releaseNotesUri;
+
+    // Hide the tab group if "details" is the only visible button.
+    this.tabGroup.hidden = Array.from(this.tabGroup.children).every(button => {
+      return button.name == "details" || button.hidden;
+    });
+
     // Show the update check button if necessary. The button might not exist if
     // the add-on doesn't support updates.
     let updateButton = this.querySelector('[action="update-check"]');
     if (updateButton) {
       updateButton.hidden =
         this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon);
     }
 
@@ -717,16 +820,19 @@ class AddonDetails extends HTMLElement {
     let {addon} = this;
     if (!addon) {
       throw new Error("addon-details must be initialized by setAddon");
     }
 
     this.textContent = "";
     this.appendChild(importTemplate("addon-details"));
 
+    this.deck = this.querySelector("named-deck");
+    this.tabGroup = this.querySelector(".deck-tab-group");
+
     // Full description.
     let description = this.querySelector(".addon-detail-description");
     if (addon.getFullDescription) {
       description.appendChild(addon.getFullDescription(document));
     } else if (addon.fullDescription) {
       description.appendChild(nl2br(addon.fullDescription));
     }
 
--- a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
+++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
@@ -1,16 +1,17 @@
 /* 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/. */
 /* eslint max-len: ["error", 80] */
 
 "use strict";
 
-/* exported attachUpdateHandler, getBrowserElement, openOptionsInTab */
+/* exported attachUpdateHandler, getBrowserElement, loadReleaseNotes,
+ * openOptionsInTab */
 
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "WEBEXT_PERMISSION_PROMPTS",
   "extensions.webextPermissionPrompts", false);
 
@@ -60,16 +61,39 @@ function attachUpdateHandler(install) {
           },
         },
       };
       Services.obs.notifyObservers(subject, "webextension-permission-prompt");
     });
   };
 }
 
+async function loadReleaseNotes(uri) {
+  const res = await fetch(uri.spec, {credentials: "omit"});
+
+  if (!res.ok) {
+    throw new Error("Error loading release notes");
+  }
+
+  // Load the content.
+  const text = await res.text();
+
+  // Setup the content sanitizer.
+  const ParserUtils = Cc["@mozilla.org/parserutils;1"]
+    .getService(Ci.nsIParserUtils);
+  const flags =
+    ParserUtils.SanitizerDropMedia |
+    ParserUtils.SanitizerDropNonCSSPresentation |
+    ParserUtils.SanitizerDropForms;
+
+  // Sanitize and parse the content to a fragment.
+  const context = document.createElement("div");
+  return ParserUtils.parseFragment(text, flags, false, uri, context);
+}
+
 function openOptionsInTab(optionsURL) {
   let mainWindow = window.windowRoot.ownerGlobal;
   if ("switchToTabHavingURI" in mainWindow) {
     mainWindow.switchToTabHavingURI(optionsURL, true, {
       relatedToCurrent: true,
       triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
     });
     return true;
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -2,17 +2,17 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 /* import-globals-from ../../../content/contentAreaUtils.js */
 /* import-globals-from aboutaddonsCommon.js */
 /* globals ProcessingInstruction */
-/* exported UPDATES_RELEASENOTES_TRANSFORMFILE, XMLURI_PARSE_ERROR, loadView, gBrowser */
+/* exported gBrowser, loadView */
 
 const {DeferredTask} = ChromeUtils.import("resource://gre/modules/DeferredTask.jsm");
 const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 const {AddonRepository} = ChromeUtils.import("resource://gre/modules/addons/AddonRepository.jsm");
 const {AddonSettings} = ChromeUtils.import("resource://gre/modules/addons/AddonSettings.jsm");
 
 ChromeUtils.defineModuleGetter(this, "AMTelemetry",
                                "resource://gre/modules/AddonManager.jsm");
@@ -54,19 +54,16 @@ const PREF_GETADDONS_CACHE_ID_ENABLED = 
 const PREF_UI_TYPE_HIDDEN = "extensions.ui.%TYPE%.hidden";
 const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
 const PREF_LEGACY_EXCEPTIONS = "extensions.legacy.exceptions";
 const PREF_LEGACY_ENABLED = "extensions.legacy.enabled";
 
 const LOADING_MSG_DELAY = 100;
 
 const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
-const UPDATES_RELEASENOTES_TRANSFORMFILE = "chrome://mozapps/content/extensions/updateinfo.xsl";
-
-const XMLURI_PARSE_ERROR = "http://www.mozilla.org/newlayout/xml/parsererror.xml";
 
 var gViewDefault = "addons://discover/";
 
 XPCOMUtils.defineLazyGetter(this, "extensionStylesheets", () => {
   const {ExtensionParent} = ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
   return ExtensionParent.extensionStylesheets;
 });
 
--- a/toolkit/mozapps/extensions/content/extensions.xml
+++ b/toolkit/mozapps/extensions/content/extensions.xml
@@ -1177,79 +1177,34 @@
             this.dispatchEvent(event);
           };
 
           if (!aURI || this._relNotesLoaded) {
             sendToggleEvent();
             return;
           }
 
-          var relNotesData = null, transformData = null;
-
           this._relNotesLoaded = true;
           this._relNotesLoading.hidden = false;
           this._relNotesError.hidden = true;
 
-          let showRelNotes = () => {
-            if (!relNotesData || !transformData)
-              return;
-
+          loadReleaseNotes(aURI).then(fragment => {
             this._relNotesLoading.hidden = true;
-
-            var processor = new XSLTProcessor();
-            processor.flags |= XSLTProcessor.DISABLE_ALL_LOADS;
-
-            processor.importStylesheet(transformData);
-            var fragment = processor.transformToFragment(relNotesData, document);
             this._relNotes.appendChild(fragment);
             if (this.hasAttribute("show-relnotes")) {
               var container = this._relNotesContainer;
               container.style.height = container.scrollHeight + "px";
             }
             sendToggleEvent();
-          };
-
-          let handleError = () => {
-            dataReq.abort();
-            styleReq.abort();
+          }, () => {
             this._relNotesLoading.hidden = true;
             this._relNotesError.hidden = false;
             this._relNotesLoaded = false; // allow loading to be re-tried
             sendToggleEvent();
-          };
-
-          function handleResponse(aEvent) {
-            var req = aEvent.target;
-            var ct = req.getResponseHeader("content-type");
-            if ((!ct || !ct.includes("text/html")) &&
-                req.responseXML &&
-                req.responseXML.documentElement.namespaceURI != XMLURI_PARSE_ERROR) {
-              if (req == dataReq)
-                relNotesData = req.responseXML;
-              else
-                transformData = req.responseXML;
-              showRelNotes();
-            } else {
-              handleError();
-            }
-          }
-
-          var dataReq = new XMLHttpRequest({mozAnon: true});
-          dataReq.open("GET", aURI.spec, true);
-          dataReq.responseType = "document";
-          dataReq.addEventListener("load", handleResponse);
-          dataReq.addEventListener("error", handleError);
-          dataReq.send(null);
-
-          var styleReq = new XMLHttpRequest({mozAnon: true});
-          styleReq.open("GET", UPDATES_RELEASENOTES_TRANSFORMFILE, true);
-          styleReq.responseType = "document";
-          styleReq.addEventListener("load", handleResponse);
-          styleReq.addEventListener("error", handleError);
-          styleReq.send(null);
+          });
         ]]></body>
       </method>
 
       <method name="toggleReleaseNotes">
         <body><![CDATA[
           if (this.hasAttribute("show-relnotes")) {
             this._relNotesContainer.style.height = "0px";
             this.removeAttribute("show-relnotes");
@@ -1512,16 +1467,22 @@
 
     <handlers>
       <handler event="click" button="0"><![CDATA[
         if (!["button", "checkbox", "menulist", "menuitem"].includes(event.originalTarget.localName) &&
             !event.originalTarget.classList.contains("text-link") &&
             // Treat the relnotes container as embedded text instead of a click target.
             !event.originalTarget.closest(".relnotes-container")) {
           this.showInDetailView();
+        } else if (event.originalTarget.localName == "a" &&
+                   event.originalTarget.closest(".relnotes-container") &&
+                   event.originalTarget.href) {
+          event.preventDefault();
+          event.stopPropagation();
+          openURL(event.originalTarget.href);
         }
       ]]></handler>
     </handlers>
   </binding>
 
 
   <!-- Addon - uninstalled - An uninstalled addon that can be re-installed. -->
   <binding id="addon-uninstalled"
deleted file mode 100644
--- a/toolkit/mozapps/extensions/content/updateinfo.xsl
+++ /dev/null
@@ -1,41 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- This Source Code Form is subject to the terms of the Mozilla Public
-   - License, v. 2.0. If a copy of the MPL was not distributed with this
-   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
-
-<xsl:stylesheet version="1.0" xmlns:xhtml="http://www.w3.org/1999/xhtml"
-                              xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
-
-  <!-- Any elements not otherwise specified will be stripped but the contents
-       will be displayed. All attributes are stripped from copied elements. -->
-
-  <!-- Block these elements and their contents -->
-  <xsl:template match="xhtml:head|xhtml:script|xhtml:style">
-  </xsl:template>
-
-  <!-- Allowable styling elements -->
-  <xsl:template match="xhtml:b|xhtml:i|xhtml:em|xhtml:strong|xhtml:u|xhtml:q|xhtml:sub|xhtml:sup|xhtml:code">
-    <xsl:copy><xsl:apply-templates/></xsl:copy>
-  </xsl:template>
-
-  <!-- Allowable block formatting elements -->
-  <xsl:template match="xhtml:h1|xhtml:h2|xhtml:h3|xhtml:p|xhtml:div|xhtml:blockquote|xhtml:pre">
-    <xsl:copy><xsl:apply-templates/></xsl:copy>
-  </xsl:template>
-
-  <!-- Allowable list formatting elements -->
-  <xsl:template match="xhtml:ul|xhtml:ol|xhtml:li|xhtml:dl|xhtml:dt|xhtml:dd">
-    <xsl:copy><xsl:apply-templates/></xsl:copy>
-  </xsl:template>
-
-  <!-- These elements are copied and their contents dropped -->
-  <xsl:template match="xhtml:br|xhtml:hr">
-    <xsl:copy/>
-  </xsl:template>
-
-  <!-- The root document -->
-  <xsl:template match="/">
-    <xhtml:body><xsl:apply-templates/></xhtml:body>
-  </xsl:template>
-  
-</xsl:stylesheet>
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -1095,16 +1095,18 @@ class AddonInstall {
    *        Optional icons for the add-on
    * @param {string} [options.version]
    *        An optional version for the add-on
    * @param {Object?} [options.telemetryInfo]
    *        An optional object which provides details about the installation source
    *        included in the addon manager telemetry events.
    * @param {boolean} [options.isUserRequestedUpdate]
    *        An optional boolean, true if the install object is related to a user triggered update.
+   * @param {nsIURL} [options.releaseNotesURI]
+   *        An optional nsIURL that release notes where release notes can be retrieved.
    * @param {function(string) : Promise<void>} [options.promptHandler]
    *        A callback to prompt the user before installing.
    */
   constructor(installLocation, url, options = {}) {
     this.wrapper = new AddonInstallWrapper(this);
     this.location = installLocation;
     this.sourceURI = url;
 
@@ -1113,17 +1115,17 @@ class AddonInstall {
       this.originalHash = {
         algorithm: hashSplit[0],
         data: hashSplit[1],
       };
     }
     this.hash = this.originalHash;
     this.existingAddon = options.existingAddon || null;
     this.promptHandler = options.promptHandler || (() => Promise.resolve());
-    this.releaseNotesURI = null;
+    this.releaseNotesURI = options.releaseNotesURI || null;
 
     this._startupPromise = null;
 
     this._installPromise = new Promise(resolve => {
       this._resolveInstallPromise = resolve;
     });
     // Ignore uncaught rejections for this promise, since they're
     // handled by install listeners.
@@ -2281,29 +2283,30 @@ function createUpdate(aCallback, aAddon,
       existingAddon: aAddon,
       name: aAddon.selectedLocale.name,
       type: aAddon.type,
       icons: aAddon.icons,
       version: aUpdate.version,
       isUserRequestedUpdate: isUserRequested,
     };
 
+    try {
+      if (aUpdate.updateInfoURL)
+        opts.releaseNotesURI = Services.io.newURI(escapeAddonURI(aAddon, aUpdate.updateInfoURL));
+    } catch (e) {
+      // If the releaseNotesURI cannot be parsed then just ignore it.
+    }
+
     let install;
     if (url instanceof Ci.nsIFileURL) {
       install = new LocalAddonInstall(aAddon.location, url, opts);
       await install.init();
     } else {
       install = new DownloadAddonInstall(aAddon.location, url, opts);
     }
-    try {
-      if (aUpdate.updateInfoURL)
-        install.releaseNotesURI = Services.io.newURI(escapeAddonURI(aAddon, aUpdate.updateInfoURL));
-    } catch (e) {
-      // If the releaseNotesURI cannot be parsed then just ignore it.
-    }
 
     aCallback(install);
   })();
 }
 
 // Maps instances of AddonInstall to AddonInstallWrapper
 const wrapperMap = new WeakMap();
 let installFor = wrapper => wrapperMap.get(wrapper);
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -7,17 +7,16 @@ toolkit.jar:
   content/mozapps/extensions/shortcuts.html                     (content/shortcuts.html)
   content/mozapps/extensions/shortcuts.css                      (content/shortcuts.css)
   content/mozapps/extensions/shortcuts.js                       (content/shortcuts.js)
 #ifndef MOZ_FENNEC
 * content/mozapps/extensions/extensions.xul                     (content/extensions.xul)
   content/mozapps/extensions/extensions.css                     (content/extensions.css)
   content/mozapps/extensions/extensions.js                      (content/extensions.js)
 * content/mozapps/extensions/extensions.xml                     (content/extensions.xml)
-  content/mozapps/extensions/updateinfo.xsl                     (content/updateinfo.xsl)
   content/mozapps/extensions/blocklist.xul                      (content/blocklist.xul)
   content/mozapps/extensions/blocklist.js                       (content/blocklist.js)
   content/mozapps/extensions/pluginPrefs.xul                    (content/pluginPrefs.xul)
   content/mozapps/extensions/pluginPrefs.js                     (content/pluginPrefs.js)
   content/mozapps/extensions/OpenH264-license.txt               (content/OpenH264-license.txt)
   content/mozapps/extensions/aboutaddons.html                   (content/aboutaddons.html)
   content/mozapps/extensions/aboutaddons.js                     (content/aboutaddons.js)
   content/mozapps/extensions/aboutaddonsCommon.js               (content/aboutaddonsCommon.js)
--- a/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
@@ -28,16 +28,17 @@ add_task(async function enableHtmlViews(
     type: "extension",
     updateDate: dateHoursAgo(9),
   }, {
     id: "addon-today-1@mochi.test",
     name: "Updated today",
     creator: {name: "The creator"},
     version: "3.1",
     type: "extension",
+    releaseNotesURI: "http://example.com/notes.txt",
     updateDate: dateHoursAgo(1),
   }, {
     id: "addon-yesterday-1@mochi.test",
     name: "Updated yesterday one",
     creator: {name: "The creator"},
     version: "3.3",
     type: "extension",
     updateDate: dateHoursAgo(15),
@@ -85,16 +86,38 @@ add_task(async function testRecentUpdate
 
   // Verify that the add-ons are in the right order.
   Assert.deepEqual(addonsInOrder(), [
     "addon-today-1@mochi.test", "addon-today-2@mochi.test",
     "addon-today-3@mochi.test", "addon-yesterday-1@mochi.test",
     "addon-yesterday-2@mochi.test",
   ], "The add-ons are in the right order");
 
+  info("Check that release notes are shown on the details page");
+  let card = list.querySelector(
+    'addon-card[addon-id="addon-today-1@mochi.test"]');
+  loaded = waitForViewLoad(win);
+  card.querySelector('[action="expand"]').click();
+  await loaded;
+
+  card = doc.querySelector("addon-card");
+  ok(card.expanded, "The card is expanded");
+  ok(!card.details.tabGroup.hidden, "The tabs are shown");
+  ok(!card.details.tabGroup.querySelector('[name="release-notes"]').hidden,
+     "The release notes button is shown");
+
+  info("Go back to the recent updates view");
+  loaded = waitForViewLoad(win);
+  managerDoc.getElementById("utils-viewUpdates").doCommand();
+  await loaded;
+
+  // Find the list again.
+  list = doc.querySelector("addon-list");
+
+  info("Install a new add-on, it should be first in the list");
   // Install a new extension.
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       name: "New extension",
       applications: {gecko: {id: "new@mochi.test"}},
     },
     useAddonManager: "temporary",
   });
@@ -105,17 +128,17 @@ add_task(async function testRecentUpdate
   // The new extension should now be at the top of the list.
   Assert.deepEqual(addonsInOrder(), [
     "new@mochi.test", "addon-today-1@mochi.test", "addon-today-2@mochi.test",
     "addon-today-3@mochi.test", "addon-yesterday-1@mochi.test",
     "addon-yesterday-2@mochi.test",
   ], "The new add-on went to the top");
 
   // Open the detail view for the new add-on.
-  let card = list.querySelector('addon-card[addon-id="new@mochi.test"]');
+  card = list.querySelector('addon-card[addon-id="new@mochi.test"]');
   loaded = waitForViewLoad(win);
   card.querySelector('[action="expand"]').click();
   await loaded;
 
   is(win.managerWindow.gCategories.selected, "addons://list/extension",
      "The extensions category is selected");
 
   await closeView(win);
--- a/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -102,17 +102,17 @@ add_task(async function testChangeAutoUp
   updated = BrowserTestUtils.waitForEvent(card, "update");
   AddonManager.autoUpdateDefault = true;
   await updated;
 
   await closeView(win);
   await extension.unload();
 });
 
-async function setupExtensionWithUpdate(id) {
+async function setupExtensionWithUpdate(id, {releaseNotes} = {}) {
   await SpecialPowers.pushPrefEnv({
     set: [["extensions.checkUpdateSecurity", false]],
   });
 
   let server = AddonTestUtils.createHttpServer();
   let serverHost = `http://localhost:${server.identity.primaryPort}`;
   let updatesPath = `/ext-updates-${id}.json`;
 
@@ -127,24 +127,43 @@ async function setupExtensionWithUpdate(
   };
 
   let updateXpi = AddonTestUtils.createTempWebExtensionFile({
     manifest: {
       ...baseManifest,
       version: "2",
     },
   });
+
+  let releaseNotesExtra = {};
+  if (releaseNotes) {
+    let notesPath = "/notes.txt";
+    server.registerPathHandler(notesPath, (request, response) => {
+      if (releaseNotes == "ERROR") {
+        response.setStatusLine(null, 404, "Not Found");
+      } else {
+        response.setStatusLine(null, 200, "OK");
+        response.write(releaseNotes);
+      }
+      response.processAsync();
+      response.finish();
+    });
+    releaseNotesExtra.update_info_url = serverHost + notesPath;
+  }
+
   let xpiFilename = `/update-${id}.xpi`;
   server.registerFile(xpiFilename, updateXpi);
   AddonTestUtils.registerJSON(server, updatesPath, {
     addons: {
       [id]: {
-        updates: [
-          {version: "2", update_link: serverHost + xpiFilename},
-        ],
+        updates: [{
+          version: "2",
+          update_link: serverHost + xpiFilename,
+          ...releaseNotesExtra,
+        }],
       },
     },
   });
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       ...baseManifest,
       version: "1",
@@ -179,27 +198,36 @@ function checkForUpdate(card, expected) 
 function installUpdate(card, expected) {
   // Install the update.
   let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
   let updated = BrowserTestUtils.waitForEvent(card, "update");
   card.querySelector('panel-item[action="install-update"]').click();
   return Promise.all([updateInstalled, updated]);
 }
 
-function assertUpdateState({card, shown, expanded = true}) {
+function assertUpdateState({
+  card, shown, expanded = true, releaseNotes = false,
+}) {
   let menuButton = card.querySelector(".more-options-button");
   ok(menuButton.classList.contains("more-options-button-badged") == shown,
      "The menu button is badged");
   let installButton = card.querySelector('panel-item[action="install-update"]');
   ok(installButton.hidden != shown,
      `The install button is ${shown ? "hidden" : "shown"}`);
   if (expanded) {
     let updateCheckButton = card.querySelector('button[action="update-check"]');
     ok(updateCheckButton.hidden == shown,
       `The update check button is ${shown ? "hidden" : "shown"}`);
+
+    let {tabGroup} = card.details;
+    is(tabGroup.hidden, !releaseNotes,
+       `The tab group is ${releaseNotes ? "shown" : "hidden"}`);
+    let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+    is(notesBtn.hidden, !releaseNotes,
+       `The release notes button is ${releaseNotes ? "shown" : "hidden"}`);
   }
 }
 
 add_task(async function testUpdateAvailable() {
   let id = "update@mochi.test";
   let extension = await setupExtensionWithUpdate(id);
 
   let win = await loadInitialView("extension");
@@ -231,16 +259,167 @@ add_task(async function testUpdateAvaila
 
   // Check for updates again, there shouldn't be an update.
   await checkForUpdate(card, "no-update");
 
   await closeView(win);
   await extension.unload();
 });
 
+add_task(async function testReleaseNotesLoad() {
+  let id = "update-with-notes@mochi.test";
+  let extension = await setupExtensionWithUpdate(id, {
+    releaseNotes: `
+      <html xmlns="http://www.w3.org/1999/xhtml">
+        <head><link rel="stylesheet" href="remove-me.css"/></head>
+        <body>
+          <script src="no-scripts.js"></script>
+          <h1>My release notes</h1>
+          <img src="http://example.com/tracker.png"/>
+          <ul>
+            <li onclick="alert('hi')">A thing</li>
+          </ul>
+          <a href="http://example.com/">Go somewhere</a>
+        </body>
+      </html>
+    `,
+  });
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  await loadDetailView(win, id);
+
+  let card = doc.querySelector("addon-card");
+  let {deck, tabGroup} = card.details;
+
+  // Disable updates and then check.
+  disableAutoUpdates(card);
+  await checkForUpdate(card, "update-found");
+
+  // There should now be an update.
+  assertUpdateState({card, shown: true, releaseNotes: true});
+
+  info("Check release notes");
+  let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+  let notes = card.querySelector("update-release-notes");
+  let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+  let loaded = BrowserTestUtils.waitForEvent(notes, "release-notes-loaded");
+  // Don't use notesBtn.click() since it causes an assertion to fail.
+  // See bug 1551621 for more info.
+  EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+  await loading;
+  is(doc.l10n.getAttributes(notes.firstElementChild).id,
+     "release-notes-loading", "The loading message is shown");
+  await loaded;
+  info("Checking HTML release notes");
+  let [h1, ul, a] = notes.children;
+  is(h1.tagName, "H1", "There's a heading");
+  is(h1.textContent, "My release notes", "The heading has content");
+  is(ul.tagName, "UL", "There's a list");
+  is(ul.children.length, 1, "There's one item in the list");
+  let [li] = ul.children;
+  is(li.tagName, "LI", "There's a list item");
+  is(li.textContent, "A thing", "The text is set");
+  ok(!li.hasAttribute("onclick"), "The onclick was removed");
+  ok(!notes.querySelector("link"), "The link tag was removed");
+  ok(!notes.querySelector("script"), "The script tag was removed");
+  is(a.textContent, "Go somewhere", "The link text is preserved");
+  is(a.href, "http://example.com/", "The link href is preserved");
+
+  info("Verify the link opened in a new tab");
+  let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, a.href);
+  a.click();
+  let tab = await tabOpened;
+  BrowserTestUtils.removeTab(tab);
+
+  let originalContent = notes.innerHTML;
+
+  info("Switch away and back to release notes");
+  // Load details view.
+  let detailsBtn = tabGroup.firstElementChild;
+  let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  detailsBtn.click();
+  await viewChanged;
+
+  // Load release notes again, verify they weren't loaded.
+  viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  let notesCached = BrowserTestUtils.waitForEvent(
+    notes, "release-notes-cached");
+  notesBtn.click();
+  await viewChanged;
+  await notesCached;
+  is(notes.innerHTML, originalContent, "The content didn't change");
+
+  info("Install the update to clean it up");
+  await installUpdate(card, "update-installed");
+
+  // There's no more update but release notes are still shown.
+  assertUpdateState({card, shown: false, releaseNotes: true});
+
+  await closeView(win);
+  await extension.unload();
+});
+
+add_task(async function testReleaseNotesError() {
+  let id = "update-with-notes-error@mochi.test";
+  let extension = await setupExtensionWithUpdate(id, {releaseNotes: "ERROR"});
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  await loadDetailView(win, id);
+
+  let card = doc.querySelector("addon-card");
+  let {deck, tabGroup} = card.details;
+
+  // Disable updates and then check.
+  disableAutoUpdates(card);
+  await checkForUpdate(card, "update-found");
+
+  // There should now be an update.
+  assertUpdateState({card, shown: true, releaseNotes: true});
+
+  info("Check release notes");
+  let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+  let notes = card.querySelector("update-release-notes");
+  let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+  let errored = BrowserTestUtils.waitForEvent(notes, "release-notes-error");
+  // Don't use notesBtn.click() since it causes an assertion to fail.
+  // See bug 1551621 for more info.
+  EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+  await loading;
+  is(doc.l10n.getAttributes(notes.firstElementChild).id,
+     "release-notes-loading", "The loading message is shown");
+  await errored;
+  is(doc.l10n.getAttributes(notes.firstElementChild).id,
+     "release-notes-error", "The error message is shown");
+
+  info("Switch away and back to release notes");
+  // Load details view.
+  let detailsBtn = tabGroup.firstElementChild;
+  let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  detailsBtn.click();
+  await viewChanged;
+
+  // Load release notes again, verify they weren't loaded.
+  viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  let notesCached = BrowserTestUtils.waitForEvent(
+    notes, "release-notes-cached");
+  notesBtn.click();
+  await viewChanged;
+  await notesCached;
+
+  info("Install the update to clean it up");
+  await installUpdate(card, "update-installed");
+
+  await closeView(win);
+  await extension.unload();
+});
+
 add_task(async function testUpdateCancelled() {
   let id = "update@mochi.test";
   let extension = await setupExtensionWithUpdate(id);
 
   let win = await loadInitialView("extension");
   let doc = win.document;
 
   await loadDetailView(win, "update@mochi.test");