summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/content/aboutaddons.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/content/aboutaddons.js')
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.js4811
1 files changed, 4811 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.js b/toolkit/mozapps/extensions/content/aboutaddons.js
new file mode 100644
index 0000000000..0ad41fd91c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -0,0 +1,4811 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+/* exported hide, initialize, show */
+/* import-globals-from aboutaddonsCommon.js */
+/* import-globals-from abuse-reports.js */
+/* global MozXULElement, MessageBarStackElement, windowRoot */
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
+ AMTelemetry: "resource://gre/modules/AddonManager.jsm",
+ ClientID: "resource://gre/modules/ClientID.jsm",
+ DeferredTask: "resource://gre/modules/DeferredTask.jsm",
+ E10SUtils: "resource://gre/modules/E10SUtils.jsm",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "browserBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+XPCOMUtils.defineLazyGetter(this, "brandBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+XPCOMUtils.defineLazyGetter(this, "extBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://mozapps/locale/extensions/extensions.properties"
+ );
+});
+XPCOMUtils.defineLazyGetter(this, "extensionStylesheets", () => {
+ const { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+ );
+ return ExtensionParent.extensionStylesheets;
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "allowPrivateBrowsingByDefault",
+ "extensions.allowPrivateBrowsingByDefault",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "SUPPORT_URL",
+ "app.support.baseURL",
+ "",
+ null,
+ val => Services.urlFormatter.formatURL(val)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "XPINSTALL_ENABLED",
+ "xpinstall.enabled",
+ true
+);
+
+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/plugin.svg";
+const EXTENSION_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+const BUILTIN_THEME_PREVIEWS = new Map([
+ [
+ "default-theme@mozilla.org",
+ "chrome://mozapps/content/extensions/default-theme.svg",
+ ],
+ [
+ "firefox-compact-light@mozilla.org",
+ "chrome://mozapps/content/extensions/firefox-compact-light.svg",
+ ],
+ [
+ "firefox-compact-dark@mozilla.org",
+ "chrome://mozapps/content/extensions/firefox-compact-dark.svg",
+ ],
+ [
+ "firefox-alpenglow@mozilla.org",
+ "chrome://mozapps/content/extensions/firefox-alpenglow.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,
+ "change-privatebrowsing": AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS,
+};
+
+const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_THEME_RECOMMENDATION_URL =
+ "extensions.recommendations.themeRecommendationUrl";
+const PREF_RECOMMENDATION_HIDE_NOTICE = "extensions.recommendations.hideNotice";
+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: [],
+};
+
+function shouldSkipAnimations() {
+ return (
+ document.body.hasAttribute("skip-animations") ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function callListeners(name, args, listeners) {
+ for (let listener of listeners) {
+ try {
+ if (name in listener) {
+ listener[name](...args);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+}
+
+function getUpdateInstall(addon) {
+ return (
+ // Install object for a pending update.
+ addon.updateInstall ||
+ // Install object for a postponed upgrade (only for extensions,
+ // because is the only addon type that can postpone their own
+ // updates).
+ (addon.type === "extension" &&
+ addon.pendingUpgrade &&
+ addon.pendingUpgrade.install)
+ );
+}
+
+function isManualUpdate(install) {
+ let isManual =
+ install.existingAddon &&
+ !AddonManager.shouldAutoUpdate(install.existingAddon);
+ let isExtension =
+ install.existingAddon && install.existingAddon.type == "extension";
+ return (
+ (isManual && isInState(install, "available")) ||
+ (isExtension && isInState(install, "postponed"))
+ );
+}
+
+const AddonManagerListenerHandler = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+
+ delegateEvent(name, args) {
+ callListeners(name, args, this.listeners);
+ },
+
+ startup() {
+ this._listener = new Proxy(
+ {},
+ {
+ has: () => true,
+ get: (_, name) => (...args) => this.delegateEvent(name, args),
+ }
+ );
+ AddonManager.addAddonListener(this._listener);
+ AddonManager.addInstallListener(this._listener);
+ AddonManager.addManagerListener(this._listener);
+ this._permissionHandler = (type, data) => {
+ if (type == "change-permissions") {
+ this.delegateEvent("onChangePermissions", [data]);
+ }
+ };
+ ExtensionPermissions.addListener(this._permissionHandler);
+ },
+
+ shutdown() {
+ AddonManager.removeAddonListener(this._listener);
+ AddonManager.removeInstallListener(this._listener);
+ AddonManager.removeManagerListener(this._listener);
+ ExtensionPermissions.removeListener(this._permissionHandler);
+ },
+};
+
+/**
+ * This object wires the AddonManager event listeners into addon-card and
+ * addon-details elements rather than needing to add/remove listeners all the
+ * time as the view changes.
+ */
+const AddonCardListenerHandler = new Proxy(
+ {},
+ {
+ has: () => true,
+ get(_, name) {
+ return (...args) => {
+ let elements = [];
+ let addonId;
+
+ // We expect args[0] to be of type:
+ // - AddonInstall, on AddonManager install events
+ // - AddonWrapper, on AddonManager addon events
+ // - undefined, on AddonManager manage events
+ if (args[0]) {
+ addonId =
+ args[0].addon?.id ||
+ args[0].existingAddon?.id ||
+ args[0].extensionId ||
+ args[0].id;
+ }
+
+ if (addonId) {
+ let cardSelector = `addon-card[addon-id="${addonId}"]`;
+ elements = document.querySelectorAll(
+ `${cardSelector}, ${cardSelector} addon-details`
+ );
+ } else if (name == "onUpdateModeChanged") {
+ elements = document.querySelectorAll("addon-card");
+ }
+
+ callListeners(name, args, elements);
+ };
+ },
+ }
+);
+AddonManagerListenerHandler.addListener(AddonCardListenerHandler);
+
+function isAbuseReportSupported(addon) {
+ return (
+ ABUSE_REPORT_ENABLED &&
+ ["extension", "theme"].includes(addon.type) &&
+ !(addon.isBuiltin || addon.isSystem)
+ );
+}
+
+async function isAllowedInPrivateBrowsing(addon) {
+ // Use the Promise directly so this function stays sync for the other case.
+ let perms = await ExtensionPermissions.get(addon.id);
+ return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
+}
+
+function hasPermission(addon, permission) {
+ return !!(addon.permissions & PERMISSION_MASKS[permission]);
+}
+
+function isInState(install, state) {
+ return install.state == AddonManager["STATE_" + state.toUpperCase()];
+}
+
+async function getAddonMessageInfo(addon) {
+ const { name } = addon;
+ const appName = brandBundle.GetStringFromName("brandShortName");
+ const {
+ STATE_BLOCKED,
+ STATE_OUTDATED,
+ STATE_SOFTBLOCKED,
+ STATE_VULNERABLE_UPDATE_AVAILABLE,
+ STATE_VULNERABLE_NO_UPDATE,
+ } = Ci.nsIBlocklistService;
+
+ const formatString = (name, args) =>
+ extBundle.formatStringFromName(
+ `details.notification.${name}`,
+ args,
+ args.length
+ );
+ const getString = name =>
+ extBundle.GetStringFromName(`details.notification.${name}`);
+
+ if (addon.blocklistState === STATE_BLOCKED) {
+ return {
+ linkText: getString("blocked.link"),
+ linkUrl: await addon.getBlocklistURL(),
+ message: formatString("blocked", [name]),
+ type: "error",
+ };
+ } else if (isDisabledUnsigned(addon)) {
+ return {
+ linkText: getString("unsigned.link"),
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ message: formatString("unsignedAndDisabled", [name, appName]),
+ type: "error",
+ };
+ } else if (
+ !addon.isCompatible &&
+ (AddonManager.checkCompatibility ||
+ addon.blocklistState !== STATE_SOFTBLOCKED)
+ ) {
+ return {
+ message: formatString("incompatible", [
+ name,
+ appName,
+ Services.appinfo.version,
+ ]),
+ type: "warning",
+ };
+ } else if (!isCorrectlySigned(addon)) {
+ return {
+ linkText: getString("unsigned.link"),
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ message: formatString("unsigned", [name, appName]),
+ type: "warning",
+ };
+ } else if (addon.blocklistState === STATE_SOFTBLOCKED) {
+ return {
+ linkText: getString("softblocked.link"),
+ linkUrl: await addon.getBlocklistURL(),
+ message: formatString("softblocked", [name]),
+ type: "warning",
+ };
+ } else if (addon.blocklistState === STATE_OUTDATED) {
+ return {
+ linkText: getString("outdated.link"),
+ linkUrl: await addon.getBlocklistURL(),
+ message: formatString("outdated", [name]),
+ type: "warning",
+ };
+ } else if (addon.blocklistState === STATE_VULNERABLE_UPDATE_AVAILABLE) {
+ return {
+ linkText: getString("vulnerableUpdatable.link"),
+ linkUrl: await addon.getBlocklistURL(),
+ message: formatString("vulnerableUpdatable", [name]),
+ type: "error",
+ };
+ } else if (addon.blocklistState === STATE_VULNERABLE_NO_UPDATE) {
+ return {
+ linkText: getString("vulnerableNoUpdate.link"),
+ linkUrl: await addon.getBlocklistURL(),
+ message: formatString("vulnerableNoUpdate", [name]),
+ type: "error",
+ };
+ } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) {
+ return {
+ message: formatString("gmpPending", [name]),
+ type: "warning",
+ };
+ }
+ return {};
+}
+
+function checkForUpdate(addon) {
+ return new Promise(resolve => {
+ let listener = {
+ onUpdateAvailable(addon, install) {
+ if (AddonManager.shouldAutoUpdate(addon)) {
+ // Make sure that an update handler is attached to all the install
+ // objects when updated xpis are going to be installed automatically.
+ attachUpdateHandler(install);
+
+ let failed = () => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: false, found: true });
+ };
+ let updateListener = {
+ onDownloadFailed: failed,
+ onInstallCancelled: failed,
+ onInstallFailed: failed,
+ onInstallEnded: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: true, pending: false, found: true });
+ },
+ onInstallPostponed: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: true, found: true });
+ },
+ };
+ install.addListener(updateListener);
+ install.install();
+ } else {
+ resolve({ installed: false, pending: true, found: true });
+ }
+ },
+ onNoUpdateAvailable() {
+ resolve({ found: false });
+ },
+ };
+ addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ });
+}
+
+async function checkForUpdates() {
+ let addons = await AddonManager.getAddonsByTypes(null);
+ addons = addons.filter(addon => hasPermission(addon, "upgrade"));
+ let updates = await Promise.all(addons.map(addon => checkForUpdate(addon)));
+ Services.obs.notifyObservers(null, "EM-update-check-finished");
+ return updates.reduce(
+ (counts, update) => ({
+ installed: counts.installed + (update.installed ? 1 : 0),
+ pending: counts.pending + (update.pending ? 1 : 0),
+ found: counts.found + (update.found ? 1 : 0),
+ }),
+ { installed: 0, pending: 0, found: 0 }
+ );
+}
+
+// Don't change how we handle this while the page is open.
+const INLINE_OPTIONS_ENABLED = Services.prefs.getBoolPref(
+ "extensions.htmlaboutaddons.inline-options.enabled"
+);
+const OPTIONS_TYPE_MAP = {
+ [AddonManager.OPTIONS_TYPE_TAB]: "tab",
+ [AddonManager.OPTIONS_TYPE_INLINE_BROWSER]: INLINE_OPTIONS_ENABLED
+ ? "inline"
+ : "tab",
+};
+
+// Check if an add-on has the provided options type, accounting for the pref
+// to disable inline options.
+function getOptionsType(addon, type) {
+ return OPTIONS_TYPE_MAP[addon.optionsType];
+}
+
+// Check whether the options page can be loaded in the current browser window.
+async function isAddonOptionsUIAllowed(addon) {
+ if (addon.type !== "extension" || !getOptionsType(addon)) {
+ // Themes never have options pages.
+ // Some plugins have preference pages, and they can always be shown.
+ // Extensions do not need to be checked if they do not have options pages.
+ return true;
+ }
+ if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) {
+ return true;
+ }
+ if (addon.incognito === "not_allowed") {
+ return false;
+ }
+ // The current page is in a private browsing window, and the add-on does not
+ // have the permission to access private browsing windows. Block access.
+ return (
+ allowPrivateBrowsingByDefault ||
+ // Note: This function is async because isAllowedInPrivateBrowsing is async.
+ isAllowedInPrivateBrowsing(addon)
+ );
+}
+
+/**
+ * This function is set in initialize() by the parent about:addons window. It
+ * is a helper for gViewController.loadView().
+ *
+ * @param {string} type The view type to load.
+ * @param {string} param The (optional) param for the view.
+ */
+let loadViewFn;
+
+/**
+ * This function is set in initialize() by the parent about:addons window. It
+ * is a helper for gViewController.replaceView(defaultViewId). This should be
+ * used to reset the view if we try to load an invalid view.
+ */
+let replaceWithDefaultViewFn;
+
+let _templates = {};
+
+/**
+ * Import a template from the main document.
+ */
+function importTemplate(name) {
+ if (!_templates.hasOwnProperty(name)) {
+ _templates[name] = document.querySelector(`template[name="${name}"]`);
+ }
+ let template = _templates[name];
+ if (template) {
+ return document.importNode(template.content, true);
+ }
+ throw new Error(`Unknown template: ${name}`);
+}
+
+function nl2br(text) {
+ let frag = document.createDocumentFragment();
+ let hasAppended = false;
+ for (let part of text.split("\n")) {
+ if (hasAppended) {
+ frag.appendChild(document.createElement("br"));
+ }
+ frag.appendChild(new Text(part));
+ hasAppended = true;
+ }
+ return frag;
+}
+
+/**
+ * Select the screeenshot to display above an add-on card.
+ *
+ * @param {AddonWrapper|DiscoAddonWrapper} addon
+ * @returns {string|null}
+ * The URL of the best fitting screenshot, if any.
+ */
+function getScreenshotUrlForAddon(addon) {
+ if (BUILTIN_THEME_PREVIEWS.has(addon.id)) {
+ return BUILTIN_THEME_PREVIEWS.get(addon.id);
+ }
+
+ let { screenshots } = addon;
+ if (!screenshots || !screenshots.length) {
+ return null;
+ }
+
+ // The image size is defined at .card-heading-image in aboutaddons.css, and
+ // is based on the aspect ratio for a 680x92 image. Use the image if possible,
+ // and otherwise fall back to the first image and hope for the best.
+ let screenshot = screenshots.find(s => s.width === 680 && s.height === 92);
+ if (!screenshot) {
+ console.warn(`Did not find screenshot with desired size for ${addon.id}.`);
+ screenshot = screenshots[0];
+ }
+ return screenshot.url;
+}
+
+/**
+ * Adds UTM parameters to a given URL, if it is an AMO URL.
+ *
+ * @param {string} contentAttribute
+ * Identifies the part of the UI with which the link is associated.
+ * @param {string} url
+ * @returns {string}
+ * The url with UTM parameters if it is an AMO URL.
+ * Otherwise the url in unmodified form.
+ */
+function formatUTMParams(contentAttribute, url) {
+ let parsedUrl = new URL(url);
+ let domain = `.${parsedUrl.hostname}`;
+ if (
+ !domain.endsWith(".mozilla.org") &&
+ // For testing: addons-dev.allizom.org and addons.allizom.org
+ !domain.endsWith(".allizom.org")
+ ) {
+ return url;
+ }
+
+ parsedUrl.searchParams.set("utm_source", "firefox-browser");
+ parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+ parsedUrl.searchParams.set("utm_content", contentAttribute);
+ return parsedUrl.href;
+}
+
+// A wrapper around an item from the "results" array from AMO's discovery API.
+// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+class DiscoAddonWrapper {
+ /**
+ * @param {object} details
+ * An item in the "results" array from AMO's discovery API.
+ */
+ constructor(details) {
+ // Reuse AddonRepository._parseAddon to have the AMO response parsing logic
+ // in one place.
+ let repositoryAddon = AddonRepository._parseAddon(details.addon);
+
+ // Note: Any property used by RecommendedAddonCard should appear here.
+ // The property names and values should have the same semantics as
+ // AddonWrapper, to ease the reuse of helper functions in this file.
+ this.id = repositoryAddon.id;
+ this.type = repositoryAddon.type;
+ this.name = repositoryAddon.name;
+ this.screenshots = repositoryAddon.screenshots;
+ this.sourceURI = repositoryAddon.sourceURI;
+ this.creator = repositoryAddon.creator;
+ this.averageRating = repositoryAddon.averageRating;
+
+ this.dailyUsers = details.addon.average_daily_users;
+
+ this.editorialDescription = details.description_text;
+ this.iconURL = details.addon.icon_url;
+ 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(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;
+ }
+
+ // 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(useClientId) {
+ let discoveryApiUrl = new URL(
+ Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL)
+ );
+
+ 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}`);
+ }
+ let { results } = await res.json();
+ return results.map(details => new DiscoAddonWrapper(details));
+ },
+};
+
+class SupportLink extends HTMLAnchorElement {
+ static get observedAttributes() {
+ return ["support-page"];
+ }
+
+ connectedCallback() {
+ this.setHref();
+ this.setAttribute("target", "_blank");
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === "support-page") {
+ this.setHref();
+ }
+ }
+
+ setHref() {
+ let base = SUPPORT_URL + this.getAttribute("support-page");
+ this.href = this.hasAttribute("utmcontent")
+ ? formatUTMParams(this.getAttribute("utmcontent"), base)
+ : base;
+ }
+}
+customElements.define("support-link", SupportLink, { extends: "a" });
+
+class PanelList extends HTMLElement {
+ static get observedAttributes() {
+ return ["open"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ // Ensure that the element is hidden even if its main stylesheet hasn't
+ // loaded yet. On initial load, or with cache disabled, the element could
+ // briefly flicker before the stylesheet is loaded without this.
+ let style = document.createElement("style");
+ style.textContent = `
+ :host(:not([open])) {
+ display: none;
+ }
+ `;
+ this.shadowRoot.appendChild(style);
+ this.shadowRoot.appendChild(importTemplate("panel-list"));
+ }
+
+ connectedCallback() {
+ this.setAttribute("role", "menu");
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name == "open" && newVal != oldVal) {
+ if (this.open) {
+ this.onShow();
+ } else {
+ this.onHide();
+ }
+ }
+ }
+
+ get open() {
+ return this.hasAttribute("open");
+ }
+
+ set open(val) {
+ this.toggleAttribute("open", val);
+ }
+
+ show(triggeringEvent) {
+ this.triggeringEvent = triggeringEvent;
+ this.open = true;
+ }
+
+ hide(triggeringEvent) {
+ let openingEvent = this.triggeringEvent;
+ this.triggeringEvent = triggeringEvent;
+ this.open = false;
+ // Refocus the button that opened the menu if we have one.
+ if (openingEvent && openingEvent.target) {
+ openingEvent.target.focus();
+ }
+ }
+
+ toggle(triggeringEvent) {
+ if (this.open) {
+ this.hide(triggeringEvent);
+ } else {
+ this.show(triggeringEvent);
+ }
+ }
+
+ async setAlign() {
+ // Set the showing attribute to hide the panel until its alignment is set.
+ this.setAttribute("showing", "true");
+ // Tell the parent node to hide any overflow in case the panel extends off
+ // the page before the alignment is set.
+ this.parentNode.style.overflow = "hidden";
+
+ // Wait for a layout flush, then find the bounds.
+ let {
+ anchorHeight,
+ anchorLeft,
+ anchorTop,
+ anchorWidth,
+ panelHeight,
+ panelWidth,
+ winHeight,
+ winScrollY,
+ winScrollX,
+ winWidth,
+ } = await new Promise(resolve => {
+ this.style.left = 0;
+ this.style.top = 0;
+
+ requestAnimationFrame(() =>
+ setTimeout(() => {
+ let anchorNode =
+ (this.triggeringEvent && this.triggeringEvent.target) ||
+ this.parentNode;
+ // Use y since top is reserved.
+ let anchorBounds = window.windowUtils.getBoundsWithoutFlushing(
+ anchorNode
+ );
+ let panelBounds = window.windowUtils.getBoundsWithoutFlushing(this);
+ resolve({
+ anchorHeight: anchorBounds.height,
+ anchorLeft: anchorBounds.left,
+ anchorTop: anchorBounds.top,
+ anchorWidth: anchorBounds.width,
+ panelHeight: panelBounds.height,
+ panelWidth: panelBounds.width,
+ winHeight: innerHeight,
+ winWidth: innerWidth,
+ winScrollX: scrollX,
+ winScrollY: scrollY,
+ });
+ }, 0)
+ );
+ });
+
+ // Calculate the left/right alignment.
+ let align;
+ let leftOffset;
+ // The tip of the arrow is 25px from the edge of the panel,
+ // but 26px looks right.
+ let arrowOffset = 26;
+ let leftAlignX = anchorLeft + anchorWidth / 2 - arrowOffset;
+ let rightAlignX = anchorLeft + anchorWidth / 2 - panelWidth + arrowOffset;
+ if (Services.locale.isAppLocaleRTL) {
+ // Prefer aligning on the right.
+ align = rightAlignX < 0 ? "left" : "right";
+ } else {
+ // Prefer aligning on the left.
+ align = leftAlignX + panelWidth > winWidth ? "right" : "left";
+ }
+ leftOffset = align === "left" ? leftAlignX : rightAlignX;
+
+ let bottomAlignY = anchorTop + anchorHeight;
+ let valign;
+ let topOffset;
+ if (bottomAlignY + panelHeight > winHeight) {
+ topOffset = anchorTop - panelHeight;
+ valign = "top";
+ } else {
+ topOffset = bottomAlignY;
+ valign = "bottom";
+ }
+
+ // Set the alignments and show the panel.
+ this.setAttribute("align", align);
+ this.setAttribute("valign", valign);
+ this.parentNode.style.overflow = "";
+
+ this.style.left = `${leftOffset + winScrollX}px`;
+ this.style.top = `${topOffset + winScrollY}px`;
+
+ this.removeAttribute("showing");
+ }
+
+ addHideListeners() {
+ // Hide when a panel-item is clicked in the list.
+ this.addEventListener("click", this);
+ document.addEventListener("keydown", this);
+ // Hide when a click is initiated outside the panel.
+ document.addEventListener("mousedown", this);
+ // Hide if focus changes and the panel isn't in focus.
+ document.addEventListener("focusin", this);
+ // Reset or focus tracking, we treat the first focusin differently.
+ this.focusHasChanged = false;
+ // Hide on resize, scroll or losing window focus.
+ window.addEventListener("resize", this);
+ window.addEventListener("scroll", this);
+ window.addEventListener("blur", this);
+ }
+
+ removeHideListeners() {
+ this.removeEventListener("click", this);
+ document.removeEventListener("keydown", this);
+ document.removeEventListener("mousedown", this);
+ document.removeEventListener("focusin", this);
+ window.removeEventListener("resize", this);
+ window.removeEventListener("scroll", this);
+ window.removeEventListener("blur", this);
+ }
+
+ handleEvent(e) {
+ // Ignore the event if it caused the panel to open.
+ if (e == this.triggeringEvent) {
+ return;
+ }
+
+ switch (e.type) {
+ case "resize":
+ case "scroll":
+ case "blur":
+ this.hide();
+ break;
+ case "click":
+ if (e.target.tagName == "PANEL-ITEM") {
+ this.hide();
+ } else {
+ // Avoid falling through to the default click handler of the
+ // add-on card, which would expand the add-on card.
+ e.stopPropagation();
+ }
+ break;
+ case "keydown":
+ if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") {
+ // Ignore tabbing with a modifer other than shift.
+ if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) {
+ return;
+ }
+
+ // Don't scroll the page or let the regular tab order take effect.
+ e.preventDefault();
+
+ // Keep moving to the next/previous element sibling until we find a
+ // panel-item that isn't hidden.
+ let moveForward =
+ e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey);
+
+ // If the menu is opened with the mouse, the active element might be
+ // somewhere else in the document. In that case we should ignore it
+ // to avoid walking unrelated DOM nodes.
+ this.focusWalker.currentNode = this.contains(document.activeElement)
+ ? document.activeElement
+ : this;
+ let nextItem = moveForward
+ ? this.focusWalker.nextNode()
+ : this.focusWalker.previousNode();
+
+ // If the next item wasn't found, try looping to the top/bottom.
+ if (!nextItem) {
+ this.focusWalker.currentNode = this;
+ if (moveForward) {
+ nextItem = this.focusWalker.firstChild();
+ } else {
+ nextItem = this.focusWalker.lastChild();
+ }
+ }
+ break;
+ } else if (e.key === "Escape") {
+ this.hide();
+ } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
+ // Check if any of the children have an accesskey for this letter.
+ let item = this.querySelector(
+ `[accesskey="${e.key.toLowerCase()}"],
+ [accesskey="${e.key.toUpperCase()}"]`
+ );
+ if (item) {
+ item.click();
+ }
+ }
+ break;
+ case "mousedown":
+ case "focusin":
+ // There will be a focusin after the mousedown that opens the panel
+ // using the mouse. Ignore the first focusin event if it's on the
+ // triggering target.
+ if (
+ this.triggeringEvent &&
+ e.target == this.triggeringEvent.target &&
+ !this.focusHasChanged
+ ) {
+ this.focusHasChanged = true;
+ // If the target isn't in the panel, hide. This will close when focus
+ // moves out of the panel, or there's a click started outside the
+ // panel.
+ } else if (!e.target || e.target.closest("panel-list") != this) {
+ this.hide();
+ // Just record that there was a focusin event.
+ } else {
+ this.focusHasChanged = true;
+ }
+ break;
+ }
+ }
+
+ /**
+ * A TreeWalker that can be used to focus elements. The returned element will
+ * be the element that has gained focus based on the requested movement
+ * through the tree.
+ *
+ * Example:
+ *
+ * this.focusWalker.currentNode = this;
+ * // Focus and get the first focusable child.
+ * let focused = this.focusWalker.nextNode();
+ * // Focus the second focusable child.
+ * this.focusWalker.nextNode();
+ */
+ get focusWalker() {
+ if (!this._focusWalker) {
+ this._focusWalker = document.createTreeWalker(
+ this,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: node => {
+ // No need to look at hidden nodes.
+ if (node.hidden) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // Focus the node, if it worked then this is the node we want.
+ node.focus();
+ if (node === document.activeElement) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+
+ // Continue into child nodes if the parent couldn't be focused.
+ return NodeFilter.FILTER_SKIP;
+ },
+ }
+ );
+ }
+ return this._focusWalker;
+ }
+
+ async onShow() {
+ this.sendEvent("showing");
+ this.addHideListeners();
+ await this.setAlign();
+
+ // Wait until the next paint for the alignment to be set and panel to be
+ // visible.
+ requestAnimationFrame(() => {
+ // Focus the first focusable panel-item.
+ this.focusWalker.currentNode = this;
+ this.focusWalker.nextNode();
+
+ this.sendEvent("shown");
+ });
+ }
+
+ onHide() {
+ requestAnimationFrame(() => this.sendEvent("hidden"));
+ this.removeHideListeners();
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+}
+customElements.define("panel-list", PanelList);
+
+class PanelItem extends HTMLElement {
+ static get observedAttributes() {
+ return ["accesskey"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+
+ let style = document.createElement("link");
+ style.rel = "stylesheet";
+ style.href = "chrome://mozapps/content/extensions/panel-item.css";
+
+ this.button = document.createElement("button");
+ this.button.setAttribute("role", "menuitem");
+
+ // Use a XUL label element to show the accesskey.
+ this.label = document.createXULElement("label");
+ this.button.appendChild(this.label);
+
+ let supportLinkSlot = document.createElement("slot");
+ supportLinkSlot.name = "support-link";
+
+ let defaultSlot = document.createElement("slot");
+ defaultSlot.style.display = "none";
+
+ this.shadowRoot.append(style, this.button, supportLinkSlot, defaultSlot);
+
+ // When our content changes, move the text into the label. It doesn't work
+ // with a <slot>, unfortunately.
+ new MutationObserver(() => {
+ this.label.textContent = defaultSlot
+ .assignedNodes()
+ .map(node => node.textContent)
+ .join("");
+ }).observe(this, { characterData: true, childList: true, subtree: true });
+ }
+
+ connectedCallback() {
+ this.panel = this.closest("panel-list");
+
+ if (this.panel) {
+ this.panel.addEventListener("hidden", this);
+ this.panel.addEventListener("shown", this);
+ }
+ }
+
+ disconnectedCallback() {
+ if (this.panel) {
+ this.panel.removeEventListener("hidden", this);
+ this.panel.removeEventListener("shown", this);
+ this.panel = null;
+ }
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === "accesskey") {
+ // Bug 1037709 - Accesskey doesn't work in shadow DOM.
+ // Ideally we'd have the accesskey set in shadow DOM, and on
+ // attributeChangedCallback we'd just update the shadow DOM accesskey.
+
+ // Skip this change event if we caused it.
+ if (this._modifyingAccessKey) {
+ this._modifyingAccessKey = false;
+ return;
+ }
+
+ this.label.accessKey = newVal || "";
+
+ // Bug 1588156 - Accesskey is not ignored for hidden non-input elements.
+ // Since the accesskey won't be ignored, we need to remove it ourselves
+ // when the panel is closed, and move it back when it opens.
+ if (!this.panel || !this.panel.open) {
+ // When the panel isn't open, just store the key for later.
+ this._accessKey = newVal || null;
+ this._modifyingAccessKey = true;
+ this.accessKey = "";
+ } else {
+ this._accessKey = null;
+ }
+ }
+ }
+
+ get disabled() {
+ return this.button.hasAttribute("disabled");
+ }
+
+ set disabled(val) {
+ this.button.toggleAttribute("disabled", val);
+ }
+
+ get checked() {
+ return this.hasAttribute("checked");
+ }
+
+ set checked(val) {
+ this.toggleAttribute("checked", val);
+ }
+
+ focus() {
+ this.button.focus();
+ }
+
+ handleEvent(e) {
+ // Bug 1588156 - Accesskey is not ignored for hidden non-input elements.
+ // Since the accesskey won't be ignored, we need to remove it ourselves
+ // when the panel is closed, and move it back when it opens.
+ switch (e.type) {
+ case "shown":
+ if (this._accessKey) {
+ this.accessKey = this._accessKey;
+ this._accessKey = null;
+ }
+ break;
+ case "hidden":
+ if (this.accessKey) {
+ this._accessKey = this.accessKey;
+ this._modifyingAccessKey = true;
+ this.accessKey = "";
+ }
+ break;
+ }
+ }
+}
+customElements.define("panel-item", PanelItem);
+
+class SearchAddons extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.input = document.createXULElement("search-textbox");
+ this.input.setAttribute("searchbutton", true);
+ this.input.setAttribute("maxlength", 100);
+ this.input.setAttribute("data-l10n-attrs", "placeholder");
+ document.l10n.setAttributes(this.input, "addons-heading-search-input");
+ this.append(this.input);
+ }
+ this.input.addEventListener("command", this);
+ document.addEventListener("keypress", this);
+ }
+
+ disconnectedCallback() {
+ this.input.removeEventListener("command", this);
+ document.removeEventListener("keypress", this);
+ }
+
+ focus() {
+ this.input.focus();
+ }
+
+ get focusKey() {
+ return this.getAttribute("key");
+ }
+
+ handleEvent(e) {
+ if (e.type === "command") {
+ this.searchAddons(this.value);
+ } else if (e.type === "keypress") {
+ if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ this.focus();
+ } else if (e.key == this.focusKey) {
+ if (e.altKey || e.shiftKey) {
+ return;
+ }
+
+ if (Services.appinfo.OS === "Darwin") {
+ if (e.metaKey && !e.ctrlKey) {
+ this.focus();
+ }
+ } else if (e.ctrlKey && !e.metaKey) {
+ this.focus();
+ }
+ }
+ }
+ }
+
+ get value() {
+ return this.input.value;
+ }
+
+ searchAddons(query) {
+ if (query.length === 0) {
+ return;
+ }
+
+ let url = formatUTMParams(
+ "addons-manager-search",
+ AddonRepository.getSearchURL(query)
+ );
+
+ let browser = getBrowserElement();
+ let chromewin = browser.ownerGlobal;
+ chromewin.openLinkIn(url, "tab", {
+ fromChrome: true,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ value: "search",
+ extra: {
+ type: this.closest("addon-page-header").getAttribute("type"),
+ view: getTelemetryViewName(this),
+ },
+ });
+ }
+}
+customElements.define("search-addons", SearchAddons);
+
+class GlobalWarnings extends MessageBarStackElement {
+ constructor() {
+ super();
+ // This won't change at runtime, but we'll want to fake it in tests.
+ this.inSafeMode = Services.appinfo.inSafeMode;
+ this.globalWarning = null;
+ }
+
+ connectedCallback() {
+ this.refresh();
+ this.addEventListener("click", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ refresh() {
+ if (this.inSafeMode) {
+ this.setWarning("safe-mode");
+ } else if (
+ AddonManager.checkUpdateSecurityDefault &&
+ !AddonManager.checkUpdateSecurity
+ ) {
+ this.setWarning("update-security", { action: true });
+ } else if (!AddonManager.checkCompatibility) {
+ this.setWarning("check-compatibility", { action: true });
+ } else {
+ this.removeWarning();
+ }
+ }
+
+ setWarning(type, opts) {
+ if (
+ this.globalWarning &&
+ this.globalWarning.getAttribute("warning-type") !== type
+ ) {
+ this.removeWarning();
+ }
+ if (!this.globalWarning) {
+ this.globalWarning = document.createElement("message-bar");
+ this.globalWarning.setAttribute("warning-type", type);
+ let textContainer = document.createElement("span");
+ document.l10n.setAttributes(textContainer, `extensions-warning-${type}`);
+ this.globalWarning.appendChild(textContainer);
+ if (opts && opts.action) {
+ let button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ `extensions-warning-${type}-button`
+ );
+ button.setAttribute("action", type);
+ this.globalWarning.appendChild(button);
+ }
+ this.appendChild(this.globalWarning);
+ }
+ }
+
+ removeWarning() {
+ if (this.globalWarning) {
+ this.globalWarning.remove();
+ this.globalWarning = null;
+ }
+ }
+
+ handleEvent(e) {
+ if (e.type === "click") {
+ switch (e.target.getAttribute("action")) {
+ case "update-security":
+ AddonManager.checkUpdateSecurity = true;
+ break;
+ case "check-compatibility":
+ AddonManager.checkCompatibility = true;
+ break;
+ }
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onCompatibilityModeChanged() {
+ this.refresh();
+ }
+
+ onCheckUpdateSecurityChanged() {
+ this.refresh();
+ }
+}
+customElements.define("global-warnings", GlobalWarnings);
+
+class AddonPageHeader extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.appendChild(importTemplate("addon-page-header"));
+ this.heading = this.querySelector(".header-name");
+ this.backButton = this.querySelector(".back-button");
+ this.pageOptionsMenuButton = this.querySelector(
+ '[action="page-options"]'
+ );
+ // The addon-page-options element is outside of this element since this is
+ // position: sticky and that would break the positioning of the menu.
+ this.pageOptionsMenu = document.getElementById(
+ this.getAttribute("page-options-id")
+ );
+ }
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ // Use capture since the event is actually triggered on the internal
+ // panel-list and it doesn't bubble.
+ this.pageOptionsMenu.addEventListener("shown", this, true);
+ this.pageOptionsMenu.addEventListener("hidden", this, true);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.pageOptionsMenu.removeEventListener("shown", this, true);
+ this.pageOptionsMenu.removeEventListener("hidden", this, true);
+ }
+
+ setViewInfo({ type, param }) {
+ this.setAttribute("current-view", type);
+ this.setAttribute("current-param", param);
+ let viewType = type === "list" ? param : type;
+ this.setAttribute("type", viewType);
+
+ this.heading.hidden = viewType === "detail";
+ this.backButton.hidden = viewType !== "detail" && viewType !== "shortcuts";
+
+ let { contentWindow } = getBrowserElement();
+ this.backButton.disabled = !contentWindow.history.state?.previousView;
+
+ if (viewType !== "detail") {
+ document.l10n.setAttributes(this.heading, `${viewType}-heading`);
+ }
+ }
+
+ handleEvent(e) {
+ let { backButton, pageOptionsMenu, pageOptionsMenuButton } = this;
+ if (e.type === "click") {
+ switch (e.target) {
+ case backButton:
+ window.history.back();
+ break;
+ case pageOptionsMenuButton:
+ if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.pageOptionsMenu.toggle(e);
+ }
+ break;
+ }
+ } else if (
+ e.type == "mousedown" &&
+ e.target == pageOptionsMenuButton &&
+ e.button == 0
+ ) {
+ this.pageOptionsMenu.toggle(e);
+ } else if (
+ e.target == pageOptionsMenu.panel &&
+ (e.type == "shown" || e.type == "hidden")
+ ) {
+ this.pageOptionsMenuButton.setAttribute(
+ "aria-expanded",
+ this.pageOptionsMenu.open
+ );
+ }
+ }
+}
+customElements.define("addon-page-header", AddonPageHeader);
+
+class AddonUpdatesMessage extends HTMLElement {
+ static get observedAttributes() {
+ return ["state"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ let style = document.createElement("style");
+ style.textContent = `
+ @import "chrome://global/skin/in-content/common.css";
+ button {
+ margin: 0;
+ }
+ `;
+ this.message = document.createElement("span");
+ this.message.hidden = true;
+ this.button = document.createElement("button");
+ this.button.addEventListener("click", e => {
+ if (e.button === 0) {
+ loadViewFn("updates/available");
+ }
+ });
+ this.button.hidden = true;
+ this.shadowRoot.append(style, this.message, this.button);
+ }
+
+ connectedCallback() {
+ document.l10n.connectRoot(this.shadowRoot);
+ document.l10n.translateFragment(this.shadowRoot);
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === "state" && oldVal !== newVal) {
+ let l10nId = `addon-updates-${newVal}`;
+ switch (newVal) {
+ case "updating":
+ case "installed":
+ case "none-found":
+ this.button.hidden = true;
+ this.message.hidden = false;
+ document.l10n.setAttributes(this.message, l10nId);
+ break;
+ case "manual-updates-found":
+ this.message.hidden = true;
+ this.button.hidden = false;
+ document.l10n.setAttributes(this.button, l10nId);
+ break;
+ }
+ }
+ }
+
+ set state(val) {
+ this.setAttribute("state", val);
+ }
+}
+customElements.define("addon-updates-message", AddonUpdatesMessage);
+
+class AddonPageOptions extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ this.addEventListener("click", this);
+ this.panel.addEventListener("showing", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ this.panel.removeEventListener("showing", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ toggle(...args) {
+ return this.panel.toggle(...args);
+ }
+
+ get open() {
+ return this.panel.open;
+ }
+
+ render() {
+ this.appendChild(importTemplate("addon-page-options"));
+ this.panel = this.querySelector("panel-list");
+ this.installFromFile = this.querySelector('[action="install-from-file"]');
+ this.toggleUpdatesEl = this.querySelector(
+ '[action="set-update-automatically"]'
+ );
+ this.resetUpdatesEl = this.querySelector('[action="reset-update-states"]');
+ this.onUpdateModeChanged();
+ }
+
+ async handleEvent(e) {
+ if (e.type === "click") {
+ e.target.disabled = true;
+ try {
+ await this.onClick(e);
+ } finally {
+ e.target.disabled = false;
+ }
+ } else if (e.type === "showing") {
+ this.installFromFile.hidden = !XPINSTALL_ENABLED;
+ }
+ }
+
+ async onClick(e) {
+ switch (e.target.getAttribute("action")) {
+ case "check-for-updates":
+ await this.checkForUpdates();
+ break;
+ case "view-recent-updates":
+ loadViewFn("updates/recent");
+ break;
+ case "install-from-file":
+ if (XPINSTALL_ENABLED) {
+ installAddonsFromFilePicker().then(installs => {
+ for (let install of installs) {
+ this.recordActionEvent({
+ action: "installFromFile",
+ value: install.installId,
+ });
+ }
+ });
+ }
+ break;
+ case "debug-addons":
+ this.openAboutDebugging();
+ break;
+ case "set-update-automatically":
+ await this.toggleAutomaticUpdates();
+ break;
+ case "reset-update-states":
+ await this.resetAutomaticUpdates();
+ break;
+ case "manage-shortcuts":
+ loadViewFn("shortcuts/shortcuts");
+ break;
+ }
+ }
+
+ async checkForUpdates(e) {
+ this.recordActionEvent({ action: "checkForUpdates" });
+ let message = document.getElementById("updates-message");
+ message.state = "updating";
+ message.hidden = false;
+ let { installed, pending } = await checkForUpdates();
+ if (pending > 0) {
+ message.state = "manual-updates-found";
+ } else if (installed > 0) {
+ message.state = "installed";
+ } else {
+ message.state = "none-found";
+ }
+ }
+
+ openAboutDebugging() {
+ let mainWindow = window.windowRoot.ownerGlobal;
+ this.recordLinkEvent({ value: "about:debugging" });
+ if ("switchToTabHavingURI" in mainWindow) {
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ mainWindow.switchToTabHavingURI(
+ `about:debugging#/runtime/this-firefox`,
+ true,
+ {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: principal,
+ }
+ );
+ }
+ }
+
+ automaticUpdatesEnabled() {
+ return AddonManager.updateEnabled && AddonManager.autoUpdateDefault;
+ }
+
+ toggleAutomaticUpdates() {
+ if (!this.automaticUpdatesEnabled()) {
+ // One or both of the prefs is false, i.e. the checkbox is not
+ // checked. Now toggle both to true. If the user wants us to
+ // auto-update add-ons, we also need to auto-check for updates.
+ AddonManager.updateEnabled = true;
+ AddonManager.autoUpdateDefault = true;
+ } else {
+ // Both prefs are true, i.e. the checkbox is checked.
+ // Toggle the auto pref to false, but don't touch the enabled check.
+ AddonManager.autoUpdateDefault = false;
+ }
+ // Record telemetry for changing the update policy.
+ let updatePolicy = [];
+ if (AddonManager.autoUpdateDefault) {
+ updatePolicy.push("default");
+ }
+ if (AddonManager.updateEnabled) {
+ updatePolicy.push("enabled");
+ }
+ this.recordActionEvent({
+ action: "setUpdatePolicy",
+ value: updatePolicy.join(","),
+ });
+ }
+
+ async resetAutomaticUpdates() {
+ let addons = await AddonManager.getAllAddons();
+ for (let addon of addons) {
+ if ("applyBackgroundUpdates" in addon) {
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+ }
+ }
+ this.recordActionEvent({ action: "resetUpdatePolicy" });
+ }
+
+ getTelemetryViewName() {
+ return getTelemetryViewName(document.getElementById("page-header"));
+ }
+
+ recordActionEvent({ action, value }) {
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ view: this.getTelemetryViewName(),
+ action,
+ addon: this.addon,
+ value,
+ });
+ }
+
+ recordLinkEvent({ value }) {
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ value,
+ extra: {
+ view: this.getTelemetryViewName(),
+ },
+ });
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onUpdateModeChanged() {
+ let updatesEnabled = this.automaticUpdatesEnabled();
+ this.toggleUpdatesEl.checked = updatesEnabled;
+ let resetType = updatesEnabled ? "automatic" : "manual";
+ let resetStringId = `addon-updates-reset-updates-to-${resetType}`;
+ document.l10n.setAttributes(this.resetUpdatesEl, resetStringId);
+ }
+}
+customElements.define("addon-page-options", AddonPageOptions);
+
+class CategoryButton extends HTMLButtonElement {
+ connectedCallback() {
+ if (this.childElementCount != 0) {
+ return;
+ }
+
+ // Make sure the aria-selected attribute is set correctly.
+ this.selected = this.hasAttribute("selected");
+
+ document.l10n.setAttributes(this, `addon-category-${this.name}-title`);
+
+ let text = document.createElement("span");
+ text.classList.add("category-name");
+ document.l10n.setAttributes(text, `addon-category-${this.name}`);
+
+ this.append(text);
+ }
+
+ load() {
+ loadViewFn(this.viewId);
+ }
+
+ get isVisible() {
+ return true;
+ }
+
+ get badgeCount() {
+ return parseInt(this.getAttribute("badge-count"), 10) || 0;
+ }
+
+ set badgeCount(val) {
+ let count = parseInt(val, 10);
+ if (count) {
+ this.setAttribute("badge-count", count);
+ } else {
+ this.removeAttribute("badge-count");
+ }
+ }
+
+ get selected() {
+ return this.hasAttribute("selected");
+ }
+
+ set selected(val) {
+ this.toggleAttribute("selected", !!val);
+ this.setAttribute("aria-selected", !!val);
+ }
+
+ get name() {
+ return this.getAttribute("name");
+ }
+
+ get viewId() {
+ return this.getAttribute("viewid");
+ }
+
+ // Just setting the hidden attribute isn't enough in case the category gets
+ // hidden while about:addons is closed since it could be the last active view
+ // which will unhide the button when it gets selected.
+ get defaultHidden() {
+ return this.hasAttribute("default-hidden");
+ }
+}
+customElements.define("category-button", CategoryButton, { extends: "button" });
+
+class DiscoverButton extends CategoryButton {
+ get isVisible() {
+ return isDiscoverEnabled();
+ }
+}
+customElements.define("discover-button", DiscoverButton, { extends: "button" });
+
+class CategoriesBox extends customElements.get("button-group") {
+ constructor() {
+ super();
+ // This will resolve when the initial category states have been set from
+ // our cached prefs. This is intended for use in testing to verify that we
+ // are caching the previous state.
+ this.promiseRendered = new Promise(resolve => {
+ this._resolveRendered = resolve;
+ });
+ // This will resolve when the final category states have been set by
+ // checking the AddonManager state and showing/hiding categories. The page
+ // won't be "initialized" until this resolves.
+ this.promiseInitialized = new Promise(resolve => {
+ this._resolveInitialized = resolve;
+ });
+ }
+
+ async initialize() {
+ let addonTypesObjects = AddonManager.addonTypes;
+ let addonTypes = new Set();
+ for (let type in addonTypesObjects) {
+ addonTypes.add(type);
+ }
+
+ let hiddenTypes = new Set([]);
+
+ for (let button of this.children) {
+ let { defaultHidden, name } = button;
+ button.hidden =
+ !button.isVisible || (defaultHidden && this.shouldHideCategory(name));
+
+ if (defaultHidden && addonTypes.has(name)) {
+ hiddenTypes.add(name);
+ }
+ }
+
+ let hiddenUpdated;
+ if (hiddenTypes.size) {
+ hiddenUpdated = this.updateHiddenCategories(Array.from(hiddenTypes));
+ }
+
+ this.updateAvailableCount();
+
+ this.addEventListener("click", e => {
+ let button = e.target.closest("[viewid]");
+ if (button) {
+ button.load();
+ }
+ });
+ this.addEventListener("button-group:key-selected", e => {
+ this.activeChild.load();
+ });
+
+ AddonManagerListenerHandler.addListener(this);
+
+ this._resolveRendered();
+ await hiddenUpdated;
+ this._resolveInitialized();
+ }
+
+ get initialViewId() {
+ let viewId = Services.prefs.getStringPref(PREF_UI_LASTCATEGORY, "");
+ // If the pref value is a valid top-level view then use that viewId.
+ if (this.getButtonByViewId(viewId)) {
+ return viewId;
+ }
+ // Otherwise, use the first viewId that can be shown.
+ for (let button of this.children) {
+ if (!button.defaultHidden && !button.hidden && button.isVisible) {
+ return button.viewId;
+ }
+ }
+ // If there aren't any available views then there's nothing to load. This
+ // shouldn't happen though since the extension list should always be valid.
+ throw new Error("Couldn't find initial view to load");
+ }
+
+ shouldHideCategory(name) {
+ return Services.prefs.getBoolPref(`extensions.ui.${name}.hidden`, true);
+ }
+
+ setShouldHideCategory(name, hide) {
+ Services.prefs.setBoolPref(`extensions.ui.${name}.hidden`, hide);
+ }
+
+ getButtonByName(name) {
+ return this.querySelector(`[name="${name}"]`);
+ }
+
+ getButtonByViewId(id) {
+ return this.querySelector(`[viewid="${id}"]`);
+ }
+
+ get selectedChild() {
+ return this._selectedChild;
+ }
+
+ set selectedChild(node) {
+ if (node && this.contains(node)) {
+ if (this._selectedChild) {
+ this._selectedChild.selected = false;
+ }
+ this._selectedChild = node;
+ this._selectedChild.selected = true;
+ }
+ }
+
+ select(viewId) {
+ let button = this.querySelector(`[viewid="${viewId}"]`);
+ if (button) {
+ this.activeChild = button;
+ this.selectedChild = button;
+ button.hidden = false;
+ Services.prefs.setStringPref(PREF_UI_LASTCATEGORY, viewId);
+ }
+ }
+
+ selectType(type) {
+ this.select(`addons://list/${type}`);
+ }
+
+ onInstalled(addon) {
+ let button = this.getButtonByName(addon.type);
+ if (button) {
+ button.hidden = false;
+ this.setShouldHideCategory(addon.type, false);
+ }
+ this.updateAvailableCount();
+ }
+
+ onInstallStarted(install) {
+ this.onInstalled(install);
+ }
+
+ onNewInstall() {
+ this.updateAvailableCount();
+ }
+
+ onInstallPostponed() {
+ this.updateAvailableCount();
+ }
+
+ onInstallCancelled() {
+ this.updateAvailableCount();
+ }
+
+ async updateAvailableCount() {
+ let installs = await AddonManager.getAllInstalls();
+ var count = installs.filter(install => {
+ return isManualUpdate(install) && !install.installed;
+ }).length;
+ let availableButton = this.getButtonByName("available-updates");
+ availableButton.hidden = !availableButton.selected && count == 0;
+ availableButton.badgeCount = count;
+ }
+
+ async updateHiddenCategories(types) {
+ let hiddenTypes = new Set(types);
+ let getAddons = AddonManager.getAddonsByTypes(types);
+ let getInstalls = AddonManager.getInstallsByTypes(types);
+
+ for (let addon of await getAddons) {
+ if (addon.hidden) {
+ continue;
+ }
+
+ this.onInstalled(addon);
+ hiddenTypes.delete(addon.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let install of await getInstalls) {
+ if (
+ install.existingAddon ||
+ install.state == AddonManager.STATE_AVAILABLE
+ ) {
+ continue;
+ }
+
+ this.onInstalled(install);
+ hiddenTypes.delete(install.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let type of hiddenTypes) {
+ let button = this.getButtonByName(type);
+ if (button.selected) {
+ // Cancel the load if this view should be hidden.
+ replaceWithDefaultViewFn();
+ }
+ this.setShouldHideCategory(type, true);
+ button.hidden = true;
+ }
+ }
+}
+customElements.define("categories-box", CategoriesBox);
+
+class SidebarFooter extends HTMLElement {
+ connectedCallback() {
+ let list = document.createElement("ul");
+ list.classList.add("sidebar-footer-list");
+
+ let prefsItem = document.createElement("li");
+ prefsItem.classList.add("sidebar-footer-item");
+ let prefsLink = document.createElement("a");
+ prefsLink.classList.add("sidebar-footer-link", "preferences-icon");
+ prefsLink.id = "preferencesButton";
+ prefsLink.href = "about:preferences";
+ document.l10n.setAttributes(prefsLink, "sidebar-preferences-button-title");
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ prefsLink.addEventListener("click", e => {
+ e.preventDefault();
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ value: "about:preferences",
+ extra: {
+ view: getTelemetryViewName(this),
+ },
+ });
+ windowRoot.ownerGlobal.switchToTabHavingURI("about:preferences", true, {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: systemPrincipal,
+ });
+ });
+ let prefsText = document.createElement("span");
+ prefsText.classList.add("sidebar-footer-link-text");
+ document.l10n.setAttributes(prefsText, "preferences");
+ prefsLink.append(prefsText);
+ prefsItem.append(prefsLink);
+
+ let supportItem = document.createElement("li");
+ supportItem.classList.add("sidebar-footer-item");
+ let supportLink = document.createElement("a", { is: "support-link" });
+ document.l10n.setAttributes(supportLink, "sidebar-help-button-title");
+ supportLink.classList.add("sidebar-footer-link", "help-icon");
+ supportLink.id = "help-button";
+ supportLink.setAttribute("support-page", "addons-help");
+ supportLink.addEventListener("click", e => {
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ value: "support",
+ extra: {
+ view: getTelemetryViewName(this),
+ },
+ });
+ });
+ let supportText = document.createElement("span");
+ supportText.classList.add("sidebar-footer-link-text");
+ document.l10n.setAttributes(supportText, "help-button");
+ supportLink.append(supportText);
+ supportItem.append(supportLink);
+
+ list.append(prefsItem, supportItem);
+ this.append(list);
+ }
+}
+customElements.define("sidebar-footer", SidebarFooter, { extends: "footer" });
+
+class AddonOptions extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ }
+
+ get panel() {
+ return this.querySelector("panel-list");
+ }
+
+ updateSeparatorsVisibility() {
+ let lastSeparator;
+ let elWasVisible = false;
+
+ // Collect the panel-list children that are not already hidden.
+ const children = Array.from(this.panel.children).filter(el => !el.hidden);
+
+ for (let child of children) {
+ if (child.tagName == "PANEL-ITEM-SEPARATOR") {
+ child.hidden = !elWasVisible;
+ if (!child.hidden) {
+ lastSeparator = child;
+ }
+ elWasVisible = false;
+ } else {
+ elWasVisible = true;
+ }
+ }
+ if (!elWasVisible && lastSeparator) {
+ lastSeparator.hidden = true;
+ }
+ }
+
+ get template() {
+ return "addon-options";
+ }
+
+ render() {
+ this.appendChild(importTemplate(this.template));
+ }
+
+ setElementState(el, card, addon, updateInstall) {
+ switch (el.getAttribute("action")) {
+ case "remove":
+ if (hasPermission(addon, "uninstall")) {
+ // Regular add-on that can be uninstalled.
+ el.disabled = false;
+ el.hidden = false;
+ document.l10n.setAttributes(el, "remove-addon-button");
+ } else if (addon.isBuiltin) {
+ // Likely the built-in themes, can't be removed, that's fine.
+ el.hidden = true;
+ } else {
+ // Likely sideloaded, mention that it can't be removed with a link.
+ el.hidden = false;
+ el.disabled = true;
+ if (!el.querySelector('[slot="support-link"]')) {
+ let link = document.createElement("a", { is: "support-link" });
+ link.setAttribute("data-l10n-name", "link");
+ link.setAttribute("support-page", "cant-remove-addon");
+ link.setAttribute("slot", "support-link");
+ el.appendChild(link);
+ document.l10n.setAttributes(el, "remove-addon-disabled-button");
+ }
+ }
+ break;
+ case "report":
+ el.hidden = !isAbuseReportSupported(addon);
+ break;
+ case "install-update":
+ el.hidden = !updateInstall;
+ break;
+ case "expand":
+ el.hidden = card.expanded;
+ break;
+ case "preferences":
+ el.hidden =
+ getOptionsType(addon) !== "tab" &&
+ (getOptionsType(addon) !== "inline" || card.expanded);
+ if (!el.hidden) {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ el.hidden = !allowed;
+ });
+ }
+ break;
+ }
+ }
+
+ update(card, addon, updateInstall) {
+ for (let el of this.items) {
+ this.setElementState(el, card, addon, updateInstall);
+ }
+
+ // Update the separators visibility based on the updated visibility
+ // of the actions in the panel-list.
+ this.updateSeparatorsVisibility();
+ }
+
+ get items() {
+ return this.querySelectorAll("panel-item");
+ }
+
+ get visibleItems() {
+ return Array.from(this.items).filter(item => !item.hidden);
+ }
+}
+customElements.define("addon-options", AddonOptions);
+
+class PluginOptions extends AddonOptions {
+ get template() {
+ return "plugin-options";
+ }
+
+ setElementState(el, card, addon) {
+ const userDisabledStates = {
+ "ask-to-activate": AddonManager.STATE_ASK_TO_ACTIVATE,
+ "always-activate": false,
+ "never-activate": true,
+ };
+ const action = el.getAttribute("action");
+ if (action in userDisabledStates) {
+ let userDisabled = userDisabledStates[action];
+ el.checked = addon.userDisabled === userDisabled;
+ let resultProp =
+ action == "always-activate" && addon.isFlashPlugin
+ ? "hidden"
+ : "disabled";
+ el[resultProp] = !(el.checked || hasPermission(addon, action));
+ } else {
+ super.setElementState(el, card, addon);
+ }
+ }
+}
+customElements.define("plugin-options", PluginOptions);
+
+class FiveStarRating extends HTMLElement {
+ static get observedAttributes() {
+ return ["rating"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.append(importTemplate("five-star-rating"));
+ }
+
+ set rating(v) {
+ this.setAttribute("rating", v);
+ }
+
+ get rating() {
+ let v = parseFloat(this.getAttribute("rating"), 10);
+ if (v >= 0 && v <= 5) {
+ return v;
+ }
+ return 0;
+ }
+
+ get ratingBuckets() {
+ // 0 <= x < 0.25 = empty
+ // 0.25 <= x < 0.75 = half
+ // 0.75 <= x <= 1 = full
+ // ... et cetera, until x <= 5.
+ let { rating } = this;
+ return [0, 1, 2, 3, 4].map(ratingStart => {
+ let distanceToFull = rating - ratingStart;
+ if (distanceToFull < 0.25) {
+ return "empty";
+ }
+ if (distanceToFull < 0.75) {
+ return "half";
+ }
+ return "full";
+ });
+ }
+
+ connectedCallback() {
+ this.renderRating();
+ }
+
+ attributeChangedCallback() {
+ this.renderRating();
+ }
+
+ renderRating() {
+ let starElements = this.shadowRoot.querySelectorAll(".rating-star");
+ for (let [i, part] of this.ratingBuckets.entries()) {
+ starElements[i].setAttribute("fill", part);
+ }
+ document.l10n.setAttributes(this, "five-star-rating", {
+ rating: this.rating,
+ });
+ }
+}
+customElements.define("five-star-rating", FiveStarRating);
+
+class ContentSelectDropdown extends HTMLElement {
+ connectedCallback() {
+ if (this.children.length) {
+ return;
+ }
+ // This creates the menulist and menupopup elements needed for the inline
+ // browser to support <select> elements and context menus.
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+ <menupopup rolluponmousewheel="true" activateontab="true"
+ position="after_start" level="parent"/>
+ </menulist>
+ `)
+ );
+ }
+}
+customElements.define("content-select-dropdown", ContentSelectDropdown);
+
+class ProxyContextMenu extends HTMLElement {
+ openPopupAtScreen(...args) {
+ // prettier-ignore
+ const parentContextMenuPopup =
+ windowRoot.ownerGlobal.document.getElementById("contentAreaContextMenu");
+ return parentContextMenuPopup.openPopupAtScreen(...args);
+ }
+}
+customElements.define("proxy-context-menu", ProxyContextMenu);
+
+class InlineOptionsBrowser extends HTMLElement {
+ constructor() {
+ super();
+ // Force the options_ui remote browser to recompute window.mozInnerScreenX
+ // and window.mozInnerScreenY when the "addon details" page has been
+ // scrolled (See Bug 1390445 for rationale).
+ // Also force a repaint to fix an issue where the click location was
+ // getting out of sync (see bug 1548687).
+ this.updatePositionTask = new DeferredTask(() => {
+ if (this.browser && this.browser.isRemoteBrowser) {
+ // Select boxes can appear in the wrong spot after scrolling, this will
+ // clear that up. Bug 1390445.
+ this.browser.frameLoader.requestUpdatePosition();
+ }
+ }, 100);
+ }
+
+ connectedCallback() {
+ window.addEventListener("scroll", this, true);
+ top.browsingContext.embedderElement.addEventListener(
+ "FullZoomChange",
+ this
+ );
+ top.browsingContext.embedderElement.addEventListener(
+ "TextZoomChange",
+ this
+ );
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener("scroll", this, true);
+ top.browsingContext.embedderElement.removeEventListener(
+ "FullZoomChange",
+ this
+ );
+ top.browsingContext.embedderElement.removeEventListener(
+ "TextZoomChange",
+ this
+ );
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "scroll":
+ return this.updatePositionTask.arm();
+ case "FullZoomChange":
+ case "TextZoomChange":
+ return this.maybeUpdateZoom();
+ }
+ return undefined;
+ }
+
+ maybeUpdateZoom() {
+ let bc = this.browser?.browsingContext;
+ let topBc = top.browsingContext;
+ if (!bc || !topBc) {
+ return;
+ }
+ // Use the same full-zoom as our top window.
+ bc.fullZoom = topBc.fullZoom;
+ bc.textZoom = topBc.textZoom;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ destroyBrowser() {
+ this.textContent = "";
+ }
+
+ ensureBrowserCreated() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon required to create inline options");
+ }
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("id", "addon-inline-options");
+ browser.setAttribute("transparent", "true");
+ browser.setAttribute("forcemessagemanager", "true");
+ browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+
+ // The outer about:addons document listens for key presses to focus
+ // the search box when / is pressed. But if we're focused inside an
+ // options page, don't let those keypresses steal focus.
+ browser.addEventListener("keypress", event => {
+ event.stopPropagation();
+ });
+
+ let { optionsURL, optionsBrowserStyle } = addon;
+ if (addon.isWebExtension) {
+ let policy = ExtensionParent.WebExtensionPolicy.getByID(addon.id);
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ policy.browsingContextGroupId
+ );
+ }
+
+ let readyPromise;
+ let remoteSubframes = window.docShell.QueryInterface(Ci.nsILoadContext)
+ .useRemoteSubframes;
+ // For now originAttributes have no effect, which will change if the
+ // optionsURL becomes anything but moz-extension* or we start considering
+ // OA for extensions.
+ var oa = E10SUtils.predictOriginAttributes({ browser });
+ let loadRemote = E10SUtils.canLoadURIInRemoteType(
+ optionsURL,
+ remoteSubframes,
+ E10SUtils.EXTENSION_REMOTE_TYPE,
+ oa
+ );
+ if (loadRemote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+
+ readyPromise = promiseEvent("XULFrameLoaderCreated", browser);
+ } else {
+ readyPromise = promiseEvent("load", browser, true);
+ }
+
+ let stack = document.createXULElement("stack");
+ stack.classList.add("inline-options-stack");
+ stack.appendChild(browser);
+ this.appendChild(stack);
+ this.browser = browser;
+
+ // Force bindings to apply synchronously.
+ browser.clientTop;
+
+ await readyPromise;
+
+ this.maybeUpdateZoom();
+
+ if (!browser.messageManager) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ return;
+ }
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+ await new Promise(resolve => {
+ let messageListener = {
+ receiveMessage({ name, data }) {
+ if (name === "Extension:BrowserResized") {
+ browser.style.height = `${data.height}px`;
+ } else if (name === "Extension:BrowserContentLoaded") {
+ resolve();
+ }
+ },
+ };
+
+ let mm = browser.messageManager;
+
+ if (!mm) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ resolve();
+ return;
+ }
+
+ mm.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+ mm.addMessageListener("Extension:BrowserContentLoaded", messageListener);
+ mm.addMessageListener("Extension:BrowserResized", messageListener);
+
+ let browserOptions = {
+ fixedWidth: true,
+ isInline: true,
+ };
+
+ if (optionsBrowserStyle) {
+ browserOptions.stylesheets = extensionStylesheets;
+ }
+
+ mm.sendAsyncMessage("Extension:InitBrowser", browserOptions);
+
+ // prettier-ignore
+ browser.loadURI(optionsURL, {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ });
+ }
+}
+customElements.define("inline-options-browser", InlineOptionsBrowser);
+
+class UpdateReleaseNotes extends HTMLElement {
+ connectedCallback() {
+ this.addEventListener("click", this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ }
+
+ handleEvent(e) {
+ // We used to strip links, but ParserUtils.parseFragment() leaves them in,
+ // so just make sure we open them using the null principal in a new tab.
+ if (e.type == "click" && e.target.localName == "a" && e.target.href) {
+ e.preventDefault();
+ e.stopPropagation();
+ windowRoot.ownerGlobal.openWebLinkIn(e.target.href, "tab");
+ }
+ }
+
+ async loadForUri(uri) {
+ // Can't load the release notes without a URL to load.
+ if (!uri || !uri.spec) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ return;
+ }
+
+ // Don't try to load for the same update a second time.
+ if (this.url == uri.spec) {
+ this.dispatchEvent(new CustomEvent("release-notes-cached"));
+ return;
+ }
+
+ // Store the URL to skip the network if loaded again.
+ this.url = uri.spec;
+
+ // Set the loading message before hitting the network.
+ this.setLoadingMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-loading"));
+
+ try {
+ // loadReleaseNotes will fetch and sanitize the release notes.
+ let fragment = await loadReleaseNotes(uri);
+ this.textContent = "";
+ this.appendChild(fragment);
+ this.dispatchEvent(new CustomEvent("release-notes-loaded"));
+ } catch (e) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ }
+ }
+
+ setMessage(id) {
+ this.textContent = "";
+ let message = document.createElement("p");
+ document.l10n.setAttributes(message, id);
+ this.appendChild(message);
+ }
+
+ setLoadingMessage() {
+ this.setMessage("release-notes-loading");
+ }
+
+ setErrorMessage() {
+ this.setMessage("release-notes-error");
+ }
+}
+customElements.define("update-release-notes", UpdateReleaseNotes);
+
+class AddonPermissionsList extends HTMLElement {
+ setAddon(addon) {
+ this.addon = addon;
+ this.render();
+ }
+
+ async render() {
+ let appName = brandBundle.GetStringFromName("brandShortName");
+ let permissions = Extension.formatPermissionStrings(
+ {
+ permissions: this.addon.userPermissions,
+ optionalPermissions: this.addon.optionalPermissions,
+ appName,
+ },
+ browserBundle
+ );
+ let optionalEntries = [
+ ...Object.entries(permissions.optionalPermissions),
+ ...Object.entries(permissions.optionalOrigins),
+ ];
+ let perms = await ExtensionPermissions.get(this.addon.id);
+
+ this.textContent = "";
+ let frag = importTemplate("addon-permissions-list");
+
+ if (permissions.msgs.length) {
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let msg of permissions.msgs) {
+ let item = document.createElement("li");
+ item.classList.add("permission-info", "permission-checked");
+ item.appendChild(document.createTextNode(msg));
+ list.appendChild(item);
+ }
+ }
+ if (optionalEntries.length) {
+ let section = frag.querySelector(".addon-permissions-optional");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let id = 0; id < optionalEntries.length; id++) {
+ let [perm, msg] = optionalEntries[id];
+
+ let type = "permission";
+ if (permissions.optionalOrigins[perm]) {
+ type = "origin";
+ }
+ let item = document.createElement("li");
+ item.classList.add("permission-info");
+
+ let label = document.createElement("label");
+ label.textContent = msg;
+
+ let toggle = document.createElement("input");
+ toggle.id = `permission-${id}`;
+
+ label.setAttribute("for", toggle.id);
+ item.appendChild(label);
+
+ toggle.setAttribute("permission-type", type);
+ toggle.setAttribute("type", "checkbox");
+ if (perms.permissions.includes(perm) || perms.origins.includes(perm)) {
+ toggle.checked = true;
+ item.classList.add("permission-checked");
+ }
+ toggle.setAttribute("permission-key", perm);
+ toggle.setAttribute("action", "toggle-permission");
+ toggle.classList.add("toggle-button");
+ label.appendChild(toggle);
+ list.appendChild(item);
+ }
+ }
+ if (!permissions.msgs.length && !optionalEntries.length) {
+ let row = frag.querySelector(".addon-permissions-empty");
+ row.hidden = false;
+ }
+
+ this.appendChild(frag);
+ }
+}
+customElements.define("addon-permissions-list", AddonPermissionsList);
+
+class AddonDetails extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ this.deck.addEventListener("view-changed", this);
+ }
+
+ disconnectedCallback() {
+ this.inlineOptions.destroyBrowser();
+ this.deck.removeEventListener("view-changed", this);
+ }
+
+ handleEvent(e) {
+ if (e.type == "view-changed" && e.target == this.deck) {
+ switch (this.deck.selectedViewName) {
+ case "release-notes":
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ view: getTelemetryViewName(this),
+ action: "releaseNotes",
+ addon: this.addon,
+ });
+ let releaseNotes = this.querySelector("update-release-notes");
+ let uri = this.releaseNotesUri;
+ if (uri) {
+ releaseNotes.loadForUri(uri);
+ }
+ break;
+ case "preferences":
+ if (getOptionsType(this.addon) == "inline") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ break;
+ }
+
+ // When a details view is rendered again, the default details view is
+ // unconditionally shown. So if any other tab is selected, do not save
+ // the current scroll offset, but start at the top of the page instead.
+ ScrollOffsets.canRestore = this.deck.selectedViewName === "details";
+ }
+ }
+
+ onInstalled() {
+ let policy = WebExtensionPolicy.getByID(this.addon.id);
+ let extension = policy && policy.extension;
+ if (extension && extension.startupReason === "ADDON_UPGRADE") {
+ // Ensure the options browser is recreated when a new version starts.
+ this.extensionShutdown();
+ this.extensionStartup();
+ }
+ }
+
+ onDisabled(addon) {
+ this.extensionShutdown();
+ }
+
+ onEnabled(addon) {
+ this.extensionStartup();
+ }
+
+ extensionShutdown() {
+ this.inlineOptions.destroyBrowser();
+ }
+
+ extensionStartup() {
+ if (this.deck.selectedViewName === "preferences") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+
+ get releaseNotesUri() {
+ let { releaseNotesURI } = getUpdateInstall(this.addon) || this.addon;
+ return releaseNotesURI;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ update() {
+ let { addon } = this;
+
+ // Hide tab buttons that won't have any content.
+ let getButtonByName = name =>
+ this.tabGroup.querySelector(`[name="${name}"]`);
+ let permsBtn = getButtonByName("permissions");
+ permsBtn.hidden = addon.type != "extension";
+ let notesBtn = getButtonByName("release-notes");
+ notesBtn.hidden = !this.releaseNotesUri;
+ let prefsBtn = getButtonByName("preferences");
+ prefsBtn.hidden = getOptionsType(addon) !== "inline";
+ if (prefsBtn.hidden) {
+ if (this.deck.selectedViewName === "preferences") {
+ this.deck.selectedViewName = "details";
+ }
+ } else {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ prefsBtn.hidden = !allowed;
+ });
+ }
+
+ // Hide the tab group if "details" is the only visible button.
+ let tabGroupButtons = this.tabGroup.querySelectorAll(".tab-button");
+ this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => {
+ return button.name == "details" || button.hidden;
+ });
+
+ // Show the update check button if necessary. The button might not exist if
+ // the add-on doesn't support updates.
+ let updateButton = this.querySelector('[action="update-check"]');
+ if (updateButton) {
+ updateButton.hidden =
+ this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon);
+ }
+
+ // Set the value for auto updates.
+ let inputs = this.querySelectorAll(".addon-detail-row-updates input");
+ for (let input of inputs) {
+ input.checked = input.value == addon.applyBackgroundUpdates;
+ }
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-details must be initialized by setAddon");
+ }
+
+ this.textContent = "";
+ this.appendChild(importTemplate("addon-details"));
+
+ this.deck = this.querySelector("named-deck");
+ this.tabGroup = this.querySelector(".tab-group");
+
+ // Set the add-on for the permissions section.
+ this.permissionsList = this.querySelector("addon-permissions-list");
+ this.permissionsList.setAddon(addon);
+
+ // Set the add-on for the preferences section.
+ this.inlineOptions = this.querySelector("inline-options-browser");
+ this.inlineOptions.setAddon(addon);
+
+ // Full description.
+ let description = this.querySelector(".addon-detail-description");
+ if (addon.getFullDescription) {
+ description.appendChild(addon.getFullDescription(document));
+ } else if (addon.fullDescription) {
+ description.appendChild(nl2br(addon.fullDescription));
+ }
+
+ this.querySelector(
+ ".addon-detail-contribute"
+ ).hidden = !addon.contributionURL;
+ this.querySelector(".addon-detail-row-updates").hidden = !hasPermission(
+ addon,
+ "upgrade"
+ );
+
+ // By default, all private browsing rows are hidden. Possibly show one.
+ if (allowPrivateBrowsingByDefault || addon.type != "extension") {
+ // All add-addons of this type are allowed in private browsing mode, so
+ // do not show any UI.
+ } else if (addon.incognito == "not_allowed") {
+ let pbRowNotAllowed = this.querySelector(
+ ".addon-detail-row-private-browsing-disallowed"
+ );
+ pbRowNotAllowed.hidden = false;
+ pbRowNotAllowed.nextElementSibling.hidden = false;
+ } else if (!hasPermission(addon, "change-privatebrowsing")) {
+ let pbRowRequired = this.querySelector(
+ ".addon-detail-row-private-browsing-required"
+ );
+ pbRowRequired.hidden = false;
+ pbRowRequired.nextElementSibling.hidden = false;
+ } else {
+ let pbRow = this.querySelector(".addon-detail-row-private-browsing");
+ pbRow.hidden = false;
+ pbRow.nextElementSibling.hidden = false;
+ let isAllowed = await isAllowedInPrivateBrowsing(addon);
+ pbRow.querySelector(`[value="${isAllowed ? 1 : 0}"]`).checked = true;
+ }
+
+ // Author.
+ let creatorRow = this.querySelector(".addon-detail-row-author");
+ if (addon.creator) {
+ let link = creatorRow.querySelector("a");
+ link.hidden = !addon.creator.url;
+ if (link.hidden) {
+ creatorRow.appendChild(new Text(addon.creator.name));
+ } else {
+ link.href = formatUTMParams(
+ "addons-manager-user-profile-link",
+ addon.creator.url
+ );
+ link.target = "_blank";
+ link.textContent = addon.creator.name;
+ }
+ } else {
+ creatorRow.hidden = true;
+ }
+
+ // Version. Don't show a version for LWTs.
+ let version = this.querySelector(".addon-detail-row-version");
+ if (addon.version && !/@personas\.mozilla\.org/.test(addon.id)) {
+ version.appendChild(new Text(addon.version));
+ } else {
+ version.hidden = true;
+ }
+
+ // Last updated.
+ let updateDate = this.querySelector(".addon-detail-row-lastUpdated");
+ if (addon.updateDate) {
+ let lastUpdated = addon.updateDate.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ updateDate.appendChild(new Text(lastUpdated));
+ } else {
+ updateDate.hidden = true;
+ }
+
+ // Homepage.
+ let homepageRow = this.querySelector(".addon-detail-row-homepage");
+ if (addon.homepageURL) {
+ let homepageURL = homepageRow.querySelector("a");
+ homepageURL.href = addon.homepageURL;
+ homepageURL.textContent = addon.homepageURL;
+ } else {
+ homepageRow.hidden = true;
+ }
+
+ // Rating.
+ let ratingRow = this.querySelector(".addon-detail-row-rating");
+ if (addon.averageRating) {
+ ratingRow.querySelector("five-star-rating").rating = addon.averageRating;
+ let reviews = ratingRow.querySelector("a");
+ reviews.href = formatUTMParams(
+ "addons-manager-reviews-link",
+ addon.reviewURL
+ );
+ document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
+ numberOfReviews: addon.reviewCount,
+ });
+ } else {
+ ratingRow.hidden = true;
+ }
+
+ this.update();
+ }
+
+ showPrefs() {
+ if (getOptionsType(this.addon) == "inline") {
+ this.deck.selectedViewName = "preferences";
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+}
+customElements.define("addon-details", AddonDetails);
+
+/**
+ * A card component for managing an add-on. It should be initialized by setting
+ * the add-on with `setAddon()` before being connected to the document.
+ *
+ * let card = document.createElement("addon-card");
+ * card.setAddon(addon);
+ * document.body.appendChild(card);
+ */
+class AddonCard extends HTMLElement {
+ connectedCallback() {
+ // If we've already rendered we can just update, otherwise render.
+ if (this.children.length) {
+ this.update();
+ } else {
+ this.render();
+ }
+ this.registerListeners();
+ }
+
+ disconnectedCallback() {
+ this.removeListeners();
+ }
+
+ get expanded() {
+ return this.hasAttribute("expanded");
+ }
+
+ set expanded(val) {
+ if (val) {
+ this.setAttribute("expanded", "true");
+ } else {
+ this.removeAttribute("expanded");
+ }
+ }
+
+ get updateInstall() {
+ return this._updateInstall;
+ }
+
+ set updateInstall(install) {
+ this._updateInstall = install;
+ if (this.children.length) {
+ this.update();
+ }
+ }
+
+ get reloading() {
+ return this.hasAttribute("reloading");
+ }
+
+ set reloading(val) {
+ this.toggleAttribute("reloading", val);
+ }
+
+ /**
+ * Set the add-on for this card. The card will be populated based on the
+ * add-on when it is connected to the DOM.
+ *
+ * @param {AddonWrapper} addon The add-on to use.
+ */
+ setAddon(addon) {
+ this.addon = addon;
+ let install = getUpdateInstall(addon);
+ if (
+ install &&
+ (isInState(install, "available") || isInState(install, "postponed"))
+ ) {
+ this.updateInstall = install;
+ } else {
+ this.updateInstall = null;
+ }
+ if (this.children.length) {
+ this.render();
+ }
+ }
+
+ async setAddonPermission(permission, type, action) {
+ let { addon } = this;
+ let origins = [],
+ permissions = [];
+ if (!["add", "remove"].includes(action)) {
+ throw new Error("invalid action for permission change");
+ }
+ if (type == "permission") {
+ if (
+ action == "add" &&
+ !addon.optionalPermissions.permissions.includes(permission)
+ ) {
+ throw new Error("permission missing from manifest");
+ }
+ permissions = [permission];
+ } else if (type == "origin") {
+ if (
+ action == "add" &&
+ !addon.optionalPermissions.origins.includes(permission)
+ ) {
+ throw new Error("origin missing from manifest");
+ }
+ origins = [permission];
+ } else {
+ throw new Error("unknown permission type changed");
+ }
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ExtensionPermissions[action](
+ addon.id,
+ { origins, permissions },
+ policy?.extension
+ );
+ }
+
+ async handleEvent(e) {
+ let { addon } = this;
+ let action = e.target.getAttribute("action");
+
+ if (e.type == "click") {
+ switch (action) {
+ case "toggle-permission":
+ let permission = e.target.getAttribute("permission-key");
+ let type = e.target.getAttribute("permission-type");
+ let fname = e.target.checked ? "add" : "remove";
+ this.setAddonPermission(permission, type, fname);
+ break;
+ case "toggle-disabled":
+ this.recordActionEvent(addon.userDisabled ? "enable" : "disable");
+ // Keep the checked state the same until the add-on's state changes.
+ e.target.checked = !addon.userDisabled;
+ if (addon.userDisabled) {
+ if (shouldShowPermissionsPrompt(addon)) {
+ await showPermissionsPrompt(addon);
+ } else {
+ await addon.enable();
+ }
+ } else {
+ await addon.disable();
+ }
+ break;
+ case "ask-to-activate":
+ if (hasPermission(addon, "ask-to-activate")) {
+ addon.userDisabled = AddonManager.STATE_ASK_TO_ACTIVATE;
+ }
+ break;
+ case "always-activate":
+ this.recordActionEvent("enable");
+ addon.userDisabled = false;
+ break;
+ case "never-activate":
+ this.recordActionEvent("disable");
+ addon.userDisabled = true;
+ break;
+ case "update-check": {
+ this.recordActionEvent("checkForUpdate");
+ let { found } = await checkForUpdate(addon);
+ if (!found) {
+ this.sendEvent("no-update");
+ }
+ break;
+ }
+ case "install-postponed": {
+ const { updateInstall } = this;
+ if (updateInstall && isInState(updateInstall, "postponed")) {
+ updateInstall.continuePostponedInstall();
+ }
+ break;
+ }
+ case "install-update":
+ // Make sure that an update handler is attached to the install object
+ // before starting the update installation (otherwise the user would
+ // not be prompted for the new permissions requested if necessary),
+ // and also make sure that a prompt handler attached from a closed
+ // about:addons tab is replaced by the one attached by the currently
+ // active about:addons tab.
+ attachUpdateHandler(this.updateInstall);
+ this.updateInstall.install().then(
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // The card will update with the new add-on when it gets
+ // installed.
+ this.sendEvent("update-installed");
+ },
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // Update our state if the install is cancelled.
+ this.update();
+ this.sendEvent("update-cancelled");
+ }
+ );
+ // Clear the install since it will be removed from the global list of
+ // available updates (whether it succeeds or fails).
+ this.updateInstall = null;
+ break;
+ case "contribute":
+ this.recordActionEvent("contribute");
+ // prettier-ignore
+ windowRoot.ownerGlobal.openUILinkIn(addon.contributionURL, "tab", {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ break;
+ case "preferences":
+ if (getOptionsType(addon) == "tab") {
+ this.recordActionEvent("preferences", "external");
+ openOptionsInTab(addon.optionsURL);
+ } else if (getOptionsType(addon) == "inline") {
+ this.recordActionEvent("preferences", "inline");
+ loadViewFn(`detail/${this.addon.id}/preferences`);
+ }
+ break;
+ case "remove":
+ {
+ this.panel.hide();
+ let {
+ remove,
+ report,
+ } = windowRoot.ownerGlobal.BrowserAddonUI.promptRemoveExtension(
+ addon
+ );
+ let value = remove ? "accepted" : "cancelled";
+ this.recordActionEvent("uninstall", value);
+ if (remove) {
+ await addon.uninstall(true);
+ this.sendEvent("remove");
+ if (report) {
+ openAbuseReport({
+ addonId: addon.id,
+ reportEntryPoint: "uninstall",
+ });
+ }
+ } else {
+ this.sendEvent("remove-cancelled");
+ }
+ }
+ break;
+ case "expand":
+ loadViewFn(`detail/${this.addon.id}`);
+ break;
+ case "more-options":
+ // Open panel on click from the keyboard.
+ if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.panel.toggle(e);
+ }
+ break;
+ case "report":
+ this.panel.hide();
+ openAbuseReport({ addonId: addon.id, reportEntryPoint: "menu" });
+ break;
+ case "link":
+ if (e.target.getAttribute("url")) {
+ windowRoot.ownerGlobal.openWebLinkIn(
+ e.target.getAttribute("url"),
+ "tab"
+ );
+ }
+ break;
+ default:
+ // Handle a click on the card itself.
+ if (
+ !this.expanded &&
+ (e.target === this.addonNameEl || !e.target.closest("a"))
+ ) {
+ e.preventDefault();
+ loadViewFn(`detail/${this.addon.id}`);
+ } else if (
+ e.target.localName == "a" &&
+ e.target.getAttribute("data-telemetry-name")
+ ) {
+ let value = e.target.getAttribute("data-telemetry-name");
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ addon,
+ value,
+ extra: {
+ view: getTelemetryViewName(this),
+ },
+ });
+ }
+ break;
+ }
+ } else if (e.type == "change") {
+ let { name } = e.target;
+ let telemetryValue = e.target.getAttribute("data-telemetry-value");
+ if (name == "autoupdate") {
+ this.recordActionEvent("setAddonUpdate", telemetryValue);
+ addon.applyBackgroundUpdates = e.target.value;
+ } else if (name == "private-browsing") {
+ this.recordActionEvent("privateBrowsingAllowed", telemetryValue);
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ let extension = policy && policy.extension;
+
+ if (e.target.value == "1") {
+ await ExtensionPermissions.add(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ } else {
+ await ExtensionPermissions.remove(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ }
+ // Reload the extension if it is already enabled. This ensures any
+ // change on the private browsing permission is properly handled.
+ if (addon.isActive) {
+ this.reloading = true;
+ // Reloading will trigger an enable and update the card.
+ addon.reload();
+ } else {
+ // Update the card if the add-on isn't active.
+ this.update();
+ }
+ }
+ } else if (e.type == "mousedown") {
+ // Open panel on mousedown when the mouse is used.
+ if (action == "more-options" && e.button == 0) {
+ this.panel.toggle(e);
+ }
+ } else if (e.type === "shown" || e.type === "hidden") {
+ let panelOpen = e.type === "shown";
+ // The card will be dimmed if it's disabled, but when the panel is open
+ // that should be reverted so the menu items can be easily read.
+ this.toggleAttribute("panelopen", panelOpen);
+ this.optionsButton.setAttribute("aria-expanded", panelOpen);
+ }
+ }
+
+ get panel() {
+ return this.card.querySelector("panel-list");
+ }
+
+ get postponedMessageBar() {
+ return this.card.querySelector(".update-postponed-bar");
+ }
+
+ registerListeners() {
+ this.addEventListener("change", this);
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ this.panel.addEventListener("shown", this);
+ this.panel.addEventListener("hidden", this);
+ }
+
+ removeListeners() {
+ this.removeEventListener("change", this);
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.panel.removeEventListener("shown", this);
+ this.panel.removeEventListener("hidden", this);
+ }
+
+ /**
+ * Update the card's contents based on the previously set add-on. This should
+ * be called if there has been a change to the add-on.
+ */
+ update() {
+ let { addon, card } = this;
+
+ card.setAttribute("active", addon.isActive);
+
+ // Set the icon or theme preview.
+ let iconEl = card.querySelector(".addon-icon");
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ iconEl.hidden = true;
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ }
+ preview.hidden = !screenshotUrl;
+ } else {
+ preview.hidden = true;
+ iconEl.hidden = false;
+ if (addon.type == "plugin") {
+ iconEl.src = PLUGIN_ICON_URL;
+ } else {
+ iconEl.src =
+ AddonManager.getPreferredIconURL(addon, 32, window) ||
+ EXTENSION_ICON_URL;
+ }
+ }
+
+ // Update the name.
+ let name = this.addonNameEl;
+ if (addon.isActive) {
+ name.textContent = addon.name;
+ name.removeAttribute("data-l10n-id");
+ } else {
+ document.l10n.setAttributes(name, "addon-name-disabled", {
+ name: addon.name,
+ });
+ }
+ name.title = `${addon.name} ${addon.version}`;
+
+ let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]');
+ if (toggleDisabledButton) {
+ let toggleDisabledAction = addon.userDisabled ? "enable" : "disable";
+ toggleDisabledButton.hidden = !hasPermission(addon, toggleDisabledAction);
+ if (addon.type === "theme") {
+ document.l10n.setAttributes(
+ toggleDisabledButton,
+ `${toggleDisabledAction}-addon-button`
+ );
+ } else if (addon.type === "extension") {
+ toggleDisabledButton.checked = !addon.userDisabled;
+ }
+ }
+
+ // Set the items in the more options menu.
+ this.options.update(this, addon, this.updateInstall);
+
+ // Badge the more options button if there's an update.
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ moreOptionsButton.classList.toggle(
+ "more-options-button-badged",
+ !!(this.updateInstall && isInState(this.updateInstall, "available"))
+ );
+
+ // Postponed update addon card message bar.
+ const hasPostponedInstall =
+ this.updateInstall && isInState(this.updateInstall, "postponed");
+ this.postponedMessageBar.hidden = !hasPostponedInstall;
+
+ // Hide the more options button if it's empty.
+ moreOptionsButton.hidden = this.options.visibleItems.length === 0;
+
+ // Ensure all badges are initially hidden.
+ for (let node of card.querySelectorAll(".addon-badge")) {
+ node.hidden = true;
+ }
+
+ // Set the private browsing badge visibility.
+ if (
+ !allowPrivateBrowsingByDefault &&
+ addon.type == "extension" &&
+ addon.incognito != "not_allowed"
+ ) {
+ // Keep update synchronous, the badge can appear later.
+ isAllowedInPrivateBrowsing(addon).then(isAllowed => {
+ card.querySelector(
+ ".addon-badge-private-browsing-allowed"
+ ).hidden = !isAllowed;
+ });
+ }
+
+ // Show the recommended badges if needed.
+ // Plugins don't have recommendationStates, so ensure a default.
+ let states = addon.recommendationStates || [];
+ for (let badgeName of states) {
+ let badge = card.querySelector(`.addon-badge-${badgeName}`);
+ if (badge) {
+ badge.hidden = false;
+ }
+ }
+
+ // Update description.
+ card.querySelector(".addon-description").textContent = addon.description;
+
+ this.updateMessage();
+
+ // Update the details if they're shown.
+ if (this.details) {
+ this.details.update();
+ }
+
+ this.sendEvent("update");
+ }
+
+ async updateMessage() {
+ let { addon, card } = this;
+ let messageBar = card.querySelector(".addon-card-message");
+ let link = messageBar.querySelector("button");
+
+ let { message, type = "", linkText, linkUrl } = await getAddonMessageInfo(
+ addon
+ );
+
+ if (message) {
+ messageBar.querySelector("span").textContent = message;
+ messageBar.setAttribute("type", type);
+ if (linkText) {
+ link.textContent = linkText;
+ link.setAttribute("url", linkUrl);
+ }
+ }
+
+ messageBar.hidden = !message;
+ link.hidden = !linkText;
+ }
+
+ showPrefs() {
+ this.details.showPrefs();
+ }
+
+ expand() {
+ if (!this.children.length) {
+ this.expanded = true;
+ } else {
+ throw new Error("expand() is only supported before render()");
+ }
+ }
+
+ render() {
+ this.textContent = "";
+
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-card must be initialized with setAddon()");
+ }
+
+ let headingId = ExtensionCommon.makeWidgetId(`${addon.name}-heading`);
+ this.setAttribute("aria-labelledby", headingId);
+ this.setAttribute("addon-id", addon.id);
+
+ this.card = importTemplate("card").firstElementChild;
+
+ // Remove the toggle-disabled button(s) based on type.
+ if (addon.type != "theme") {
+ this.card.querySelector(".theme-enable-button").remove();
+ }
+ if (addon.type != "extension") {
+ this.card.querySelector(".extension-enable-button").remove();
+ }
+
+ let nameContainer = this.card.querySelector(".addon-name-container");
+ let headingLevel = this.expanded ? "h1" : "h3";
+ let nameHeading = document.createElement(headingLevel);
+ nameHeading.classList.add("addon-name");
+ if (!this.expanded) {
+ let name = document.createElement("a");
+ name.classList.add("addon-name-link");
+ name.href = `addons://detail/${addon.id}`;
+ nameHeading.appendChild(name);
+ this.addonNameEl = name;
+ } else {
+ this.addonNameEl = nameHeading;
+ }
+ nameContainer.prepend(nameHeading);
+
+ let panelType = addon.type == "plugin" ? "plugin-options" : "addon-options";
+ this.options = document.createElement(panelType);
+ this.options.render();
+ this.card.appendChild(this.options);
+ this.optionsButton = this.card.querySelector(".more-options-button");
+
+ // Set the contents.
+ this.update();
+
+ let doneRenderPromise = Promise.resolve();
+ if (this.expanded) {
+ if (!this.details) {
+ this.details = document.createElement("addon-details");
+ }
+ this.details.setAddon(this.addon);
+ doneRenderPromise = this.details.render();
+
+ // If we're re-rendering we still need to append the details since the
+ // entire card was emptied at the beginning of the render.
+ this.card.appendChild(this.details);
+ }
+
+ this.appendChild(this.card);
+
+ if (this.expanded) {
+ requestAnimationFrame(() => this.optionsButton.focus());
+ }
+
+ // Return the promise of details rendering to wait on in DetailView.
+ return doneRenderPromise;
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ recordActionEvent(action, value) {
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ view: getTelemetryViewName(this),
+ action,
+ addon: this.addon,
+ value,
+ });
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onNewInstall(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-found");
+ }
+
+ onInstallEnded(install) {
+ this.setAddon(install.addon);
+ }
+
+ onInstallPostponed(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-postponed");
+ }
+
+ onDisabled(addon) {
+ if (!this.reloading) {
+ this.update();
+ }
+ }
+
+ onEnabled(addon) {
+ this.reloading = false;
+ this.update();
+ }
+
+ onInstalled(addon) {
+ // When a temporary addon is reloaded, onInstalled is triggered instead of
+ // onEnabled.
+ this.reloading = false;
+ this.update();
+ }
+
+ onUninstalling() {
+ // Dispatch a remove event, the DetailView is listening for this to get us
+ // back to the list view when the current add-on is removed.
+ this.sendEvent("remove");
+ }
+
+ onUpdateModeChanged() {
+ this.update();
+ }
+
+ onPropertyChanged(addon, changed) {
+ if (this.details && changed.includes("applyBackgroundUpdates")) {
+ this.details.update();
+ } else if (addon.type == "plugin" && changed.includes("userDisabled")) {
+ this.update();
+ }
+ }
+
+ /* Extension Permission change listener */
+ onChangePermissions(data) {
+ let perms = data.added || data.removed;
+ let fname = data.added ? "add" : "remove";
+ for (let permission of perms.permissions.concat(perms.origins)) {
+ let target = document.querySelector(`[permission-key="${permission}"]`);
+ if (target) {
+ target.parentNode.parentNode.classList[fname]("permission-checked");
+ target.checked = !data.removed;
+ }
+ }
+ }
+}
+customElements.define("addon-card", AddonCard);
+
+/**
+ * A child element of `<recommended-addon-list>`. It should be initialized
+ * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been
+ * installed, and call `setAddon(null)` upon uninstall.
+ *
+ * let discoAddon = new DiscoAddonWrapper({ ... });
+ * let card = document.createElement("recommended-addon-card");
+ * card.setDiscoAddon(discoAddon);
+ * document.body.appendChild(card);
+ *
+ * AddonManager.getAddonsByID(discoAddon.id)
+ * .then(addon => card.setAddon(addon));
+ */
+class RecommendedAddonCard extends HTMLElement {
+ /**
+ * @param {DiscoAddonWrapper} addon
+ * The details of the add-on that should be rendered in the card.
+ */
+ setDiscoAddon(addon) {
+ this.addonId = addon.id;
+
+ // Save the information so we can install.
+ this.discoAddon = addon;
+
+ let card = importTemplate("card").firstElementChild;
+ let heading = card.querySelector(".addon-name-container");
+ heading.textContent = "";
+ heading.append(importTemplate("addon-name-container-in-disco-card"));
+
+ this.setCardContent(card, addon);
+ if (addon.type != "theme") {
+ card
+ .querySelector(".addon-description")
+ .append(importTemplate("addon-description-in-disco-card"));
+ this.setCardDescription(card, addon);
+ }
+ this.registerButtons(card, addon);
+
+ this.textContent = "";
+ this.append(card);
+
+ // We initially assume that the add-on is not installed.
+ this.setAddon(null);
+ }
+
+ /**
+ * Fills in all static parts of the card.
+ *
+ * @param {HTMLElement} card
+ * The primary content of this card.
+ * @param {DiscoAddonWrapper} addon
+ */
+ setCardContent(card, addon) {
+ // Set the icon.
+ if (addon.type == "theme") {
+ card.querySelector(".addon-icon").hidden = true;
+ } else {
+ card.querySelector(".addon-icon").src = AddonManager.getPreferredIconURL(
+ addon,
+ 32,
+ window
+ );
+ }
+
+ // Set the theme preview.
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ preview.hidden = false;
+ }
+ } else {
+ preview.hidden = true;
+ }
+
+ // Set the name.
+ card.querySelector(".disco-addon-name").textContent = addon.name;
+
+ // Set the author name and link to AMO.
+ if (addon.creator) {
+ let authorInfo = card.querySelector(".disco-addon-author");
+ document.l10n.setAttributes(authorInfo, "created-by-author", {
+ author: addon.creator.name,
+ });
+ // This is intentionally a link to the add-on listing instead of the
+ // author page, because the add-on listing provides more relevant info.
+ authorInfo.querySelector("a").href = formatUTMParams(
+ "discopane-entry-link",
+ addon.amoListingUrl
+ );
+ authorInfo.hidden = false;
+ }
+ }
+
+ setCardDescription(card, addon) {
+ // Set the description. Note that this is the editorial description, not
+ // the add-on's original description that would normally appear on a card.
+ card.querySelector(".disco-description-main").textContent =
+ addon.editorialDescription;
+
+ let hasStats = false;
+ if (addon.averageRating) {
+ hasStats = true;
+ card.querySelector("five-star-rating").rating = addon.averageRating;
+ } else {
+ card.querySelector("five-star-rating").hidden = true;
+ }
+
+ if (addon.dailyUsers) {
+ hasStats = true;
+ let userCountElem = card.querySelector(".disco-user-count");
+ document.l10n.setAttributes(userCountElem, "user-count", {
+ dailyUsers: addon.dailyUsers,
+ });
+ }
+
+ card.querySelector(".disco-description-statistics").hidden = !hasStats;
+ }
+
+ registerButtons(card, addon) {
+ let installButton = card.querySelector("[action='install-addon']");
+ if (addon.type == "theme") {
+ document.l10n.setAttributes(installButton, "install-theme-button");
+ } else {
+ document.l10n.setAttributes(installButton, "install-extension-button");
+ }
+
+ this.addEventListener("click", this);
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "install-addon":
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ view: getTelemetryViewName(this),
+ action: "installFromRecommendation",
+ addon: this.discoAddon,
+ });
+ this.installDiscoAddon();
+ break;
+ case "manage-addon":
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ 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: getTelemetryViewName(this),
+ },
+ });
+ }
+ }
+ }
+
+ 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
+ // main tab's browser since all of it is using the system principal.
+ let browser = window.docShell.chromeEventHandler;
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ browser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ install
+ );
+ }
+
+ /**
+ * @param {AddonWrapper|null} addon
+ * The add-on that has been installed; null if it has been removed.
+ */
+ setAddon(addon) {
+ let card = this.firstElementChild;
+ card.querySelector("[action='install-addon']").hidden = !!addon;
+ card.querySelector("[action='manage-addon']").hidden = !addon;
+
+ this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing.
+ }
+}
+customElements.define("recommended-addon-card", RecommendedAddonCard);
+
+/**
+ * A list view for add-ons of a certain type. It should be initialized with the
+ * type of add-on to render and have section data set before being connected to
+ * the document.
+ *
+ * let list = document.createElement("addon-list");
+ * list.type = "plugin";
+ * list.setSections([{
+ * headingId: "plugin-section-heading",
+ * filterFn: addon => !addon.isSystem,
+ * }]);
+ * document.body.appendChild(list);
+ */
+class AddonList extends HTMLElement {
+ constructor() {
+ super();
+ this.sections = [];
+ this.pendingUninstallAddons = new Set();
+ this._addonsToUpdate = new Set();
+ this._userFocusListenersAdded = false;
+ }
+
+ async connectedCallback() {
+ // Register the listener and get the add-ons, these operations should
+ // happpen as close to each other as possible.
+ this.registerListener();
+ // Don't render again if we were rendered prior to being inserted.
+ if (!this.children.length) {
+ // Render the initial view.
+ this.render();
+ }
+ }
+
+ disconnectedCallback() {
+ // Remove content and stop listening until this is connected again.
+ this.textContent = "";
+ this.removeListener();
+
+ // Process any pending uninstall related to this list.
+ for (const addon of this.pendingUninstallAddons) {
+ if (isPending(addon, "uninstall")) {
+ addon.uninstall();
+ }
+ }
+ this.pendingUninstallAddons.clear();
+ }
+
+ /**
+ * Configure the sections in the list.
+ *
+ * @param {object[]} sections
+ * The options for the section. Each entry in the array should have:
+ * headingId: The fluent id for the section's heading.
+ * filterFn: A function that determines if an add-on belongs in
+ * the section.
+ */
+ setSections(sections) {
+ this.sections = sections.map(section => Object.assign({}, section));
+ }
+
+ /**
+ * Set the add-on type for this list. This will be used to filter the add-ons
+ * that are displayed.
+ *
+ * @param {string} val The type to filter on.
+ */
+ set type(val) {
+ this.setAttribute("type", val);
+ }
+
+ get type() {
+ return this.getAttribute("type");
+ }
+
+ getSection(index) {
+ return this.sections[index].node;
+ }
+
+ getCards(section) {
+ return section.querySelectorAll("addon-card");
+ }
+
+ getCard(addon) {
+ return this.querySelector(`addon-card[addon-id="${addon.id}"]`);
+ }
+
+ getPendingUninstallBar(addon) {
+ return this.querySelector(`message-bar[addon-id="${addon.id}"]`);
+ }
+
+ sortByFn(aAddon, bAddon) {
+ return aAddon.name.localeCompare(bAddon.name);
+ }
+
+ async getAddons() {
+ if (!this.type) {
+ throw new Error(`type must be set to find add-ons`);
+ }
+
+ // Find everything matching our type, null will find all types.
+ let type = this.type == "all" ? null : [this.type];
+ let addons = await AddonManager.getAddonsByTypes(type);
+
+ // Put the add-ons into the sections, an add-on goes in the first section
+ // that it matches the filterFn for. It might not go in any section.
+ let sectionedAddons = this.sections.map(() => []);
+ for (let addon of addons) {
+ let index = this.sections.findIndex(({ filterFn }) => filterFn(addon));
+ if (index != -1) {
+ sectionedAddons[index].push(addon);
+ } else if (isPending(addon, "uninstall")) {
+ // A second tab may be opened on "about:addons" (or Firefox may
+ // have crashed) while there are still "pending uninstall" add-ons.
+ // Ensure to list them in the pendingUninstall message-bar-stack
+ // when the AddonList is initially rendered.
+ this.pendingUninstallAddons.add(addon);
+ }
+ }
+
+ // Sort the add-ons in each section.
+ for (let section of sectionedAddons) {
+ section.sort(this.sortByFn);
+ }
+
+ return sectionedAddons;
+ }
+
+ createPendingUninstallStack() {
+ const stack = document.createElement("message-bar-stack");
+ stack.setAttribute("class", "pending-uninstall");
+ stack.setAttribute("reverse", "");
+ return stack;
+ }
+
+ addPendingUninstallBar(addon) {
+ const stack = this.pendingUninstallStack;
+ const mb = document.createElement("message-bar");
+ mb.setAttribute("addon-id", addon.id);
+ mb.setAttribute("type", "generic");
+
+ const addonName = document.createElement("span");
+ addonName.setAttribute("data-l10n-name", "addon-name");
+ const message = document.createElement("span");
+ message.append(addonName);
+ const undo = document.createElement("button");
+ undo.setAttribute("action", "undo");
+ undo.addEventListener("click", () => {
+ AMTelemetry.recordActionEvent({
+ object: "aboutAddons",
+ view: getTelemetryViewName(this),
+ action: "undo",
+ addon,
+ });
+ addon.cancelUninstall();
+ });
+
+ document.l10n.setAttributes(message, "pending-uninstall-description", {
+ addon: addon.name,
+ });
+ document.l10n.setAttributes(undo, "pending-uninstall-undo-button");
+
+ mb.append(message, undo);
+ stack.append(mb);
+ }
+
+ removePendingUninstallBar(addon) {
+ const messagebar = this.getPendingUninstallBar(addon);
+ if (messagebar) {
+ messagebar.remove();
+ }
+ }
+
+ createSectionHeading(headingIndex) {
+ let { headingId } = this.sections[headingIndex];
+ let heading = document.createElement("h2");
+ heading.classList.add("list-section-heading");
+ document.l10n.setAttributes(heading, headingId);
+ return heading;
+ }
+
+ updateSectionIfEmpty(section) {
+ // The header is added before any add-on cards, so if there's only one
+ // child then it's the header. In that case we should empty out the section.
+ if (section.children.length == 1) {
+ section.textContent = "";
+ }
+ }
+
+ insertCardInto(card, sectionIndex) {
+ let section = this.getSection(sectionIndex);
+ let sectionCards = this.getCards(section);
+
+ // If this is the first card in the section, create the heading.
+ if (!sectionCards.length) {
+ section.appendChild(this.createSectionHeading(sectionIndex));
+ }
+
+ // Find where to insert the card.
+ let insertBefore = Array.from(sectionCards).find(
+ otherCard => this.sortByFn(card.addon, otherCard.addon) < 0
+ );
+ // This will append if insertBefore is null.
+ section.insertBefore(card, insertBefore || null);
+ }
+
+ addAddon(addon) {
+ // Only insert add-ons of the right type.
+ if (addon.type != this.type && this.type != "all") {
+ this.sendEvent("skip-add", "type-mismatch");
+ return;
+ }
+
+ let insertSection = this._addonSectionIndex(addon);
+
+ // Don't add the add-on if it doesn't go in a section.
+ if (insertSection == -1) {
+ return;
+ }
+
+ // Create and insert the card.
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ this.insertCardInto(card, insertSection);
+ this.sendEvent("add", { id: addon.id });
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ removeAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let section = card.parentNode;
+ card.remove();
+ this.updateSectionIfEmpty(section);
+ this.sendEvent("remove", { id: addon.id });
+ }
+ }
+
+ updateAddon(addon) {
+ if (!this.getCard(addon)) {
+ // Try to add the add-on right away.
+ this.addAddon(addon);
+ } else if (this._addonSectionIndex(addon) == -1) {
+ // Try to remove the add-on right away.
+ this._updateAddon(addon);
+ } else if (this.isUserFocused) {
+ // Queue up a change for when the focus is cleared.
+ this.updateLater(addon);
+ } else {
+ // Not currently focused, make the change now.
+ this.withCardAnimation(() => this._updateAddon(addon));
+ }
+ }
+
+ updateLater(addon) {
+ this._addonsToUpdate.add(addon);
+ this._addUserFocusListeners();
+ }
+
+ _addUserFocusListeners() {
+ if (this._userFocusListenersAdded) {
+ return;
+ }
+
+ this._userFocusListenersAdded = true;
+ this.addEventListener("mouseleave", this);
+ this.addEventListener("hidden", this, true);
+ this.addEventListener("focusout", this);
+ }
+
+ _removeUserFocusListeners() {
+ if (!this._userFocusListenersAdded) {
+ return;
+ }
+
+ this.removeEventListener("mouseleave", this);
+ this.removeEventListener("hidden", this, true);
+ this.removeEventListener("focusout", this);
+ this._userFocusListenersAdded = false;
+ }
+
+ get hasMenuOpen() {
+ return !!this.querySelector("panel-list[open]");
+ }
+
+ get isUserFocused() {
+ return this.matches(":hover, :focus-within") || this.hasMenuOpen;
+ }
+
+ update() {
+ if (this._addonsToUpdate.size) {
+ this.withCardAnimation(() => {
+ for (let addon of this._addonsToUpdate) {
+ this._updateAddon(addon);
+ }
+ this._addonsToUpdate = new Set();
+ });
+ }
+ }
+
+ _getChildCoords() {
+ let results = new Map();
+ for (let child of this.querySelectorAll("addon-card")) {
+ results.set(child, child.getBoundingClientRect());
+ }
+ return results;
+ }
+
+ withCardAnimation(changeFn) {
+ if (shouldSkipAnimations()) {
+ changeFn();
+ return;
+ }
+
+ let origChildCoords = this._getChildCoords();
+
+ changeFn();
+
+ let newChildCoords = this._getChildCoords();
+ let cards = this.querySelectorAll("addon-card");
+ let transitionCards = [];
+ for (let card of cards) {
+ let orig = origChildCoords.get(card);
+ let moved = newChildCoords.get(card);
+ let changeY = moved.y - (orig || moved).y;
+ let cardEl = card.firstElementChild;
+
+ if (changeY != 0) {
+ cardEl.style.transform = `translateY(${changeY * -1}px)`;
+ transitionCards.push(card);
+ }
+ }
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ card.firstElementChild.style.transition = "transform 125ms";
+ }
+
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ let cardEl = card.firstElementChild;
+ cardEl.style.transform = "";
+ cardEl.addEventListener("transitionend", function handler(e) {
+ if (e.target == cardEl && e.propertyName == "transform") {
+ cardEl.style.transition = "";
+ cardEl.removeEventListener("transitionend", handler);
+ }
+ });
+ }
+ });
+ });
+ }
+
+ _addonSectionIndex(addon) {
+ return this.sections.findIndex(s => s.filterFn(addon));
+ }
+
+ _updateAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let sectionIndex = this._addonSectionIndex(addon);
+ if (sectionIndex != -1) {
+ // Move the card, if needed. This will allow an animation between
+ // page sections and provides clearer events for testing.
+ if (card.parentNode.getAttribute("section") != sectionIndex) {
+ let { activeElement } = document;
+ let refocus = card.contains(activeElement);
+ let oldSection = card.parentNode;
+ this.insertCardInto(card, sectionIndex);
+ this.updateSectionIfEmpty(oldSection);
+ if (refocus) {
+ activeElement.focus();
+ }
+ this.sendEvent("move", { id: addon.id });
+ }
+ } else {
+ this.removeAddon(addon);
+ }
+ }
+ }
+
+ renderSection(addons, index) {
+ let section = document.createElement("section");
+ section.setAttribute("section", index);
+
+ // Render the heading and add-ons if there are any.
+ if (addons.length) {
+ section.appendChild(this.createSectionHeading(index));
+
+ for (let addon of addons) {
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ card.render();
+ section.appendChild(card);
+ }
+ }
+
+ return section;
+ }
+
+ async render() {
+ this.textContent = "";
+
+ let sectionedAddons = await this.getAddons();
+
+ let frag = document.createDocumentFragment();
+
+ // Render the pending uninstall message-bar-stack.
+ this.pendingUninstallStack = this.createPendingUninstallStack();
+ for (let addon of this.pendingUninstallAddons) {
+ this.addPendingUninstallBar(addon);
+ }
+ frag.appendChild(this.pendingUninstallStack);
+
+ // Render the sections.
+ for (let i = 0; i < sectionedAddons.length; i++) {
+ this.sections[i].node = this.renderSection(sectionedAddons[i], i);
+ frag.appendChild(this.sections[i].node);
+ }
+
+ // Make sure fluent has set all the strings before we render. This will
+ // avoid the height changing as strings go from 0 height to having text.
+ await document.l10n.translateFragment(frag);
+ this.appendChild(frag);
+ }
+
+ registerListener() {
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ removeListener() {
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ handleEvent(e) {
+ if (!this.isUserFocused || (e.type == "mouseleave" && !this.hasMenuOpen)) {
+ this._removeUserFocusListeners();
+ this.update();
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onOperationCancelled(addon) {
+ if (
+ this.pendingUninstallAddons.has(addon) &&
+ !isPending(addon, "uninstall")
+ ) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ }
+ this.updateAddon(addon);
+ }
+
+ onEnabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onDisabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onUninstalling(addon) {
+ if (
+ isPending(addon, "uninstall") &&
+ (this.type === "all" || addon.type === this.type)
+ ) {
+ this.pendingUninstallAddons.add(addon);
+ this.addPendingUninstallBar(addon);
+ this.updateAddon(addon);
+ }
+ }
+
+ onInstalled(addon) {
+ if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) {
+ return;
+ }
+ this.addAddon(addon);
+ }
+
+ onUninstalled(addon) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ this.removeAddon(addon);
+ }
+}
+customElements.define("addon-list", AddonList);
+
+class RecommendedAddonList extends HTMLElement {
+ connectedCallback() {
+ if (this.isConnected) {
+ this.loadCardsIfNeeded();
+ this.updateCardsWithAddonManager();
+ }
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ AddonManagerListenerHandler.removeListener(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);
+ }
+
+ 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];
+ this.setAddonForCard(card, addon);
+ if (addon) {
+ // Already installed, move card to end.
+ this.append(card);
+ }
+ }
+ }
+
+ async loadCardsIfNeeded() {
+ // Use promise as guard. Also used by tests to detect when load completes.
+ if (!this.cardsReady) {
+ this.cardsReady = this._loadCards();
+ }
+ return this.cardsReady;
+ }
+
+ async _loadCards() {
+ let recommendedAddons;
+ try {
+ 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();
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onInstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, addon);
+ }
+ }
+
+ onUninstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, null);
+ }
+ }
+}
+customElements.define("recommended-addon-list", RecommendedAddonList);
+
+class TaarMessageBar extends HTMLElement {
+ connectedCallback() {
+ this.hidden =
+ Services.prefs.getBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, false) ||
+ !DiscoveryAPI.clientIdDiscoveryEnabled;
+ if (this.childElementCount == 0 && !this.hidden) {
+ this.appendChild(importTemplate("taar-notice"));
+ this.addEventListener("click", this);
+ this.messageBar = this.querySelector("message-bar");
+ this.messageBar.addEventListener("message-bar:user-dismissed", 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"
+ );
+ } else if (e.type == "message-bar:user-dismissed") {
+ Services.prefs.setBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, true);
+ }
+ }
+}
+customElements.define("taar-notice", TaarMessageBar);
+
+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 "open-amo":
+ openAmoInTab(this);
+ break;
+ }
+ }
+}
+customElements.define("recommended-footer", RecommendedFooter, {
+ extends: "footer",
+});
+
+class RecommendedThemesFooter extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.appendChild(importTemplate("recommended-themes-footer"));
+ 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;
+ this.addEventListener("click", this);
+ }
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "open-amo":
+ openAmoInTab(this);
+ break;
+ }
+ }
+}
+customElements.define("recommended-themes-footer", RecommendedThemesFooter, {
+ 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";
+ }
+}
+customElements.define(
+ "recommended-extensions-section",
+ RecommendedExtensionsSection
+);
+
+class RecommendedThemesSection extends RecommendedSection {
+ get template() {
+ return "recommended-themes-section";
+ }
+}
+customElements.define("recommended-themes-section", RecommendedThemesSection);
+
+class DiscoveryPane extends RecommendedSection {
+ get template() {
+ return "discopane";
+ }
+}
+customElements.define("discovery-pane", DiscoveryPane);
+
+class ListView {
+ constructor({ param, root }) {
+ this.type = param;
+ this.root = root;
+ }
+
+ async render() {
+ if (!(this.type in AddonManager.addonTypes)) {
+ replaceWithDefaultViewFn();
+ return;
+ }
+
+ let frag = document.createDocumentFragment();
+
+ let list = document.createElement("addon-list");
+ list.type = this.type;
+ list.setSections([
+ {
+ headingId: this.type + "-enabled-heading",
+ filterFn: addon =>
+ !addon.hidden && addon.isActive && !isPending(addon, "uninstall"),
+ },
+ {
+ headingId: this.type + "-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(frag);
+ }
+}
+
+class DetailView {
+ constructor({ param, root }) {
+ let [id, selectedTab] = param.split("/");
+ this.id = id;
+ this.selectedTab = selectedTab;
+ this.root = root;
+ }
+
+ async render() {
+ let addon = await AddonManager.getAddonByID(this.id);
+
+ if (!addon) {
+ replaceWithDefaultViewFn();
+ return;
+ }
+
+ let card = document.createElement("addon-card");
+
+ // Ensure the category for this add-on type is selected.
+ categoriesBox.selectType(addon.type);
+
+ // Go back to the list view when the add-on is removed.
+ card.addEventListener("remove", () => loadViewFn(`list/${addon.type}`));
+
+ card.setAddon(addon);
+ card.expand();
+ await card.render();
+ if (
+ this.selectedTab === "preferences" &&
+ (await isAddonOptionsUIAllowed(addon))
+ ) {
+ card.showPrefs();
+ }
+
+ this.root.textContent = "";
+ this.root.appendChild(card);
+ }
+}
+
+class UpdatesView {
+ constructor({ param, root }) {
+ this.root = root;
+ this.param = param;
+ }
+
+ async render() {
+ let list = document.createElement("addon-list");
+ list.type = "all";
+ if (this.param == "available") {
+ list.setSections([
+ {
+ headingId: "available-updates-heading",
+ filterFn: addon => {
+ // Filter the addons visible in the updates view using the same
+ // criteria that is being used to compute the counter on the
+ // available updates category button badge.
+ const install = getUpdateInstall(addon);
+ return install && isManualUpdate(install) && !install.installed;
+ },
+ },
+ ]);
+ } else if (this.param == "recent") {
+ list.sortByFn = (a, b) => {
+ if (a.updateDate > b.updateDate) {
+ return -1;
+ }
+ if (a.updateDate < b.updateDate) {
+ return 1;
+ }
+ return 0;
+ };
+ let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN;
+ list.setSections([
+ {
+ headingId: "recent-updates-heading",
+ filterFn: addon =>
+ !addon.hidden && addon.updateDate && addon.updateDate > updateLimit,
+ },
+ ]);
+ } else {
+ throw new Error(`Unknown updates view ${this.param}`);
+ }
+
+ await list.render();
+ this.root.textContent = "";
+ this.root.appendChild(list);
+ }
+}
+
+class DiscoveryView {
+ render() {
+ let discopane = document.createElement("discovery-pane");
+ discopane.render();
+ return discopane;
+ }
+}
+
+// Generic view management.
+let mainEl = null;
+let addonPageHeader = null;
+let categoriesBox = 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) {
+ let root =
+ el.closest("[current-view]") || document.querySelector("[current-view]");
+ return root.getAttribute("current-view");
+}
+
+/**
+ * @param {Element} el The button element.
+ */
+function openAmoInTab(el) {
+ // The element is a button but opens a URL, so record as link.
+ AMTelemetry.recordLinkEvent({
+ object: "aboutAddons",
+ value: "discomore",
+ extra: {
+ view: getTelemetryViewName(el),
+ },
+ });
+ let amoUrl = Services.urlFormatter.formatURLPref(
+ "extensions.getAddons.link.url"
+ );
+ amoUrl = formatUTMParams("find-more-link-bottom", amoUrl);
+ windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
+}
+
+/**
+ * Helper for saving and restoring the scroll offsets when a previously loaded
+ * view is accessed again.
+ */
+var ScrollOffsets = {
+ _key: null,
+ _offsets: new Map(),
+ canRestore: true,
+
+ setView(historyEntryId) {
+ this._key = historyEntryId;
+ this.canRestore = true;
+ },
+
+ getPosition() {
+ if (!this.canRestore) {
+ return { top: 0, left: 0 };
+ }
+ let { scrollTop: top, scrollLeft: left } = document.documentElement;
+ return { top, left };
+ },
+
+ save() {
+ if (this._key) {
+ this._offsets.set(this._key, this.getPosition());
+ }
+ },
+
+ restore() {
+ let { top = 0, left = 0 } = this._offsets.get(this._key) || {};
+ window.scrollTo({ top, left, behavior: "auto" });
+ },
+};
+
+/**
+ * Called from extensions.js once, when about:addons is loading.
+ */
+function initialize(opts) {
+ mainEl = document.getElementById("main");
+ addonPageHeader = document.getElementById("page-header");
+ categoriesBox = document.querySelector("categories-box");
+
+ loadViewFn = opts.loadViewFn;
+ replaceWithDefaultViewFn = opts.replaceWithDefaultViewFn;
+
+ if (opts.shouldLoadInitialView) {
+ opts.loadInitialViewFn(categoriesBox.initialViewId);
+ }
+ categoriesBox.initialize();
+
+ AddonManagerListenerHandler.startup();
+
+ window.addEventListener(
+ "unload",
+ () => {
+ // Clear out the document so the disconnectedCallback will trigger
+ // properly and all of the custom elements can cleanup.
+ document.body.textContent = "";
+ AddonManagerListenerHandler.shutdown();
+ },
+ { once: true }
+ );
+}
+
+/**
+ * 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, { historyEntryId }) {
+ let container = document.createElement("div");
+ container.setAttribute("current-view", type);
+ addonPageHeader.setViewInfo({ type, param });
+ categoriesBox.select(`addons://${type}/${param}`);
+ if (type == "list") {
+ await new ListView({ param, root: container }).render();
+ } else if (type == "detail") {
+ await new DetailView({
+ param,
+ root: container,
+ }).render();
+ } else if (type == "discover") {
+ let discoverView = new DiscoveryView();
+ let elem = discoverView.render();
+ await document.l10n.translateFragment(elem);
+ container.append(elem);
+ } else if (type == "updates") {
+ await new UpdatesView({ param, root: container }).render();
+ } else if (type == "shortcuts") {
+ // Force the extension category to be selected, in the case of a reload,
+ // restart, or if the view was opened from another category's page.
+ categoriesBox.selectType("extension");
+ let view = document.createElement("addon-shortcuts");
+ await view.render();
+ await document.l10n.translateFragment(view);
+ container.appendChild(view);
+ } else {
+ console.warn(`No view for ${type} ${param}, switching to default`);
+ replaceWithDefaultViewFn();
+ }
+
+ ScrollOffsets.save();
+ ScrollOffsets.setView(historyEntryId);
+ mainEl.textContent = "";
+ mainEl.appendChild(container);
+
+ // Most content has been rendered at this point. The only exception are
+ // recommendations in the discovery pane and extension/theme list, because
+ // they rely on remote data. If loaded before, then these may be rendered
+ // within one tick, so wait a frame before restoring scroll offsets.
+ return new Promise(resolve => {
+ window.requestAnimationFrame(() => {
+ ScrollOffsets.restore();
+ resolve();
+ });
+ });
+}
+
+function hide() {
+ ScrollOffsets.save();
+ ScrollOffsets.setView(null);
+ mainEl.textContent = "";
+}