Bug 1550911 - Show recommendations on extension and theme lists r=robwu,flod,jaws
authorMark Striemer <mstriemer@mozilla.com>
Tue, 04 Jun 2019 01:50:33 +0000
changeset 476705 502619bc75a86049e772eb9f85ffd1c32aad3a30
parent 476704 d15cd2d343c9a6770b6157f86132b9933c9c4b56
child 476706 d5750bca9f677b6e8bff7d0d26770a17006027e6
push id36106
push userbtara@mozilla.com
push dateTue, 04 Jun 2019 16:04:27 +0000
treeherdermozilla-central@d17dc040eec7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrobwu, flod, jaws
bugs1550911
milestone69.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 1550911 - Show recommendations on extension and theme lists r=robwu,flod,jaws Differential Revision: https://phabricator.services.mozilla.com/D30745
browser/app/profile/firefox.js
modules/libpref/init/all.js
testing/profiles/common/user.js
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/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
toolkit/mozapps/extensions/test/browser/head.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -44,16 +44,21 @@ pref("extensions.getAddons.search.browse
 pref("extensions.webservice.discoverURL", "https://discovery.addons.mozilla.org/%LOCALE%/firefox/discovery/pane/%VERSION%/%OS%/%COMPATIBILITY_MODE%");
 pref("extensions.getAddons.link.url", "https://addons.mozilla.org/%LOCALE%/firefox/");
 pref("extensions.getAddons.langpacks.url", "https://services.addons.mozilla.org/api/v3/addons/language-tools/?app=firefox&type=language&appversion=%VERSION%");
 pref("extensions.getAddons.discovery.api_url", "https://services.addons.mozilla.org/api/v4/discovery/?lang=%LOCALE%");
 
 // Enable the HTML-based discovery panel at about:addons.
 pref("extensions.htmlaboutaddons.discover.enabled", false);
 
+// The URL for the privacy policy related to recommended extensions.
+pref("extensions.recommendations.privacyPolicyUrl", "https://www.mozilla.org/privacy/firefox/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=privacy-policy-link#addons");
+// The URL for Firefox Color, recommended on the theme page in about:addons.
+pref("extensions.recommendations.themeRecommendationUrl", "https://color.firefox.com/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=theme-footer-link");
+
 pref("extensions.update.autoUpdateDefault", true);
 
 // Check AUS for system add-on updates.
 pref("extensions.systemAddon.update.url", "https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml");
 pref("extensions.systemAddon.update.enabled", true);
 
 // Disable add-ons that are not installed by the user in all scopes by default.
 // See the SCOPE constants in AddonManager.jsm for values to use here.
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5169,16 +5169,23 @@ pref("extensions.webextensions.enablePer
 // When reached, the counters are sent to the main process and
 // reset, so we reduce memory footprint.
 pref("extensions.webextensions.performanceCountersMaxAge", 5000);
 
 // The HTML about:addons page.
 pref("extensions.htmlaboutaddons.enabled", false);
 // Whether to allow the inline options browser in HTML about:addons page.
 pref("extensions.htmlaboutaddons.inline-options.enabled", true);
+// Show recommendations on the extension and theme list views.
+pref("extensions.htmlaboutaddons.recommendations.enabled", true);
+
+// The URL for the privacy policy related to recommended add-ons.
+pref("extensions.recommendations.privacyPolicyUrl", "");
+// The URL for a recommended theme, shown on the theme page in about:addons.
+pref("extensions.recommendations.themeRecommendationUrl", "");
 
 // Report Site Issue button
 // Note that on enabling the button in other release channels, make sure to
 // disable it in problematic tests, see disableNonReleaseActions() inside
 // browser/modules/test/browser/head.js
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if MOZ_UPDATE_CHANNEL != release && MOZ_UPDATE_CHANNEL != esr
 pref("extensions.webcompat-reporter.enabled", true);
--- a/testing/profiles/common/user.js
+++ b/testing/profiles/common/user.js
@@ -32,16 +32,18 @@ user_pref("dom.max_chrome_script_run_tim
 user_pref("dom.max_script_run_time", 0); // no slow script dialogs
 user_pref("dom.send_after_paint_to_content", true);
 // Only load extensions from the application and user profile
 // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
 user_pref("extensions.enabledScopes", 5);
 user_pref("extensions.legacy.enabled", true);
 // Turn off extension updates so they don't bother tests
 user_pref("extensions.update.enabled", false);
+// Prevent network access for recommendations by default. The payload is {"results":[]}.
+user_pref("extensions.getAddons.discovery.api_url", "data:;base64,eyJyZXN1bHRzIjpbXX0%3D");
 // Disable useragent updates.
 user_pref("general.useragent.updates.enabled", false);
 // Ensure WR doesn't get enabled in tests unless we do it explicitly with the MOZ_WEBRENDER envvar.
 user_pref("gfx.webrender.all.qualified", false);
 user_pref("hangmonitor.timeout", 0); // no hang monitor
 user_pref("media.gmp-manager.updateEnabled", false);
 // Don't do network connections for mitm priming
 user_pref("security.certerrors.mitm.priming.enabled", false);
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -456,8 +456,15 @@ addon-badge-recommended =
 
 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.
 
 addon-permissions-empty = This extension doesn’t require any permissions
+
+recommended-extensions-heading = Recommended Extensions
+recommended-themes-heading = Recommended Themes
+
+# A recommendation for the Firefox Color theme shown at the bottom of the theme
+# list view. The "Firefox Color" name itself should not be translated.
+recommended-theme-1 = Feeling creative? <a data-l10n-name="link">Build your own theme with Firefox Color.</a>
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -1,13 +1,21 @@
 :root {
   --section-width: 664px;
   --addon-icon-size: 32px;
 }
 
+*|*[hidden] {
+  display: none !important;
+}
+
+.header-name {
+  -moz-user-select: initial;
+}
+
 #main {
   margin-inline-start: 28px;
   margin-bottom: 28px;
   max-width: var(--section-width);
 }
 
 #abuse-reports-messages {
   margin-inline-start: 28px;
@@ -51,17 +59,17 @@ addon-card:not([expanded]) > .addon.card
   box-shadow: var(--card-shadow-hover);
   cursor: pointer;
 }
 
 .addon-card-collapsed {
   display: flex;
 }
 
-addon-list .addon.card {
+addon-list addon-card > .addon.card {
   -moz-user-select: none;
 }
 
 /* Theme preview image. */
 .card-heading-image {
   /* If the width, height or aspect ratio changes, don't forget to update the
    * getScreenshotUrlForAddon function in aboutaddons.js */
   width: var(--section-width);
@@ -138,16 +146,21 @@ addon-card:not([expanded]) .addon-descri
 
 .more-options-menu {
   position: relative;
   /* Add some negative margin to account for the button's padding */
   margin-top: -10px;
   margin-inline-end: -8px;
 }
 
+/* Recommended add-ons on list views */
+.recommended-heading {
+  margin-bottom: 16px;
+}
+
 /* Discopane extensions to the add-on card */
 
 recommended-addon-card .addon-name {
   display: flex;
 }
 
 recommended-addon-card .addon-description:not(:empty) {
   margin-top: 0.5em;
@@ -198,25 +211,25 @@ recommended-addon-card .addon-descriptio
   flex-grow: 1;
 }
 
 .discopane-notice-content > button {
   flex-grow: 0;
   flex-shrink: 0;
 }
 
-.discopane-footer {
+.view-footer {
   text-align: center;
 }
 
-.discopane-footer > * {
+.view-footer-item {
   margin-top: 30px;
 }
 
-.discopane-privacy-policy-link {
+.privacy-policy-link {
   font-size: small;
 }
 
 addon-details {
   color: var(--in-content-deemphasized-text);
 }
 
 .addon-detail-description {
@@ -333,17 +346,17 @@ panel-item[action="report"] {
 panel-item-separator {
   display: block;
   height: 1px;
   background: var(--in-content-box-border-color);
   padding: 0;
   margin: 6px 0;
 }
 
-panel-item-separator[hidden] {
+.hide-amo-link .amo-link-container {
   display: none;
 }
 
 .button-link {
   min-height: auto;
   background: none !important;
   padding: 0;
   margin: 0;
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -191,43 +191,63 @@
       <div class="arrow bottom"></div>
     </template>
 
     <template name="panel-item">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-item.css">
       <button><slot></slot></button>
     </template>
 
+    <template name="taar-notice">
+      <message-bar class="discopane-notice">
+        <div class="discopane-notice-content">
+          <span data-l10n-id="discopane-notice-recommendations"></span>
+          <button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button>
+        </div>
+      </message-bar>
+    </template>
+
+    <template name="recommended-footer">
+      <div class="amo-link-container view-footer-item">
+        <button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button>
+      </div>
+      <div class="view-footer-item">
+        <a class="privacy-policy-link" data-l10n-id="privacy-policy" target="_blank"></a>
+      </div>
+    </template>
+
     <template name="discopane">
       <header>
         <p>
           <span data-l10n-id="discopane-intro">
             <a
               class="discopane-intro-learn-more-link"
               data-l10n-name="learn-more-trigger"
               target="_blank">
             </a>
           </span>
         </p>
       </header>
-      <message-bar class="discopane-notice">
-        <div class="discopane-notice-content">
-          <span data-l10n-id="discopane-notice-recommendations"></span>
-          <button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button>
-        </div>
-      </message-bar>
+      <taar-notice></taar-notice>
       <recommended-addon-list></recommended-addon-list>
-      <footer class="discopane-footer">
+      <footer is="recommended-footer" class="view-footer"></footer>
+    </template>
+
+    <template name="recommended-extensions-section">
+      <h2 data-l10n-id="recommended-extensions-heading" class="header-name recommended-heading"></h2>
+      <taar-notice></taar-notice>
+      <recommended-addon-list type="extension" hide-installed></recommended-addon-list>
+      <footer is="recommended-footer" class="view-footer hide-amo-link"></footer>
+    </template>
+
+    <template name="recommended-themes-section">
+      <h2 data-l10n-id="recommended-themes-heading" class="header-name recommended-heading"></h2>
+      <recommended-addon-list type="theme" hide-installed></recommended-addon-list>
+      <footer>
         <div>
-          <button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button>
-        </div>
-        <div>
-          <a
-            class="discopane-privacy-policy-link"
-            data-l10n-id="privacy-policy"
-            href="https://www.mozilla.org/privacy/firefox/?utm_source=firefox-browser&amp;utm_medium=firefox-browser&amp;utm_content=privacy-policy-link#addons"
-            target="_blank"
-          ></a>
+          <p data-l10n-id="recommended-theme-1" class="theme-recommendation">
+            <a data-l10n-name="link" target="_blank"></a>
+          </p>
         </div>
       </footer>
     </template>
   </body>
 </html>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -41,29 +41,35 @@ XPCOMUtils.defineLazyPreferenceGetter(
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "SUPPORT_URL", "app.support.baseURL",
   "", null, val => Services.urlFormatter.formatURL(val));
 
 const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "ABUSE_REPORT_ENABLED",
                                       "extensions.abuseReport.enabled", false);
+XPCOMUtils.defineLazyPreferenceGetter(
+  this, "LIST_RECOMMENDATIONS_ENABLED",
+  "extensions.htmlaboutaddons.recommendations.enabled", false);
 
 const PLUGIN_ICON_URL = "chrome://global/skin/plugins/pluginGeneric.svg";
 const PERMISSION_MASKS = {
   "ask-to-activate": AddonManager.PERM_CAN_ASK_TO_ACTIVATE,
   enable: AddonManager.PERM_CAN_ENABLE,
   "always-activate": AddonManager.PERM_CAN_ENABLE,
   disable: AddonManager.PERM_CAN_DISABLE,
   "never-activate": AddonManager.PERM_CAN_DISABLE,
   uninstall: AddonManager.PERM_CAN_UNINSTALL,
   upgrade: AddonManager.PERM_CAN_UPGRADE,
 };
 
 const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_THEME_RECOMMENDATION_URL =
+  "extensions.recommendations.themeRecommendationUrl";
+const PREF_PRIVACY_POLICY_URL = "extensions.recommendations.privacyPolicyUrl";
 const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
 const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
 const PRIVATE_BROWSING_PERMS =
   {permissions: [PRIVATE_BROWSING_PERM_NAME], origins: []};
 
 const AddonCardListenerHandler = {
   ADDON_EVENTS: new Set([
@@ -276,52 +282,78 @@ class DiscoAddonWrapper {
     this.amoListingUrl = details.addon.url;
   }
 }
 
 /**
  * A helper to retrieve the list of recommended add-ons via AMO's discovery API.
  */
 var DiscoveryAPI = {
+  // Map<boolean, Promise> Promises from fetching the API results with or
+  // without a client ID. The `false` (no client ID) case could actually
+  // have been fetched with a client ID. See getResults() for more info.
+  _resultPromises: new Map(),
+
   /**
    * Fetch the list of recommended add-ons. The results are cached.
    *
    * Pending requests are coalesced, so there is only one request at any given
    * time. If a request fails, the pending promises are rejected, but a new
    * call will result in a new request. A succesful response is cached for the
    * lifetime of the document.
    *
+   * @param {boolean} preferClientId
+   *                  A boolean indicating a preference for using a client ID.
+   *                  This will not overwrite the user preference but will
+   *                  avoid sending a client ID if no request has been made yet.
    * @returns {Promise<DiscoAddonWrapper[]>}
    */
-  async getResults() {
-    if (!this._resultPromise) {
-      this._resultPromise = this._fetchRecommendedAddons()
-        .catch(e => {
-          // Delete the pending promise, so _fetchRecommendedAddons can be
-          // called again at the next property access.
-          delete this._resultPromise;
-          Cu.reportError(e);
-          throw e;
-        });
+  async getResults(preferClientId = true) {
+    // Allow a caller to set preferClientId to false, but not true if discovery
+    // is disabled.
+    preferClientId = preferClientId && this.clientIdDiscoveryEnabled;
+
+    // Reuse a request for this preference first.
+    let resultPromise = this._resultPromises.get(preferClientId) ||
+      // If the client ID isn't preferred, we can still reuse a request with the
+      // client ID.
+      !preferClientId && this._resultPromises.get(true);
+
+    if (resultPromise) {
+      return resultPromise;
     }
-    return this._resultPromise;
+
+    // Nothing is prepared for this preference, make a new request.
+    resultPromise = this._fetchRecommendedAddons(preferClientId)
+      .catch(e => {
+        // Delete the pending promise, so _fetchRecommendedAddons can be
+        // called again at the next property access.
+        this._resultPromises.delete(preferClientId);
+        Cu.reportError(e);
+        throw e;
+      });
+
+    // Store the new result for the preference.
+    this._resultPromises.set(preferClientId, resultPromise);
+
+    return resultPromise;
   },
 
   get clientIdDiscoveryEnabled() {
     // These prefs match Discovery.jsm for enabling clientId cookies.
     return Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
            Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
            !PrivateBrowsingUtils.isContentWindowPrivate(window);
   },
 
-  async _fetchRecommendedAddons() {
+  async _fetchRecommendedAddons(useClientId) {
     let discoveryApiUrl =
       new URL(Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL));
 
-    if (DiscoveryAPI.clientIdDiscoveryEnabled) {
+    if (useClientId) {
       let clientId = await ClientID.getClientIdHash();
       discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
     }
     let res = await fetch(discoveryApiUrl.href, {
       credentials: "omit",
     });
     if (!res.ok) {
       throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
@@ -577,22 +609,23 @@ class AddonOptions extends HTMLElement {
   setElementState(el, card, addon, updateInstall) {
     switch (el.getAttribute("action")) {
       case "remove":
         el.hidden = !hasPermission(addon, "uninstall");
         break;
       case "report":
         el.hidden = !ABUSE_REPORT_ENABLED;
         break;
-      case "toggle-disabled":
+      case "toggle-disabled": {
         let toggleDisabledAction = addon.userDisabled ? "enable" : "disable";
         document.l10n.setAttributes(
           el, `${toggleDisabledAction}-addon-button`);
         el.hidden = !hasPermission(addon, toggleDisabledAction);
         break;
+      }
       case "install-update":
         el.hidden = !updateInstall;
         break;
       case "expand":
         el.hidden = card.expanded;
         break;
       case "preferences":
         el.hidden = getOptionsType(addon) !== "tab" &&
@@ -1736,53 +1769,46 @@ class RecommendedAddonCard extends HTMLE
   }
 
   handleEvent(event) {
     let action = event.target.getAttribute("action");
     switch (action) {
       case "install-addon":
         AMTelemetry.recordActionEvent({
           object: "aboutAddons",
-          view: this.getTelemetryViewName(),
+          view: getTelemetryViewName(this),
           action: "installFromRecommendation",
           addon: this.discoAddon,
         });
         this.installDiscoAddon();
         break;
       case "manage-addon":
         AMTelemetry.recordActionEvent({
           object: "aboutAddons",
-          view: this.getTelemetryViewName(),
+          view: getTelemetryViewName(this),
           action: "manage",
           addon: this.discoAddon,
         });
         loadViewFn("detail", this.addonId);
         break;
       default:
         if (event.target.matches(".disco-addon-author a[href]")) {
           AMTelemetry.recordLinkEvent({
             object: "aboutAddons",
             // Note: This is not "author" nor "homepage", because the link text
             // is the author name, but the link URL the add-on's listing URL.
             value: "discohome",
             extra: {
-              view: this.getTelemetryViewName(),
+              view: getTelemetryViewName(this),
             },
           });
         }
     }
   }
 
-  /**
-   * The name of the view for use in addonsManager telemetry events.
-   */
-  getTelemetryViewName() {
-    return "discover";
-  }
-
   async installDiscoAddon() {
     let addon = this.discoAddon;
     let url = addon.sourceURI.spec;
     let install = await AddonManager.getInstallForURL(url, {
       name: addon.name,
       telemetryInfo: {source: "disco"},
     });
     // We are hosted in a <browser> in about:addons, but we can just use the
@@ -2159,46 +2185,99 @@ class RecommendedAddonList extends HTMLE
     }
     AddonManager.addAddonListener(this);
   }
 
   disconnectedCallback() {
     AddonManager.removeAddonListener(this);
   }
 
+  get type() {
+    return this.getAttribute("type");
+  }
+
+  /**
+   * Set the add-on type for this list. This will be used to filter the add-ons
+   * that are displayed.
+   *
+   * Must be set prior to the first render.
+   *
+   * @param {string} val The type to filter on.
+   */
+  set type(val) {
+    this.setAttribute("type", val);
+  }
+
+  get hideInstalled() {
+    return this.hasAttribute("hide-installed");
+  }
+
+  /**
+   * Set whether installed add-ons should be hidden from the list. If false,
+   * installed add-ons will be shown with a "Manage" button, otherwise they
+   * will be hidden.
+   *
+   * Must be set prior to the first render.
+   *
+   * @param {boolean} val Whether to show installed add-ons.
+   */
+  set hideInstalled(val) {
+    this.toggleAttribute("hide-installed", val);
+  }
+
   onInstalled(addon) {
     let card = this.getCardById(addon.id);
     if (card) {
-      card.setAddon(addon);
+      this.setAddonForCard(card, addon);
     }
   }
 
   onUninstalled(addon) {
     let card = this.getCardById(addon.id);
     if (card) {
-      card.setAddon(null);
+      this.setAddonForCard(card, null);
     }
   }
 
   getCardById(addonId) {
     for (let card of this.children) {
       if (card.addonId === addonId) {
         return card;
       }
     }
     return null;
   }
 
+  setAddonForCard(card, addon) {
+    card.setAddon(addon);
+
+    let wasHidden = card.hidden;
+    card.hidden = this.hideInstalled && addon;
+
+    if (wasHidden != card.hidden) {
+      let eventName = card.hidden ? "card-hidden" : "card-shown";
+      this.dispatchEvent(new CustomEvent(eventName, {detail: {card}}));
+    }
+  }
+
+  /**
+   * Whether the client ID should be preferred. This is disabled for themes
+   * since they don't use the telemetry data and don't show the TAAR notice.
+   */
+  get preferClientId() {
+    return !this.type || this.type == "extension";
+  }
+
   async updateCardsWithAddonManager() {
     let cards = Array.from(this.children);
     let addonIds = cards.map(card => card.addonId);
     let addons = await AddonManager.getAddonsByIDs(addonIds);
     for (let [i, card] of cards.entries()) {
       let addon = addons[i];
-      card.setAddon(addon);
+      this.setAddonForCard(card, addon);
       if (addon) {
         // Already installed, move card to end.
         this.append(card);
       }
     }
   }
 
   async loadCardsIfNeeded() {
@@ -2207,68 +2286,76 @@ class RecommendedAddonList extends HTMLE
       this.cardsReady = this._loadCards();
     }
     return this.cardsReady;
   }
 
   async _loadCards() {
     let recommendedAddons;
     try {
-      recommendedAddons = await DiscoveryAPI.getResults();
+      recommendedAddons = await DiscoveryAPI.getResults(this.preferClientId);
     } catch (e) {
       return;
     }
 
     let frag = document.createDocumentFragment();
     for (let addon of recommendedAddons) {
+      if (this.type && addon.type != this.type) {
+        continue;
+      }
       let card = document.createElement("recommended-addon-card");
       card.setDiscoAddon(addon);
       frag.append(card);
     }
     this.append(frag);
     await this.updateCardsWithAddonManager();
   }
 }
 customElements.define("recommended-addon-list", RecommendedAddonList);
 
-class DiscoveryPane extends HTMLElement {
-  render() {
-    this.append(importTemplate("discopane"));
-    this.querySelector(".discopane-intro-learn-more-link").href =
-      Services.urlFormatter.formatURLPref("app.support.baseURL") +
-      "recommended-extensions-program";
+class TaarMessageBar extends HTMLElement {
+  connectedCallback() {
+    this.hidden = !this.hidden && !DiscoveryAPI.clientIdDiscoveryEnabled;
+    if (this.childElementCount == 0 && !this.hidden) {
+      this.appendChild(importTemplate("taar-notice"));
+      this.addEventListener("click", this);
+    }
+  }
 
-    this.querySelector(".discopane-notice").hidden =
-      !DiscoveryAPI.clientIdDiscoveryEnabled;
-    this.addEventListener("click", this);
+  handleEvent(e) {
+    if (e.type == "click" &&
+        e.target.getAttribute("action") == "notice-learn-more") {
+      // The element is a button but opens a URL, so record as link.
+      AMTelemetry.recordLinkEvent({
+        object: "aboutAddons",
+        value: "disconotice",
+        extra: {
+          view: getTelemetryViewName(this),
+        },
+      });
+      windowRoot.ownerGlobal.openTrustedLinkIn(
+        SUPPORT_URL + "personalized-addons", "tab");
+    }
+  }
+}
+customElements.define("taar-notice", TaarMessageBar);
 
-    // Hide footer until the cards is loaded, to prevent the content from
-    // suddenly shifting when the user attempts to interact with it.
-    let footer = this.querySelector("footer");
-    footer.hidden = true;
-    this.querySelector("recommended-addon-list").loadCardsIfNeeded()
-      .finally(() => { footer.hidden = false; });
+class RecommendedFooter extends HTMLElement {
+  connectedCallback() {
+    if (this.childElementCount == 0) {
+      this.appendChild(importTemplate("recommended-footer"));
+      this.querySelector(".privacy-policy-link")
+        .href = Services.prefs.getStringPref(PREF_PRIVACY_POLICY_URL);
+      this.addEventListener("click", this);
+    }
   }
 
   handleEvent(event) {
     let action = event.target.getAttribute("action");
     switch (action) {
-      case "notice-learn-more":
-        // The element is a button but opens a URL, so record as link.
-        AMTelemetry.recordLinkEvent({
-          object: "aboutAddons",
-          value: "disconotice",
-          extra: {
-            view: "discover",
-          },
-        });
-        windowRoot.ownerGlobal.openTrustedLinkIn(
-          Services.urlFormatter.formatURLPref("app.support.baseURL") +
-          "personalized-extension-recommendations", "tab");
-        break;
       case "open-amo":
         // The element is a button but opens a URL, so record as link.
         AMTelemetry.recordLinkEvent({
           object: "aboutAddons",
           value: "discomore",
           extra: {
             view: "discover",
           },
@@ -2276,41 +2363,160 @@ class DiscoveryPane extends HTMLElement 
         let amoUrl =
           Services.urlFormatter.formatURLPref("extensions.getAddons.link.url");
         amoUrl = formatAmoUrl("find-more-link-bottom", amoUrl);
         windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
         break;
     }
   }
 }
+customElements.define(
+  "recommended-footer", RecommendedFooter, {extends: "footer"});
 
+/**
+ * This element will handle showing recommendations with a
+ * <recommended-addon-list> and a <footer>. The footer will be hidden until
+ * the <recommended-addon-list> is done making its request so the footer
+ * doesn't move around.
+ *
+ * Subclass this element to use it and define a `template` property to pull
+ * the template from. Expected template:
+ *
+ * <h1>My extra content can go here.</h1>
+ * <p>It can be anything but a footer or recommended-addon-list.</p>
+ * <recommended-addon-list></recommended-addon-list>
+ * <footer>My custom footer</footer>
+ */
+class RecommendedSection extends HTMLElement {
+  connectedCallback() {
+    if (this.childElementCount == 0) {
+      this.render();
+    }
+  }
+
+  get list() {
+    return this.querySelector("recommended-addon-list");
+  }
+
+  get footer() {
+    return this.querySelector("footer");
+  }
+
+  render() {
+    this.appendChild(importTemplate(this.template));
+
+    // Hide footer until the cards are loaded, to prevent the content from
+    // suddenly shifting when the user attempts to interact with it.
+    let {footer} = this;
+    footer.hidden = true;
+    this.list.loadCardsIfNeeded().finally(() => { footer.hidden = false; });
+  }
+}
+
+class RecommendedExtensionsSection extends RecommendedSection {
+  get template() {
+    return "recommended-extensions-section";
+  }
+
+  setAmoButtonVisibility() {
+    // Show the AMO button if there are no cards, this is mostly for the case
+    // where the user has no extensions and is offline.
+    let cards = Array.from(this.list.children);
+    let cardVisible = cards.some(card => !card.hidden);
+    this.footer.classList.toggle("hide-amo-link", cardVisible);
+  }
+
+  render() {
+    super.render();
+    let {list} = this;
+    list.cardsReady.then(() => this.setAmoButtonVisibility());
+    list.addEventListener("card-hidden", this);
+    list.addEventListener("card-shown", this);
+  }
+
+  handleEvent(e) {
+    if (e.type == "card-hidden") {
+      this.setAmoButtonVisibility();
+    } else if (e.type == "card-shown") {
+      this.footer.classList.add("hide-amo-link");
+    }
+  }
+}
+customElements.define(
+  "recommended-extensions-section", RecommendedExtensionsSection);
+
+class RecommendedThemesSection extends RecommendedSection {
+  get template() {
+    return "recommended-themes-section";
+  }
+
+  render() {
+    super.render();
+    let themeRecommendationRow = this.querySelector(".theme-recommendation");
+    let themeRecommendationUrl =
+      Services.prefs.getStringPref(PREF_THEME_RECOMMENDATION_URL);
+    if (themeRecommendationUrl) {
+      themeRecommendationRow.querySelector("a").href = themeRecommendationUrl;
+    }
+    themeRecommendationRow.hidden = !themeRecommendationUrl;
+  }
+}
+customElements.define("recommended-themes-section", RecommendedThemesSection);
+
+class DiscoveryPane extends RecommendedSection {
+  get template() {
+    return "discopane";
+  }
+
+  render() {
+    super.render();
+    this.querySelector(".discopane-intro-learn-more-link").href =
+      SUPPORT_URL + "recommended-extensions-program";
+  }
+}
 customElements.define("discovery-pane", DiscoveryPane);
 
 class ListView {
   constructor({param, root}) {
     this.type = param;
     this.root = root;
   }
 
   async render() {
+    let frag = document.createDocumentFragment();
+
     let list = document.createElement("addon-list");
     list.type = this.type;
     list.setSections([{
       headingId: "addons-enabled-heading",
       filterFn: addon => !addon.hidden && addon.isActive &&
                          !isPending(addon, "uninstall"),
     }, {
       headingId: "addons-disabled-heading",
       filterFn: addon => !addon.hidden && !addon.isActive &&
                          !isPending(addon, "uninstall"),
     }]);
+    frag.appendChild(list);
+
+    // Show recommendations for themes and extensions.
+    if (LIST_RECOMMENDATIONS_ENABLED &&
+        (this.type == "extension" || this.type == "theme")) {
+      let elementName = this.type == "extension" ?
+        "recommended-extensions-section" : "recommended-themes-section";
+      let recommendations = document.createElement(elementName);
+      // Start loading the recommendations. This can finish after the view load
+      // event is sent.
+      recommendations.render();
+      frag.appendChild(recommendations);
+    }
 
     await list.render();
+
     this.root.textContent = "";
-    this.root.appendChild(list);
+    this.root.appendChild(frag);
   }
 }
 
 class DetailView {
   constructor({param, root}) {
     let [id, selectedTab] = param.split("/");
     this.id = id;
     this.selectedTab = selectedTab;
@@ -2392,16 +2598,27 @@ class DiscoveryView {
     return discopane;
   }
 }
 
 // Generic view management.
 let root = null;
 
 /**
+ * The name of the view for an element, used for telemetry.
+ *
+ * @param {Element} el The element to find the view from. A parent of the
+ *                     element must define a current-view property.
+ * @returns {string} The current view name.
+ */
+function getTelemetryViewName(el) {
+  return el.closest("[current-view]").getAttribute("current-view");
+}
+
+/**
  * Called from extensions.js once, when about:addons is loading.
  */
 function initialize(opts) {
   root = document.getElementById("main");
   loadViewFn = opts.loadViewFn;
   replaceWithDefaultViewFn = opts.replaceWithDefaultViewFn;
   setCategoryFn = opts.setCategoryFn;
   AddonCardListenerHandler.startup();
@@ -2414,28 +2631,31 @@ function initialize(opts) {
 }
 
 /**
  * Called from extensions.js to load a view. The view's render method should
  * resolve once the view has been updated to conform with other about:addons
  * views.
  */
 async function show(type, param) {
+  let container = document.createElement("div");
+  container.setAttribute("current-view", type);
   if (type == "list") {
-    await new ListView({param, root}).render();
+    await new ListView({param, root: container}).render();
   } else if (type == "detail") {
-    await new DetailView({param, root}).render();
+    await new DetailView({param, root: container}).render();
   } else if (type == "discover") {
     let discoverView = new DiscoveryView();
     let elem = discoverView.render();
     await document.l10n.translateFragment(elem);
-    root.textContent = "";
-    root.append(elem);
+    container.append(elem);
   } else if (type == "updates") {
-    await new UpdatesView({param, root}).render();
+    await new UpdatesView({param, root: container}).render();
   } else {
     throw new Error(`Unknown view type: ${type}`);
   }
+  root.textContent = "";
+  root.appendChild(container);
 }
 
 function hide() {
   root.textContent = "";
 }
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -79,16 +79,17 @@ skip-if = true # Bug 1449071 - Frequent 
 [browser_gmpProvider.js]
 skip-if = os == 'linux' && !debug # Bug 1398766
 [browser_html_abuse_report.js]
 [browser_html_detail_view.js]
 [browser_html_discover_view.js]
 [browser_html_discover_view_clientid.js]
 [browser_html_discover_view_prefs.js]
 [browser_html_list_view.js]
+[browser_html_list_view_recommendations.js]
 [browser_html_message_bar.js]
 [browser_html_named_deck.js]
 [browser_html_options_ui.js]
 [browser_html_options_ui_in_tab.js]
 [browser_html_plugins.js]
 skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
 [browser_html_recent_updates.js]
 [browser_html_updates.js]
--- a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -9,17 +9,17 @@ const {
 
 const {
   TelemetryTestUtils,
 } = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
 
 AddonTestUtils.initMochitest(this);
 const server = AddonTestUtils.createHttpServer();
 const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
-server.registerPathHandler("/sumo/personalized-extension-recommendations",
+server.registerPathHandler("/sumo/personalized-addons",
   (request, response) => {
     response.write("This is a SUMO page that explains personalized add-ons.");
   });
 
 // Before a discovery API request is triggered, this method should be called.
 // Resolves with the value of the "telemetry-client-id" query parameter.
 async function promiseOneDiscoveryApiRequest() {
   return new Promise(resolve => {
@@ -35,28 +35,30 @@ async function promiseOneDiscoveryApiReq
   });
 }
 
 function getNoticeButton(win) {
   return win.document.querySelector("[action='notice-learn-more']");
 }
 
 function isNoticeVisible(win) {
-  return getNoticeButton(win).closest("message-bar").offsetHeight > 0;
+  let message = win.document.querySelector("taar-notice");
+  return message && message.offsetHeight > 0;
 }
 
 add_task(async function setup() {
   await SpecialPowers.pushPrefEnv({
     set: [
       // Enable clientid - see Discovery.jsm for the first two prefs.
       ["browser.discovery.enabled", true],
       ["datareporting.healthreport.uploadEnabled", true],
       ["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
       ["app.support.baseURL", `${serverBaseUrl}sumo/`],
       ["extensions.htmlaboutaddons.discover.enabled", true],
+      ["extensions.htmlaboutaddons.enabled", true],
     ],
   });
 });
 
 // Test that the clientid is passed to the API when enabled via prefs.
 add_task(async function clientid_enabled() {
   let EXPECTED_CLIENT_ID = await ClientID.getClientIdHash();
   ok(EXPECTED_CLIENT_ID, "ClientID should be available");
@@ -78,18 +80,17 @@ add_task(async function clientid_enabled
   //
   // is(await requestPromise, EXPECTED_CLIENT_ID,
   ok(await requestPromise,
      "Moz-Client-Id should be set when telemetry & discovery are enabled");
 
   Services.telemetry.clearEvents();
 
   let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
-  let expectedUrl =
-    `${serverBaseUrl}sumo/personalized-extension-recommendations`;
+  let expectedUrl = `${serverBaseUrl}sumo/personalized-addons`;
   let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
 
   getNoticeButton(win).click();
 
   info(`Waiting for new tab with URL: ${expectedUrl}`);
   let tab = await tabPromise;
   BrowserTestUtils.removeTab(tab);
 
@@ -135,8 +136,56 @@ add_task(async function clientid_from_pr
      "Addon-manager is in a private window");
 
   is(await requestPromise, null,
      "Moz-Client-Id should not be sent in private windows");
 
   await close_manager(managerWindow);
   await BrowserTestUtils.closeWindow(privateWindow);
 });
+
+add_task(async function clientid_enabled_from_extension_list() {
+  // Force the extension list to be the first load. This pref will be
+  // overwritten once the view loads.
+  Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("extension");
+
+  ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+  ok(await requestPromise,
+     "Moz-Client-Id should be set when telemetry & discovery are enabled");
+
+  // Make sure switching to the theme view doesn't trigger another request.
+  await switchView(win, "theme");
+
+  // Wait until the request would have happened so promiseOneDiscoveryApiRequest
+  // can fail if it does.
+  let recommendations = win.document.querySelector("recommended-addon-list");
+  await recommendations.loadCardsIfNeeded();
+
+  await closeView(win);
+});
+
+add_task(async function clientid_enabled_from_theme_list() {
+  // Force the theme list to be the first load. This pref will be overwritten
+  // once the view loads.
+  Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/theme");
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("theme");
+
+  ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+
+  is(await requestPromise, null,
+     "Moz-Client-Id should not be sent when loading themes initially");
+
+  info("Load the extension list and verify the client ID is now sent");
+
+  requestPromise = promiseOneDiscoveryApiRequest();
+  await switchView(win, "extension");
+
+  ok(await requestPromise,
+     "Moz-Client-Id is now sent for extensions");
+
+  await closeView(win);
+});
--- a/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
@@ -274,44 +274,47 @@ add_task(async function testKeyboardSupp
 
   // Focus the more options menu button.
   let moreOptionsButton = card.querySelector('[action="more-options"]');
   moreOptionsButton.focus();
   isFocused(moreOptionsButton, "The more options button is focused");
 
   // Test opening and closing the menu.
   let moreOptionsMenu = card.querySelector("panel-list");
+  let expandButton = moreOptionsMenu.querySelector('[action="expand"]');
   is(moreOptionsMenu.open, false, "The menu is closed");
   space();
   is(moreOptionsMenu.open, true, "The menu is open");
   space();
   is(moreOptionsMenu.open, false, "The menu is closed");
 
   // Test tabbing out of the menu.
   space();
   is(moreOptionsMenu.open, true, "The menu is open");
   tab({shiftKey: true});
   is(moreOptionsMenu.open, false, "Tabbing away from the menu closes it");
   tab();
   isFocused(moreOptionsButton, "The button is focused again");
+  let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
   space();
+  await shown;
   is(moreOptionsMenu.open, true, "The menu is open");
   tab();
   tab();
   tab();
-  isFocused(moreOptionsButton, "The last item is focused");
+  isFocused(expandButton, "The last item is focused");
   tab();
   is(moreOptionsMenu.open, false, "Tabbing out of the menu closes it");
 
   // Focus the button again, focus may have moved out of the browser.
   moreOptionsButton.focus();
   isFocused(moreOptionsButton, "The button is focused again");
 
   // Open the menu to test contents.
-  let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
+  shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
   space();
   is(moreOptionsMenu.open, true, "The menu is open");
   // Wait for the panel to be shown.
   await shown;
 
   // Disable the add-on.
   let toggleDisableButton = card.querySelector('[action="toggle-disabled"]');
   tab();
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
@@ -0,0 +1,306 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+const {
+  TelemetryTestUtils,
+} = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
+
+AddonTestUtils.initMochitest(this);
+
+function makeResult({guid, type}) {
+  return {
+    addon: {
+      authors: [{name: "Some author"}],
+      current_version: {
+        files: [{platform: "all", url: "data:,"}],
+      },
+      url: "data:,",
+      guid,
+      type,
+    },
+  };
+}
+
+function mockResults() {
+  let types = ["extension", "theme", "extension", "extension", "theme"];
+  return {
+    results: types.map((type, i) => makeResult({
+      guid: `${type}${i}@mochi.test`,
+      type,
+    })),
+  };
+}
+
+add_task(async function setup() {
+  let results = btoa(JSON.stringify(mockResults()));
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      // Disable personalized recommendations, they will break the data URI.
+      ["browser.discovery.enabled", false],
+      ["extensions.htmlaboutaddons.enabled", true],
+      ["extensions.getAddons.discovery.api_url", `data:;base64,${results}`],
+      ["extensions.recommendations.themeRecommendationUrl",
+       "https://example.com/theme"],
+    ],
+  });
+});
+
+function checkExtraContents(doc, type, opts = {}) {
+  let {
+    showAmoButton = false,
+    showThemeRecommendationFooter = type === "theme",
+  } = opts;
+  let footer = doc.querySelector("footer");
+  let amoButton = footer.querySelector('[action="open-amo"]');
+  let privacyPolicyLink = footer.querySelector(".privacy-policy-link");
+  let themeRecommendationFooter = footer.querySelector(".theme-recommendation");
+  let themeRecommendationLink =
+    themeRecommendationFooter && themeRecommendationFooter.querySelector("a");
+  let taarNotice = doc.querySelector("taar-notice");
+
+  is_element_visible(footer, "The footer is visible");
+
+
+  if (type == "extension") {
+    ok(taarNotice, "There is a TAAR notice");
+    if (showAmoButton) {
+      is_element_visible(amoButton, "The AMO button is shown");
+    } else {
+      is_element_hidden(amoButton, "The AMO button is hidden");
+    }
+    is_element_visible(privacyPolicyLink, "The privacy policy is visible");
+  } else if (type == "theme") {
+    ok(!taarNotice, "There is no TAAR notice");
+    ok(!amoButton, "There is no AMO button");
+    ok(!privacyPolicyLink, "There is no privacy policy");
+  } else {
+    throw new Error(`Unknown type ${type}`);
+  }
+
+  if (showThemeRecommendationFooter) {
+    is_element_visible(
+      themeRecommendationFooter, "There's a theme recommendation footer");
+    is_element_visible(themeRecommendationLink, "There's a link to the theme");
+    is(themeRecommendationLink.target, "_blank", "The link opens in a new tab");
+    is(themeRecommendationLink.href, "https://example.com/theme",
+       "The link goes to the pref's URL");
+    is(doc.l10n.getAttributes(themeRecommendationFooter).id,
+       "recommended-theme-1", "The recommendation has the right l10n-id");
+  } else {
+    ok(!themeRecommendationFooter || themeRecommendationFooter.hidden,
+       "There's no theme recommendation");
+  }
+}
+
+async function installAddon({card, recommendedList, manifestExtra = {}}) {
+  // Install an add-on to hide the card.
+  let hidden = BrowserTestUtils.waitForEvent(
+    recommendedList, "card-hidden", false, e => e.detail.card == card);
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id: card.addonId}},
+      ...manifestExtra,
+    },
+    useAddonManager: "temporary",
+  });
+  await extension.startup();
+  await hidden;
+  return extension;
+}
+
+async function testListRecommendations({type, manifestExtra = {}}) {
+  Services.telemetry.clearEvents();
+
+  let win = await loadInitialView(type);
+  let doc = win.document;
+
+  // Wait for the list to render, rendering is tested with the discovery pane.
+  let recommendedList = doc.querySelector("recommended-addon-list");
+  await recommendedList.cardsReady;
+
+  checkExtraContents(doc, type);
+
+  // Check that the cards are all for the right type.
+  let cards = doc.querySelectorAll("recommended-addon-card");
+  ok(cards.length > 0, "There were some cards found");
+  for (let card of cards) {
+    is(card.discoAddon.type, type, `The card is for a ${type}`);
+    is_element_visible(card, "The card is visible");
+  }
+
+  // Install an add-on for the first card, verify it is hidden.
+  let {addonId} = cards[0];
+  ok(addonId, "The card has an addonId");
+
+  // Installing the add-on will fail since the URL doesn't point to a valid
+  // XPI. This will trigger the telemetry though.
+  let installButton = cards[0].querySelector('[action="install-addon"]');
+  let {panel} = PopupNotifications;
+  let popupId = "addon-install-failed-notification";
+  let failPromise = TestUtils.topicObserved("addon-install-failed");
+  installButton.click();
+  await failPromise;
+  // Wait for the installing popup to be hidden and leave just the error popup.
+  await BrowserTestUtils.waitForCondition(() => {
+    return panel.children.length == 1 && panel.firstElementChild.id == popupId;
+  });
+
+  // Dismiss the popup.
+  panel.firstElementChild.button.click();
+  await BrowserTestUtils.waitForPopupEvent(panel, "hidden");
+
+  let extension = await installAddon({card: cards[0], recommendedList});
+  is_element_hidden(cards[0], "The card is now hidden");
+
+  // Switch away and back, there should still be a hidden card.
+  await closeView(win);
+  win = await loadInitialView(type);
+  doc = win.document;
+  recommendedList = doc.querySelector("recommended-addon-list");
+  await recommendedList.cardsReady;
+
+  cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+
+  let hiddenCard = cards.pop();
+  is(hiddenCard.addonId, addonId, "The expected card was found");
+  is_element_hidden(hiddenCard, "The card is still hidden");
+
+  ok(cards.length > 0, "There are still some visible cards");
+  for (let card of cards) {
+    is(card.discoAddon.type, type, `The card is for a ${type}`);
+    is_element_visible(card, "The card is visible");
+  }
+
+  // Uninstall the add-on, verify the card is shown again.
+  let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+  await extension.unload();
+  await shown;
+
+  is_element_visible(hiddenCard, "The card is now shown");
+
+  await closeView(win);
+
+  TelemetryTestUtils.assertEvents([{
+    category: "addonsManager",
+    method: "action",
+    object: "aboutAddons",
+    extra: {
+      action: "installFromRecommendation",
+      view: "list",
+      addonId,
+      type,
+    },
+  }], {
+    category: "addonsManager",
+    method: "action",
+    object: "aboutAddons",
+  });
+}
+
+add_task(async function testExtensionList() {
+  await testListRecommendations({type: "extension"});
+});
+
+add_task(async function testThemeList() {
+  await testListRecommendations({
+    type: "theme",
+    manifestExtra: {theme: {}},
+  });
+});
+
+add_task(async function testInstallAllExtensions() {
+  let type = "extension";
+  let win = await loadInitialView(type);
+  let doc = win.document;
+
+  // Wait for the list to render, rendering is tested with the discovery pane.
+  let recommendedList = doc.querySelector("recommended-addon-list");
+  await recommendedList.cardsReady;
+
+  // Find more button is hidden.
+  checkExtraContents(doc, type);
+
+  let cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+  is(cards.length, 3, "We found some cards");
+
+  let extensions = await Promise.all(
+    cards.map(card => installAddon({card, recommendedList})));
+
+  // The find more on AMO button is now shown.
+  checkExtraContents(doc, type, {showAmoButton: true});
+
+  // Uninstall one of the extensions, the button should be hidden again.
+  let extension = extensions.pop();
+  let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+  await extension.unload();
+  await shown;
+
+  // The find more on AMO button is now hidden.
+  checkExtraContents(doc, type);
+
+  await Promise.all(extensions.map(extension => extension.unload()));
+  await closeView(win);
+});
+
+add_task(async function testError() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["extensions.getAddons.discovery.api_url", "data:,"],
+    ],
+  });
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  // Wait for the list to render, rendering is tested with the discovery pane.
+  let recommendedList = doc.querySelector("recommended-addon-list");
+  await recommendedList.cardsReady;
+
+  checkExtraContents(doc, "extension", {showAmoButton: true});
+
+  await closeView(win);
+  await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testThemesNoRecommendationUrl() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.recommendations.themeRecommendationUrl", ""]],
+  });
+
+  let win = await loadInitialView("theme");
+  let doc = win.document;
+
+  // Wait for the list to render, rendering is tested with the discovery pane.
+  let recommendedList = doc.querySelector("recommended-addon-list");
+  await recommendedList.cardsReady;
+
+  checkExtraContents(doc, "theme", {showThemeRecommendationFooter: false});
+
+  await closeView(win);
+  await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testRecommendationsDisabled() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.htmlaboutaddons.recommendations.enabled", false]],
+  });
+
+  let types = ["extension", "theme"];
+
+  for (let type of types) {
+    let win = await loadInitialView(type);
+    let doc = win.document;
+
+    let recommendedList = doc.querySelector("recommended-addon-list");
+    ok(!recommendedList, `There are no recommendations on the ${type} page`);
+
+    await closeView(win);
+  }
+
+  await SpecialPowers.popPrefEnv();
+});
--- a/toolkit/mozapps/extensions/test/browser/head.js
+++ b/toolkit/mozapps/extensions/test/browser/head.js
@@ -1490,16 +1490,20 @@ async function loadInitialView(type) {
 function waitForViewLoad(win) {
   return wait_for_view_load(win.managerWindow, undefined, true);
 }
 
 function closeView(win) {
   return close_manager(win.managerWindow);
 }
 
+function switchView(win, type) {
+  return new CategoryUtilities(win.managerWindow).openType(type);
+}
+
 function mockPromptService() {
   let {prompt} = Services;
   let promptService = {
     // The prompt returns 1 for cancelled and 0 for accepted.
     _response: 1,
     QueryInterface: ChromeUtils.generateQI([Ci.nsIPromptService]),
     confirmEx: () => promptService._response,
   };