Bug 1550911 - Show recommendations on extension and theme lists. r=robwu,flod,jaws l10n=flod a=jcristau
authorMark Striemer <mstriemer@mozilla.com>
Tue, 04 Jun 2019 01:50:33 +0000
changeset 536867 458469ed6f7e96118c0dd7224ce01f9f1a5f22bb
parent 536866 34a9ba95e9085caa5dd46f7aa78b6a7c43897fcb
child 536868 d04818deefaeb0263943fa8587d11d263b669b77
push id2082
push userffxbld-merge
push dateMon, 01 Jul 2019 08:34:18 +0000
treeherdermozilla-release@2fb19d0466d2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrobwu, flod, jaws, jcristau
bugs1550911
milestone68.0
Bug 1550911 - Show recommendations on extension and theme lists. r=robwu,flod,jaws l10n=flod a=jcristau 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
@@ -5188,16 +5188,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 || addon.isBuiltin || addon.isSystem;
         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
@@ -2162,46 +2188,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() {
@@ -2210,68 +2289,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",
           },
@@ -2279,41 +2366,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;
@@ -2395,16 +2601,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();
@@ -2417,28 +2634,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
@@ -296,44 +296,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,
   };