/* 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 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} */ 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 , 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