From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../extensions/content/OpenH264-license.txt | 59 + toolkit/mozapps/extensions/content/aboutaddons.css | 759 ++++ .../mozapps/extensions/content/aboutaddons.html | 825 ++++ toolkit/mozapps/extensions/content/aboutaddons.js | 4231 ++++++++++++++++++++ .../extensions/content/aboutaddonsCommon.js | 275 ++ .../extensions/content/abuse-report-frame.html | 213 + .../extensions/content/abuse-report-panel.css | 181 + .../extensions/content/abuse-report-panel.js | 873 ++++ .../mozapps/extensions/content/abuse-reports.js | 376 ++ .../content/drag-drop-addon-installer.js | 81 + toolkit/mozapps/extensions/content/shortcuts.css | 138 + toolkit/mozapps/extensions/content/shortcuts.js | 658 +++ .../mozapps/extensions/content/view-controller.js | 204 + 13 files changed, 8873 insertions(+) create mode 100644 toolkit/mozapps/extensions/content/OpenH264-license.txt create mode 100644 toolkit/mozapps/extensions/content/aboutaddons.css create mode 100644 toolkit/mozapps/extensions/content/aboutaddons.html create mode 100644 toolkit/mozapps/extensions/content/aboutaddons.js create mode 100644 toolkit/mozapps/extensions/content/aboutaddonsCommon.js create mode 100644 toolkit/mozapps/extensions/content/abuse-report-frame.html create mode 100644 toolkit/mozapps/extensions/content/abuse-report-panel.css create mode 100644 toolkit/mozapps/extensions/content/abuse-report-panel.js create mode 100644 toolkit/mozapps/extensions/content/abuse-reports.js create mode 100644 toolkit/mozapps/extensions/content/drag-drop-addon-installer.js create mode 100644 toolkit/mozapps/extensions/content/shortcuts.css create mode 100644 toolkit/mozapps/extensions/content/shortcuts.js create mode 100644 toolkit/mozapps/extensions/content/view-controller.js (limited to 'toolkit/mozapps/extensions/content') diff --git a/toolkit/mozapps/extensions/content/OpenH264-license.txt b/toolkit/mozapps/extensions/content/OpenH264-license.txt new file mode 100644 index 0000000000..ad37989b8c --- /dev/null +++ b/toolkit/mozapps/extensions/content/OpenH264-license.txt @@ -0,0 +1,59 @@ +------------------------------------------------------- +About The Cisco-Provided Binary of OpenH264 Video Codec +------------------------------------------------------- + +Cisco provides this program under the terms of the BSD license. + +Additionally, this binary is licensed under Cisco’s AVC/H.264 Patent Portfolio License from MPEG LA, at no cost to you, provided that the requirements and conditions shown below in the AVC/H.264 Patent Portfolio sections are met. + +As with all AVC/H.264 codecs, you may also obtain your own patent license from MPEG LA or from the individual patent owners, or proceed at your own risk. Your rights from Cisco under the BSD license are not affected by this choice. + +For more information on the OpenH264 binary licensing, please see the OpenH264 FAQ found at http://www.openh264.org/faq.html#binary + +A corresponding source code to this binary program is available under the same BSD terms, which can be found at http://www.openh264.org + +----------- +BSD License +----------- + +Copyright © 2014 Cisco Systems, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----------------------------------------- +AVC/H.264 Patent Portfolio License Notice +----------------------------------------- + +The binary form of this Software is distributed by Cisco under the AVC/H.264 Patent Portfolio License from MPEG LA, and is subject to the following requirements, which may or may not be applicable to your use of this software: + +THIS PRODUCT IS LICENSED UNDER THE AVC PATENT PORTFOLIO LICENSE FOR THE PERSONAL USE OF A CONSUMER OR OTHER USES IN WHICH IT DOES NOT RECEIVE REMUNERATION TO (i) ENCODE VIDEO IN COMPLIANCE WITH THE AVC STANDARD (“AVC VIDEO”) AND/OR (ii) DECODE AVC VIDEO THAT WAS ENCODED BY A CONSUMER ENGAGED IN A PERSONAL ACTIVITY AND/OR WAS OBTAINED FROM A VIDEO PROVIDER LICENSED TO PROVIDE AVC VIDEO. NO LICENSE IS GRANTED OR SHALL BE IMPLIED FOR ANY OTHER USE. ADDITIONAL INFORMATION MAY BE OBTAINED FROM MPEG LA, L.L.C. SEE HTTP://WWW.MPEGLA.COM + +Accordingly, please be advised that content providers and broadcasters using AVC/H.264 in their service may be required to obtain a separate use license from MPEG LA, referred to as "(b) sublicenses" in the SUMMARY OF AVC/H.264 LICENSE TERMS from MPEG LA found at http://www.openh264.org/mpegla + +--------------------------------------------- +AVC/H.264 Patent Portfolio License Conditions +--------------------------------------------- + +In addition, the Cisco-provided binary of this Software is licensed under Cisco's license from MPEG LA only if the following conditions are met: + +1. The Cisco-provided binary is separately downloaded to an end user’s device, and not integrated into or combined with third party software prior to being downloaded to the end user’s device; + +2. The end user must have the ability to control (e.g., to enable, disable, or re-enable) the use of the Cisco-provided binary; + +3. Third party software, in the location where end users can control the use of the Cisco-provided binary, must display the following text: + + "OpenH264 Video Codec provided by Cisco Systems, Inc." + +4. Any third-party software that makes use of the Cisco-provided binary must reproduce all of the above text, as well as this last condition, in the EULA and/or in another location where licensing information is to be presented to the end user. + + + + v1.0 diff --git a/toolkit/mozapps/extensions/content/aboutaddons.css b/toolkit/mozapps/extensions/content/aboutaddons.css new file mode 100644 index 0000000000..3f4e80797a --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.css @@ -0,0 +1,759 @@ +/* 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/. */ + +:root { + --addon-icon-size: 32px; + --main-margin-start: 28px; + --section-width: 664px; + --sidebar-width: var(--in-content-sidebar-width); + --z-index-sticky-container: 5; + --z-index-popup: 10; +} + +@media (max-width: 830px) { + :root { + --main-margin-start: 16px; + /* Maintain a main margin so card shadows don't overlap the sidebar. */ + --sidebar-width: calc(var(--in-content-sidebar-width) - var(--main-margin-start)); + } +} + +*|*[hidden] { + display: none !important; +} + +body { + cursor: default; + /* The page starts to look really bad lower than this. */ + min-width: 500px; +} + +h1 { + font-size: var(--font-size-xlarge); +} + +h2 { + font-size: var(--font-size-large); +} + +#full { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; +} + +#sidebar { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + margin: 0; + overflow: hidden auto; +} + +@media (prefers-reduced-motion) { + /* Setting border-inline-end on #sidebar makes it a focusable element */ + #sidebar::after { + content: ""; + width: 1px; + height: 100%; + background-color: var(--in-content-border-color); + top: 0; + inset-inline-end: 0; + position: absolute; + } +} + +#categories { + display: flex; + flex-direction: column; + padding-inline-end: 4px; /* Leave space for the button focus styles. */ +} + +.category { + display: grid; + grid-template-columns: 1fr auto; + margin-block: 0; + align-items: center; + font-weight: normal; +} + +.category[badge-count]::after { + display: inline-block; + min-width: 20px; + background-color: var(--in-content-accent-color); + color: var(--in-content-primary-button-text-color); + font-weight: bold; + /* Use a large border-radius to get semi-circles on the sides. */ + border-radius: 1000px; + padding: 2px 6px; + content: attr(badge-count); + text-align: center; + margin-inline-start: 8px; + grid-column: 2; +} + +.category[name="discover"] { + background-image: url("chrome://mozapps/skin/extensions/category-discover.svg"); +} +.category[name="locale"] { + background-image: url("chrome://mozapps/skin/extensions/category-languages.svg"); +} +.category[name="extension"] { + background-image: url("chrome://mozapps/skin/extensions/category-extensions.svg"); +} +.category[name="theme"] { + background-image: url("chrome://mozapps/skin/extensions/category-themes.svg"); +} +.category[name="plugin"] { + background-image: url("chrome://mozapps/skin/extensions/category-plugins.svg"); +} +.category[name="dictionary"] { + background-image: url("chrome://mozapps/skin/extensions/category-dictionaries.svg"); +} +.category[name="available-updates"] { + background-image: url("chrome://mozapps/skin/extensions/category-available.svg"); +} +.category[name="recent-updates"] { + background-image: url("chrome://mozapps/skin/extensions/category-recent.svg"); +} +.category[name="sitepermission"] { + background-image: url("chrome://mozapps/skin/extensions/category-sitepermission.svg"); +} + +.sticky-container { + background: var(--in-content-page-background); + width: 100%; + position: sticky; + top: 0; + z-index: var(--z-index-sticky-container); +} + +.main-search { + background: var(--in-content-page-background); + display: flex; + justify-content: flex-end; + align-items: center; + padding-inline-start: 28px; + padding-top: 20px; + padding-bottom: 30px; + max-width: var(--section-width); +} + +search-addons > search-textbox { + margin: 0; + width: 20em; + min-height: 32px; +} + +.search-label { + margin-inline-end: 8px; +} + +.main-heading { + background: var(--in-content-page-background); + display: flex; + margin-inline-start: var(--main-margin-start); + padding-bottom: 16px; + max-width: var(--section-width); +} + +.spacer { + flex-grow: 1; +} + +#updates-message { + display: flex; + align-items: center; + margin-inline-end: 8px; +} + +.back-button { + margin-inline-end: 16px; +} + +/* Plugins aren't yet disabled by safemode (bug 342333), + so don't show that warning when viewing plugins. */ +#page-header[current-param="plugin"] moz-message-bar[warning-type="safe-mode"] { + display: none; +} + +#main { + margin-inline-start: var(--main-margin-start); + margin-bottom: 28px; + max-width: var(--section-width); +} + +global-warnings, +#abuse-reports-messages { + margin-inline-start: var(--main-margin-start); + max-width: var(--section-width); +} + +/* The margin between message bars. */ +message-bar-stack > * { + margin-bottom: 8px; +} + +/* List sections */ + +.list-section-heading { + margin-bottom: 16px; +} + +.list-section-subheading { + font-size: 0.9em; + font-weight: 400; + margin-block-start: 0.5em; +} + +.section { + margin-bottom: 32px; +} + +/* Add-on cards */ + +.addon.card { + margin-bottom: 16px; + transition: opacity 150ms, box-shadow 150ms; +} + +addon-list:not([type="theme"]) addon-card:not([expanded]):not([panelopen]) > .addon.card[active="false"]:not(:focus-within):not(:hover) { + opacity: 0.6; +} + +.addon.card:hover { + box-shadow: var(--card-shadow); +} + +addon-card:not([expanded]) > .addon.card:hover { + box-shadow: var(--card-shadow-hover); + cursor: pointer; +} + +addon-card[expanded] .addon.card { + padding-bottom: 0; +} + +.addon-card-collapsed { + display: flex; +} + +addon-list addon-card > .addon.card { + user-select: none; +} + +.addon-card-message, +.update-postponed-bar { + border-top-left-radius: 0; + border-top-right-radius: 0; + margin: 8px calc(var(--card-padding) * -1) calc(var(--card-padding) * -1); +} + +addon-card[expanded] .addon-card-message, +addon-card[expanded] .update-postponed-bar { + border-radius: 0; + margin-bottom: 0; +} + +addon-card[expanded] .update-postponed-bar + .addon-card-message { + /* Remove margin between the two message bars when they are both + * visible in the detail view */ + margin-top: 0px; +} + +.update-postponed-bar + .addon-card-message:not([hidden]) { + /* Prevent the small overlapping between the two message bars + * when they are both visible at the same time one after the + * other on the same addon card */ + margin-top: 12px; +} + +/* Theme preview image. */ +.card-heading-image { + /* If the width, height or aspect ratio changes, don't forget to update the + * getScreenshotUrlForAddon function in aboutaddons.js */ + width: var(--section-width); + /* Adjust height so that the image preserves the aspect ratio from AMO. + * For details, see https://bugzilla.mozilla.org/show_bug.cgi?id=1546123 */ + height: calc(var(--section-width) * 92 / 680); + object-fit: cover; +} + +.card-heading-icon { + flex-shrink: 0; + width: var(--addon-icon-size); + height: var(--addon-icon-size); + margin-inline-end: 16px; + -moz-context-properties: fill; + fill: currentColor; +} + +.card-contents { + word-break: break-word; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.addon-name-container { + /* Subtract the top line-height so the text and icon align at the top. */ + margin-top: -3px; + display: flex; + align-items: center; +} + +.addon-name { + font-size: 16px; + font-weight: var(--font-weight-bold); + line-height: 22px; + margin: 0; + margin-inline-end: 8px; +} + +.addon-name-link, +.addon-name-link:hover { + color: var(--in-content-text-color); + text-decoration: none; +} + +.addon-name-link:-moz-focusring { + /* Since the parent is overflow:hidden to ellipsize the regular outline is hidden. */ + outline-offset: -1px; + outline-width: 1px; +} + +.addon-badge { + display: inline-block; + margin-inline-end: 8px; + width: 22px; + height: 22px; + background-repeat: no-repeat; + background-position: center; + flex-shrink: 0; + border-radius: 11px; + -moz-context-properties: fill; + fill: #fff; +} + +.addon-badge-private-browsing-allowed { + background-image: url("chrome://global/skin/icons/indicator-private-browsing.svg"); +} + +.addon-badge-recommended { + background-color: var(--orange-50); + background-image: url("chrome://mozapps/skin/extensions/recommended.svg"); +} + +.addon-badge-line { + background-color: #fff; + background-image: url("chrome://mozapps/skin/extensions/line.svg"); + background-size: 16px; + border-radius: 10px; + border: 1px solid #CFCFD8; + width: 20px; + height: 20px; +} + +.addon-badge-verified { + background-color: var(--green-70); + background-image: url("chrome://global/skin/icons/check.svg"); +} + +.theme-enable-button { + min-width: auto; + font-size: var(--font-size-small); + min-height: auto; + height: 24px; + margin: 0; + padding: 0 8px; + font-weight: normal; +} + +.addon-description { + font-size: 14px; + line-height: 20px; + color: var(--text-color-deemphasized); + font-weight: 400; +} + +/* Prevent the content from wrapping unless expanded. */ +addon-card:not([expanded]) .card-contents { + /* We're hiding the content when it's too long, so we need to define the + * width. As long as this is less than the width of its parent it works. */ + width: 1px; + white-space: nowrap; +} + +/* Ellipsize if the content is too long. */ +addon-card:not([expanded]) .addon-name, +addon-card:not([expanded]) .addon-description { + text-overflow: ellipsis; + overflow-x: hidden; +} + +.page-options-menu { + align-self: center; +} + +.page-options-menu > .more-options-button { + background-image: url("chrome://global/skin/icons/settings.svg"); + width: 32px; + height: 32px; +} + +/* Recommended add-ons on list views */ +.recommended-heading { + margin-bottom: 24px; + margin-top: 48px; +} + +/* Discopane extensions to the add-on card */ + +recommended-addon-card .addon-description:not(:empty) { + margin-top: 0.5em; +} + +.disco-card-head { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.disco-addon-name { + font-size: inherit; + font-weight: normal; + line-height: normal; + margin: 0; +} + +.disco-addon-author { + font-size: 12px; + font-weight: normal; +} + +.disco-description-statistics { + margin-top: 1em; + display: grid; + grid-template-columns: repeat(2, max-content); + grid-column-gap: 2em; + align-items: center; +} + +.disco-cta-button { + font-size: 14px; + flex-shrink: 0; + flex-grow: 0; + align-self: baseline; + margin-inline-end: 0; +} + +.discopane-notice { + margin: 24px 0; +} + +.view-footer { + text-align: center; +} + +.view-footer-item { + margin-top: 30px; +} + +.privacy-policy-link { + font-size: small; +} + +.theme-recommendation { + text-align: start; +} + +addon-details { + color: var(--text-color-deemphasized); +} + +.addon-detail-description-wrapper { + margin: 16px 0; +} + +.addon-detail-description-collapse .addon-detail-description { + max-height: 20rem; + overflow: hidden; +} + +/* Include button to beat out .button-link which is below this */ +button.addon-detail-description-toggle { + display: flex; + align-items: center; + margin-top: 8px; + font-weight: normal; + gap: 4px; +} + +.addon-detail-description-toggle::after { + content: ""; + display: block; + background-image: url("chrome://global/skin/icons/arrow-up-12.svg"); + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + width: 12px; + height: 12px; +} + +.addon-detail-description-collapse .addon-detail-description-toggle::after { + transform: scaleY(-1); +} + +.addon-detail-contribute { + display: flex; + padding: var(--card-padding); + border: 1px solid var(--in-content-box-border-color); + border-radius: 4px; + margin-bottom: var(--card-padding); + flex-direction: column; +} + +.addon-detail-contribute > label { + font-style: italic; +} + +.addon-detail-contribute-button { + -moz-context-properties: fill; + fill: currentColor; + background-image: url("chrome://global/skin/icons/heart.svg"); + background-repeat: no-repeat; + background-position: 8px; + padding-inline-start: 28px; + margin-top: var(--card-padding); + margin-bottom: 0; + align-self: flex-end; +} + +.addon-detail-contribute-button:dir(rtl) { + background-position-x: right 8px; +} + +.addon-detail-sitepermissions, +.addon-detail-row { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--in-content-border-color); + margin: 0 calc(var(--card-padding) * -1); + padding: var(--card-padding); + color: var(--in-content-text-color); +} + +.addon-detail-row.addon-detail-help-row { + display: block; + color: var(--text-color-deemphasized); + padding-top: 4px; + padding-bottom: var(--card-padding); + border: none; +} + +.addon-detail-row-has-help { + padding-bottom: 0; +} + +.addon-detail-row input[type="checkbox"] { + margin: 0; +} + +.addon-detail-actions, +.addon-detail-rating { + display: flex; +} + +.addon-detail-actions { + gap: 20px; +} + +.addon-detail-actions > label { + flex-wrap: wrap; +} + +.addon-detail-rating > a { + margin-inline-start: 8px; +} + +.more-options-button { + min-width: auto; + min-height: auto; + width: 24px; + height: 24px; + margin: 0; + margin-inline-start: 8px; + -moz-context-properties: fill; + fill: currentColor; + background-image: url("chrome://global/skin/icons/more.svg"); + background-repeat: no-repeat; + background-position: center center; + /* Get the -badged ::after element in the right spot. */ + padding: 1px; + display: flex; + justify-content: flex-end; +} + +.more-options-button-badged::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--in-content-accent-color);; +} + +panel-item[action="remove"]::part(button) { + background-image: url("chrome://global/skin/icons/delete.svg"); +} + +panel-item[action="install-update"]::part(button) { + background-image: url("chrome://global/skin/icons/update-icon.svg"); +} + +panel-item[action="report"]::part(button) { + background-image: url(chrome://global/skin/icons/warning.svg); +} + +.hide-amo-link .amo-link-container { + display: none; +} + +.button-link { + min-height: auto; + background: none !important; + padding: 0; + margin: 0; + color: var(--link-color) !important; + cursor: pointer; + border: none; +} + +.button-link:hover { + color: var(--link-color-hover) !important; + text-decoration: underline; +} + +.button-link:active { + color: var(--link-color-active) !important; + text-decoration: none; +} + +.inline-options-stack { + /* If the options browser triggers an alert we need room to show it. */ + min-height: 250px; + width: 100%; + background-color: white; + margin-block: 4px; +} + +addon-permissions-list > .addon-detail-row { + border-top: none; +} + +.addon-permissions-list { + list-style-type: none; + margin: 0; + padding-inline-start: 8px; +} + +.addon-permissions-list > li { + border: none; + padding-block: 4px; + padding-inline-start: 2rem; + background-image: none; + background-position: 0 center; + background-size: 1.6rem 1.6rem; + background-repeat: no-repeat; +} + +.addon-permissions-list > li:dir(rtl) { + background-position-x: right 0; +} + +/* using a list-style-image prevents aligning the image */ +.addon-permissions-list > li.permission-checked { + background-image: url("chrome://global/skin/icons/check.svg"); + -moz-context-properties: fill; + fill: var(--green-60); +} + +.permission-header { + font-size: 1em; +} + +.tab-group { + display: block; + margin-top: 8px; + /* Pull the buttons flush with the side of the card */ + margin-inline: calc(var(--card-padding) * -1); + border-bottom: 1px solid var(--in-content-border-color); + border-top: 1px solid var(--in-content-border-color); + font-size: 0; + line-height: 0; +} + +button.tab-button { + appearance: none; + border-inline: none; + border-block: 2px solid transparent; + border-radius: 0; + background: transparent; + font-size: 14px; + line-height: 20px; + margin: 0; + padding: 4px 16px; +} + +button.tab-button:hover { + border-top-color: var(--in-content-box-border-color); +} + +button.tab-button[selected], +button.tab-button[selected]:hover { + border-top-color: currentColor; + color: var(--in-content-accent-color); +} + +@media (prefers-contrast) { + button.tab-button[selected], + button.tab-button[selected]:hover { + color: var(--in-content-primary-button-text-color); + background-color: var(--in-content-primary-button-background); + } +} + +button.tab-button:-moz-focusring { + outline-offset: -2px; +} + +.tab-group[last-input-type="mouse"] > button.tab-button:-moz-focusring { + outline: none; + box-shadow: none; +} + +section:not(:empty) ~ #empty-addons-message { + display: none; +} + +@media (max-width: 830px) { + .category[badge-count]::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + min-width: auto; + padding: 0; + /* move the badged dot into the top-end (right in ltr, left in rtl) corner. */ + margin-top: -20px; + } +} + +.permission-header > .addon-sitepermissions-host { + font-weight: bolder; +} diff --git a/toolkit/mozapps/extensions/content/aboutaddons.html b/toolkit/mozapps/extensions/content/aboutaddons.html new file mode 100644 index 0000000000..55d6625c08 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.html @@ -0,0 +1,825 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toolkit/mozapps/extensions/content/aboutaddons.js b/toolkit/mozapps/extensions/content/aboutaddons.js new file mode 100644 index 0000000000..37687a8be7 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.js @@ -0,0 +1,4231 @@ +/* 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] */ +/* import-globals-from aboutaddonsCommon.js */ +/* import-globals-from abuse-reports.js */ +/* import-globals-from view-controller.js */ +/* global windowRoot */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "manifestV3enabled", + "extensions.manifestV3.enabled" +); + +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/icons/plugin.svg"; +const EXTENSION_ICON_URL = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + +const PERMISSION_MASKS = { + 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: [], +}; + +const L10N_ID_MAPPING = { + "theme-disabled-heading": "theme-disabled-heading2", +}; + +function getL10nIdMapping(id) { + return L10N_ID_MAPPING[id] || id; +} + +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 && + AbuseReporter.isSupportedAddonType(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 { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService; + + if (addon.blocklistState === STATE_BLOCKED) { + return { + linkUrl: await addon.getBlocklistURL(), + linkId: "details-notification-blocked-link", + messageId: "details-notification-blocked2", + messageArgs: { name }, + type: "error", + }; + } else if (isDisabledUnsigned(addon)) { + return { + linkUrl: SUPPORT_URL + "unsigned-addons", + linkId: "details-notification-unsigned-and-disabled-link", + messageId: "details-notification-unsigned-and-disabled2", + messageArgs: { name }, + type: "error", + }; + } else if ( + !addon.isCompatible && + (AddonManager.checkCompatibility || + addon.blocklistState !== STATE_SOFTBLOCKED) + ) { + return { + messageId: "details-notification-incompatible2", + messageArgs: { name, version: Services.appinfo.version }, + type: "error", + }; + } else if (!isCorrectlySigned(addon)) { + return { + linkUrl: SUPPORT_URL + "unsigned-addons", + linkId: "details-notification-unsigned-link", + messageId: "details-notification-unsigned2", + messageArgs: { name }, + type: "warning", + }; + } else if (addon.blocklistState === STATE_SOFTBLOCKED) { + return { + linkUrl: await addon.getBlocklistURL(), + linkId: "details-notification-softblocked-link", + messageId: "details-notification-softblocked2", + messageArgs: { name }, + type: "warning", + }; + } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) { + return { + messageId: "details-notification-gmp-pending2", + messageArgs: { 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))); + gViewController.notifyEMUpdateCheckFinished(); + 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 ( + // Note: This function is async because isAllowedInPrivateBrowsing is async. + isAllowedInPrivateBrowsing(addon) + ); +} + +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 (addon.id == "default-theme@mozilla.org") { + return "chrome://mozapps/content/extensions/default-theme/preview.svg"; + } + const builtInThemePreview = BuiltInThemes.previewForBuiltInThemeId(addon.id); + if (builtInThemePreview) { + return builtInThemePreview; + } + + 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; + + this.taarRecommended = details.is_recommendation; + } +} + +/** + * 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.sys.mjs 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 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); + } + + disconnectedCallback() { + this.input.removeEventListener("command", this); + } + + handleEvent(e) { + if (e.type === "command") { + this.searchAddons(this.value); + } + } + + 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.openWebLinkIn(url, "tab"); + } +} +customElements.define("search-addons", SearchAddons); + +class MessageBarStackElement extends HTMLElement { + constructor() { + super(); + this._observer = null; + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.append(this.constructor.template.content.cloneNode(true)); + } + + connectedCallback() { + // Close any message bar that should be allowed based on the + // maximum number of message bars. + this.closeMessageBars(); + + // Observe mutations to close older bars when new ones have been + // added. + this._observer = new MutationObserver(() => { + this._observer.disconnect(); + this.closeMessageBars(); + this._observer.observe(this, { childList: true }); + }); + this._observer.observe(this, { childList: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + this._observer = null; + } + + closeMessageBars() { + const { maxMessageBarCount } = this; + if (maxMessageBarCount > 1) { + // Remove the older message bars if the stack reached the + // maximum number of message bars allowed. + while (this.childElementCount > maxMessageBarCount) { + this.firstElementChild.remove(); + } + } + } + + get maxMessageBarCount() { + return parseInt(this.getAttribute("max-message-bar-count"), 10); + } + + static get template() { + const template = document.createElement("template"); + + const style = document.createElement("style"); + // Render the stack in the reverse order if the stack has the + // reverse attribute set. + style.textContent = ` + :host { + display: block; + } + :host([reverse]) > slot { + display: flex; + flex-direction: column-reverse; + } + `; + template.content.append(style); + template.content.append(document.createElement("slot")); + + Object.defineProperty(this, "template", { + value: template, + }); + + return template; + } +} + +customElements.define("message-bar-stack", MessageBarStackElement); + +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 if (AMBrowserExtensionsImport.canCompleteOrCancelInstalls) { + this.setWarning("imported-addons", { 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("moz-message-bar"); + this.globalWarning.setAttribute("warning-type", type); + let { messageId, buttonId } = this.getGlobalWarningL10nIds(type); + document.l10n.setAttributes(this.globalWarning, messageId); + this.globalWarning.setAttribute("data-l10n-attrs", "message"); + if (opts && opts.action) { + let button = document.createElement("button"); + document.l10n.setAttributes(button, buttonId); + button.setAttribute("action", type); + button.setAttribute("slot", "actions"); + this.globalWarning.appendChild(button); + } + this.appendChild(this.globalWarning); + } + } + + getGlobalWarningL10nIds(type) { + const WARNING_TYPE_TO_L10NID_MAPPING = { + "safe-mode": { + messageId: "extensions-warning-safe-mode2", + }, + "update-security": { + messageId: "extensions-warning-update-security2", + buttonId: "extensions-warning-update-security-button", + }, + "check-compatibility": { + messageId: "extensions-warning-check-compatibility2", + buttonId: "extensions-warning-check-compatibility-button", + }, + "imported-addons": { + messageId: "extensions-warning-imported-addons2", + buttonId: "extensions-warning-imported-addons-button", + }, + }; + + return WARNING_TYPE_TO_L10NID_MAPPING[type]; + } + + 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; + case "imported-addons": + AMBrowserExtensionsImport.completeInstalls(); + break; + } + } + } + + /** + * AddonManager listener events. + */ + + onCompatibilityModeChanged() { + this.refresh(); + } + + onCheckUpdateSecurityChanged() { + this.refresh(); + } + + onBrowserExtensionsImportChanged() { + 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") + ); + } + document.addEventListener("view-selected", this); + 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() { + document.removeEventListener("view-selected", this); + 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"; + + this.backButton.disabled = !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.inputSource == 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 + ); + } else if (e.target == document && e.type == "view-selected") { + const { type, param } = e.detail; + this.setViewInfo({ type, param }); + } + } +} +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) { + gViewController.loadView("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": + gViewController.loadView("updates/recent"); + break; + case "install-from-file": + if (XPINSTALL_ENABLED) { + installAddonsFromFilePicker(); + } + 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": + gViewController.loadView("shortcuts/shortcuts"); + break; + } + } + + async checkForUpdates(e) { + 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; + 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; + } + } + + async resetAutomaticUpdates() { + let addons = await AddonManager.getAllAddons(); + for (let addon of addons) { + if ("applyBackgroundUpdates" in addon) { + addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT; + } + } + } + + /** + * 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() { + gViewController.loadView(this.viewId); + } + + get isVisible() { + // Make a category button visible only if the related addon type is + // supported by the AddonManager Providers actually registered to + // the AddonManager. + return AddonManager.hasAddonType(this.name); + } + + 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" }); + +// Create the button-group element so it gets loaded. +document.createElement("button-group"); +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; + }); + } + + handleEvent(e) { + if (e.target == document && e.type == "view-selected") { + const { type, param } = e.detail; + this.select(`addons://${type}/${param}`); + return; + } + + if (e.target == this && e.type == "button-group:key-selected") { + this.activeChild.load(); + return; + } + + if (e.type == "click") { + const button = e.target.closest("[viewid]"); + if (button) { + button.load(); + return; + } + } + + // Forward the unhandled events to the button-group custom element. + super.handleEvent(e); + } + + disconnectedCallback() { + document.removeEventListener("view-selected", this); + this.removeEventListener("button-group:key-selected", this); + this.removeEventListener("click", this); + AddonManagerListenerHandler.removeListener(this); + super.disconnectedCallback(); + } + + async initialize() { + let hiddenTypes = new Set([]); + + for (let button of this.children) { + let { defaultHidden, name } = button; + button.hidden = + !button.isVisible || (defaultHidden && this.shouldHideCategory(name)); + + if (defaultHidden && AddonManager.hasAddonType(name)) { + hiddenTypes.add(name); + } + } + + let hiddenUpdated; + if (hiddenTypes.size) { + hiddenUpdated = this.updateHiddenCategories(Array.from(hiddenTypes)); + } + + this.updateAvailableCount(); + + document.addEventListener("view-selected", this); + this.addEventListener("button-group:key-selected", this); + this.addEventListener("click", this); + AddonManagerListenerHandler.addListener(this); + + this._resolveRendered(); + await hiddenUpdated; + } + + 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}"]`); + } + + 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. + gViewController.resetState(); + } + 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 systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + let prefsItem = this.createItem({ + icon: "chrome://global/skin/icons/settings.svg", + createLinkElement: () => { + let link = document.createElement("a"); + link.href = "about:preferences"; + link.id = "preferencesButton"; + return link; + }, + titleL10nId: "sidebar-settings-button-title", + labelL10nId: "addons-settings-button", + onClick: e => { + e.preventDefault(); + windowRoot.ownerGlobal.switchToTabHavingURI("about:preferences", true, { + ignoreFragment: "whenComparing", + triggeringPrincipal: systemPrincipal, + }); + }, + }); + + let supportItem = this.createItem({ + icon: "chrome://global/skin/icons/help.svg", + createLinkElement: () => { + let link = document.createElement("a", { is: "moz-support-link" }); + link.setAttribute("support-page", "addons-help"); + link.id = "help-button"; + return link; + }, + titleL10nId: "sidebar-help-button-title", + labelL10nId: "help-button", + }); + + list.append(prefsItem, supportItem); + this.append(list); + } + + createItem({ onClick, titleL10nId, labelL10nId, icon, createLinkElement }) { + let listItem = document.createElement("li"); + + let link = createLinkElement(); + link.classList.add("sidebar-footer-link"); + link.addEventListener("click", onClick); + document.l10n.setAttributes(link, titleL10nId); + + let img = document.createElement("img"); + img.src = icon; + img.className = "sidebar-footer-icon"; + + let label = document.createElement("span"); + label.className = "sidebar-footer-label"; + document.l10n.setAttributes(label, labelL10nId); + + link.append(img, label); + listItem.append(link); + return listItem; + } +} +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.localName == "hr") { + 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: "moz-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 = { + "always-activate": false, + "never-activate": true, + }; + const action = el.getAttribute("action"); + if (action in userDisabledStates) { + let userDisabled = userDisabledStates[action]; + el.checked = addon.userDisabled === userDisabled; + el.disabled = !(el.checked || hasPermission(addon, action)); + } else { + super.setElementState(el, card, addon); + } + } +} +customElements.define("plugin-options", PluginOptions); + +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); + + this._embedderElement = null; + this._promiseDisconnected = new Promise( + resolve => (this._resolveDisconnected = resolve) + ); + } + + connectedCallback() { + window.addEventListener("scroll", this, true); + const { embedderElement } = top.browsingContext; + this._embedderElement = embedderElement; + embedderElement.addEventListener("FullZoomChange", this); + embedderElement.addEventListener("TextZoomChange", this); + } + + disconnectedCallback() { + this._resolveDisconnected(); + window.removeEventListener("scroll", this, true); + this._embedderElement?.removeEventListener("FullZoomChange", this); + this._embedderElement?.removeEventListener("TextZoomChange", this); + this._embedderElement = null; + } + + 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("messagemanagergroup", "webext-browsers"); + browser.setAttribute("id", "addon-inline-options"); + browser.setAttribute("transparent", "true"); + browser.setAttribute("forcemessagemanager", "true"); + browser.setAttribute("autocompletepopup", "PopupAutoComplete"); + + 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) { + // aboutaddons.js is not used on Android. extension.css is included in + // Firefox desktop and Thunderbird. + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + browserOptions.stylesheets = ["chrome://browser/content/extension.css"]; + } + + mm.sendAsyncMessage("Extension:InitBrowser", browserOptions); + + if (browser.isConnectedAndReady) { + this.fixupAndLoadURIString(optionsURL); + } else { + // browser custom element does opt-in the delayConnectedCallback + // behavior (see connectedCallback in the custom element definition + // from browser-custom-element.js) and so calling browser.loadURI + // would fail if the about:addons document is not yet fully loaded. + Promise.race([ + promiseEvent("DOMContentLoaded", document), + this._promiseDisconnected, + ]).then(() => { + this.fixupAndLoadURIString(optionsURL); + }); + } + }); + } + + fixupAndLoadURIString(uriString) { + if (!this.browser || !this.browser.isConnectedAndReady) { + throw new Error("Fail to loadURI"); + } + + this.browser.fixupAndLoadURIString(uriString, { + 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 empty = { origins: [], permissions: [] }; + let requiredPerms = { ...(this.addon.userPermissions ?? empty) }; + let optionalPerms = { ...(this.addon.optionalPermissions ?? empty) }; + let grantedPerms = await ExtensionPermissions.get(this.addon.id); + + if (manifestV3enabled) { + // If optional permissions include , extension can request and + // be granted permission for individual sites not listed in the manifest. + // Include them as well in the optional origins list. + optionalPerms.origins = [ + ...optionalPerms.origins, + ...grantedPerms.origins.filter(o => !requiredPerms.origins.includes(o)), + ]; + } + + let permissions = Extension.formatPermissionStrings( + { + permissions: requiredPerms, + optionalPermissions: optionalPerms, + }, + { buildOptionalOrigins: manifestV3enabled } + ); + let optionalEntries = [ + ...Object.entries(permissions.optionalPermissions), + ...Object.entries(permissions.optionalOrigins), + ]; + + 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 toggle = document.createElement("moz-toggle"); + toggle.setAttribute("label", msg); + toggle.id = `permission-${id}`; + toggle.setAttribute("permission-type", type); + + let checked = + grantedPerms.permissions.includes(perm) || + grantedPerms.origins.includes(perm); + + // If this is one of the "all sites" permissions + if (Extension.isAllSitesPermission(perm)) { + // mark it as checked if ANY of the "all sites" permission is granted. + checked = await AddonCard.optionalAllSitesGranted(this.addon.id); + toggle.toggleAttribute("permission-all-sites", true); + } + + toggle.pressed = checked; + item.classList.toggle("permission-checked", checked); + + toggle.setAttribute("permission-key", perm); + toggle.setAttribute("action", "toggle-permission"); + item.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 AddonSitePermissionsList extends HTMLElement { + setAddon(addon) { + this.addon = addon; + this.render(); + } + + async render() { + let permissions = Extension.formatPermissionStrings({ + sitePermissions: this.addon.sitePermissions, + siteOrigin: this.addon.siteOrigin, + }); + + this.textContent = ""; + let frag = importTemplate("addon-sitepermissions-list"); + + if (permissions.msgs.length) { + let section = frag.querySelector(".addon-permissions-required"); + section.hidden = false; + let list = section.querySelector(".addon-permissions-list"); + let header = section.querySelector(".permission-header"); + document.l10n.setAttributes(header, "addon-sitepermissions-required", { + hostname: new URL(this.addon.siteOrigin).hostname, + }); + + 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); + } + } + + this.appendChild(frag); + } +} +customElements.define("addon-sitepermissions-list", AddonSitePermissionsList); + +class AddonDetails extends HTMLElement { + connectedCallback() { + if (!this.children.length) { + this.render(); + } + this.deck.addEventListener("view-changed", this); + this.descriptionShowMoreButton.addEventListener("click", this); + } + + disconnectedCallback() { + this.inlineOptions.destroyBrowser(); + this.deck.removeEventListener("view-changed", this); + this.descriptionShowMoreButton.removeEventListener("click", this); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target == this.deck) { + switch (this.deck.selectedViewName) { + case "release-notes": + 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"; + } else if ( + e.type == "click" && + e.target == this.descriptionShowMoreButton + ) { + this.toggleDescription(); + } + } + + 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(); + } + } + + toggleDescription() { + this.descriptionCollapsed = !this.descriptionCollapsed; + + this.descriptionWrapper.classList.toggle( + "addon-detail-description-collapse", + this.descriptionCollapsed + ); + + this.descriptionShowMoreButton.hidden = false; + document.l10n.setAttributes( + this.descriptionShowMoreButton, + this.descriptionCollapsed + ? "addon-detail-description-expand" + : "addon-detail-description-collapse" + ); + } + + 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; + } + } + + renderDescription(addon) { + this.descriptionWrapper = this.querySelector( + ".addon-detail-description-wrapper" + ); + this.descriptionContents = this.querySelector(".addon-detail-description"); + this.descriptionShowMoreButton = this.querySelector( + ".addon-detail-description-toggle" + ); + + if (addon.getFullDescription) { + this.descriptionContents.appendChild(addon.getFullDescription(document)); + } else if (addon.fullDescription) { + this.descriptionContents.appendChild(nl2br(addon.fullDescription)); + } + + this.descriptionCollapsed = false; + + requestAnimationFrame(() => { + const remSize = parseFloat( + getComputedStyle(document.documentElement).fontSize + ); + const { height } = this.descriptionContents.getBoundingClientRect(); + + // collapse description if there are too many lines,i.e. height > (20 rem) + if (height > 20 * remSize) { + this.toggleDescription(); + } + }); + } + + updateQuarantinedDomainsUserAllowed() { + const { addon } = this; + let quarantinedDomainsUserAllowedRow = this.querySelector( + ".addon-detail-row-quarantined-domains" + ); + if (addon.canChangeQuarantineIgnored) { + quarantinedDomainsUserAllowedRow.hidden = false; + quarantinedDomainsUserAllowedRow.nextElementSibling.hidden = false; + quarantinedDomainsUserAllowedRow.querySelector( + `[value="${addon.quarantineIgnoredByUser ? 1 : 0}"]` + ).checked = true; + } else { + quarantinedDomainsUserAllowedRow.hidden = true; + quarantinedDomainsUserAllowedRow.nextElementSibling.hidden = true; + } + } + + 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 sitepermissions section. + this.sitePermissionsList = this.querySelector("addon-sitepermissions-list"); + if (addon.type == "sitepermission") { + this.sitePermissionsList.setAddon(addon); + } + this.querySelector(".addon-detail-sitepermissions").hidden = + addon.type !== "sitepermission"; + + // Set the add-on for the preferences section. + this.inlineOptions = this.querySelector("inline-options-browser"); + this.inlineOptions.setAddon(addon); + + // Full description. + this.renderDescription(addon); + this.querySelector(".addon-detail-contribute").hidden = + !addon.contributionURL; + this.querySelector(".addon-detail-row-updates").hidden = !hasPermission( + addon, + "upgrade" + ); + + if (addon.type != "extension") { + // Don't show any private browsing related section for non-extension + // addon types, because not relevant or they are either always allowed + // (e.g. static themes). + // + // TODO(Bug 1799090): introduce ad-hoc UI for "sitepermission" addon type. + } 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; + } + + this.updateQuarantinedDomainsUserAllowed(); + + // 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.reviewURL) { + ratingRow.querySelector("moz-five-star").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") { + let { origins } = addon.optionalPermissions; + let patternSet = new MatchPatternSet(origins, { ignorePath: true }); + if (!patternSet.subsumes(new MatchPattern(permission))) { + throw new Error("origin missing from manifest"); + } + } + origins = [permission]; + + // If this is one of the "all sites" permissions + if (Extension.isAllSitesPermission(permission)) { + // Grant/revoke ALL "all sites" optional permissions from the manifest. + origins = addon.optionalPermissions.origins.filter(perm => + Extension.isAllSitesPermission(perm) + ); + } + } 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-disabled": + // 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 "always-activate": + addon.userDisabled = false; + break; + case "never-activate": + addon.userDisabled = true; + break; + case "update-check": { + 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": + windowRoot.ownerGlobal.openWebLinkIn(addon.contributionURL, "tab"); + break; + case "preferences": + if (getOptionsType(addon) == "tab") { + openOptionsInTab(addon.optionsURL); + } else if (getOptionsType(addon) == "inline") { + gViewController.loadView(`detail/${this.addon.id}/preferences`); + } + break; + case "remove": + { + this.panel.hide(); + if (!hasPermission(addon, "uninstall")) { + this.sendEvent("remove-disabled"); + return; + } + let { BrowserAddonUI } = windowRoot.ownerGlobal; + let { remove, report } = await BrowserAddonUI.promptRemoveExtension( + addon + ); + 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": + gViewController.loadView(`detail/${this.addon.id}`); + break; + case "more-options": + // Open panel on click from the keyboard. + if (e.inputSource == 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(); + gViewController.loadView(`detail/${this.addon.id}`); + } + break; + } + } else if (e.type == "toggle" && action == "toggle-permission") { + let permission = e.target.getAttribute("permission-key"); + let type = e.target.getAttribute("permission-type"); + let fname = e.target.pressed ? "add" : "remove"; + this.setAddonPermission(permission, type, fname); + } else if (e.type == "change") { + let { name } = e.target; + switch (name) { + case "autoupdate": { + addon.applyBackgroundUpdates = e.target.value; + break; + } + case "private-browsing": { + 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(); + } + break; + } + case "quarantined-domains-user-allowed": { + addon.quarantineIgnoredByUser = e.target.value == "1"; + break; + } + } + } 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.addEventListener("toggle", 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.removeEventListener("toggle", 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; + let setDisabledStyle = !(addon.isActive || addon.type === "theme"); + if (!setDisabledStyle) { + 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" || + addon.type === "sitepermission" + ) { + toggleDisabledButton.pressed = !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. + // TODO: We don't show the badge for SitePermsAddon for now, but this should + // be handled in Bug 1799090. + if (addon.incognito != "not_allowed" && addon.type == "extension") { + // 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() { + const messageBar = this.card.querySelector(".addon-card-message"); + + const { + linkUrl, + linkId, + messageId, + messageArgs, + type = "", + } = await getAddonMessageInfo(this.addon); + + if (messageId) { + document.l10n.pauseObserving(); + document.l10n.setAttributes(messageBar, messageId, messageArgs); + messageBar.setAttribute("data-l10n-attrs", "message"); + + const link = messageBar.querySelector("button"); + if (linkUrl) { + document.l10n.setAttributes(link, linkId); + link.setAttribute("url", linkUrl); + link.setAttribute("slot", "actions"); + link.hidden = false; + } else { + link.removeAttribute("slot"); + link.hidden = true; + } + + document.l10n.resumeObserving(); + await document.l10n.translateFragment(messageBar); + messageBar.setAttribute("type", type); + messageBar.hidden = false; + } else { + messageBar.hidden = true; + } + } + + 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()"); + } + + this.setAttribute("addon-id", addon.id); + + this.card = importTemplate("card").firstElementChild; + let headingId = ExtensionCommon.makeWidgetId(`${addon.id}-heading`); + this.card.setAttribute("aria-labelledby", headingId); + + // Remove the toggle-disabled button(s) based on type. + if (addon.type != "theme") { + this.card.querySelector(".theme-enable-button").remove(); + } + if (addon.type != "extension" && addon.type != "sitepermission") { + 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"); + nameHeading.id = headingId; + 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 })); + } + + /** + * 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(); + } + + if (this.details && changed.includes("quarantineIgnoredByUser")) { + this.details.updateQuarantinedDomainsUserAllowed(); + } + } + + /* Extension Permission change listener */ + async onChangePermissions(data) { + let perms = data.added || data.removed; + let hasAllSites = false; + for (let permission of perms.permissions.concat(perms.origins)) { + if (Extension.isAllSitesPermission(permission)) { + hasAllSites = true; + continue; + } + let target = document.querySelector(`[permission-key="${permission}"]`); + let checked = !data.removed; + if (target) { + target.closest("li").classList.toggle("permission-checked", checked); + target.pressed = checked; + } + } + if (hasAllSites) { + // special-case for finding the all-sites target by attribute. + let target = document.querySelector("[permission-all-sites]"); + let checked = await AddonCard.optionalAllSitesGranted(this.addon.id); + target.closest("li").classList.toggle("permission-checked", checked); + target.pressed = checked; + } + } + + // Only covers optional_permissions in MV2 and all host permissions in MV3. + static async optionalAllSitesGranted(addonId) { + let granted = await ExtensionPermissions.get(addonId); + return granted.origins.some(perm => Extension.isAllSitesPermission(perm)); + } +} +customElements.define("addon-card", AddonCard); + +/** + * A child element of ``. 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("moz-five-star").rating = addon.averageRating; + } else { + card.querySelector("moz-five-star").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": + this.installDiscoAddon(); + break; + case "manage-addon": + gViewController.loadView(`detail/${this.addonId}`); + break; + } + } + + async installDiscoAddon() { + let addon = this.discoAddon; + let url = addon.sourceURI.spec; + let install = await AddonManager.getInstallForURL(url, { + name: addon.name, + telemetryInfo: { + source: "disco", + taarRecommended: addon.taarRecommended, + }, + }); + // We are hosted in a 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(`moz-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); + + if (type == "theme") { + await BuiltInThemes.ensureBuiltInThemes(); + } + + // 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 [index, section] of sectionedAddons.entries()) { + let sortByFn = this.sections[index].sortByFn || this.sortByFn; + section.sort(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("moz-message-bar"); + mb.setAttribute("addon-id", addon.id); + mb.setAttribute("type", "info"); + + const undo = document.createElement("button"); + undo.setAttribute("action", "undo"); + undo.addEventListener("click", () => { + addon.cancelUninstall(); + }); + undo.setAttribute("slot", "actions"); + + document.l10n.setAttributes(mb, "pending-uninstall-description2", { + addon: addon.name, + }); + mb.setAttribute("data-l10n-attrs", "message"); + document.l10n.setAttributes(undo, "pending-uninstall-undo-button"); + + mb.appendChild(undo); + stack.append(mb); + } + + removePendingUninstallBar(addon) { + const messagebar = this.getPendingUninstallBar(addon); + if (messagebar) { + messagebar.remove(); + } + } + + createSectionHeading(headingIndex) { + let { headingId, subheadingId } = this.sections[headingIndex]; + let frag = document.createDocumentFragment(); + let heading = document.createElement("h2"); + heading.classList.add("list-section-heading"); + document.l10n.setAttributes(heading, headingId); + frag.append(heading); + + if (subheadingId) { + heading.className = "header-name"; + let subheading = document.createElement("h3"); + subheading.classList.add("list-section-subheading"); + document.l10n.setAttributes(subheading, subheadingId); + frag.append(subheading); + } + + return frag; + } + + createEmptyListMessage() { + let emptyMessage = "list-empty-get-extensions-message"; + let linkPref = "extensions.getAddons.link.url"; + + if (this.sections && this.sections.length) { + if (this.sections[0].headingId == "locale-enabled-heading") { + emptyMessage = "list-empty-get-language-packs-message"; + linkPref = "browser.dictionaries.download.url"; + } else if (this.sections[0].headingId == "dictionary-enabled-heading") { + emptyMessage = "list-empty-get-dictionaries-message"; + linkPref = "browser.dictionaries.download.url"; + } + } + + let messageContainer = document.createElement("p"); + messageContainer.id = "empty-addons-message"; + let a = document.createElement("a"); + a.href = Services.urlFormatter.formatURLPref(linkPref); + a.setAttribute("target", "_blank"); + a.setAttribute("data-l10n-name", "get-extensions"); + document.l10n.setAttributes(messageContainer, emptyMessage, { + domain: a.hostname, + }); + messageContainer.appendChild(a); + return messageContainer; + } + + 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) { + const { sectionClass } = this.sections[index]; + + let section = document.createElement("section"); + section.setAttribute("section", index); + if (sectionClass) { + section.setAttribute("class", sectionClass); + } + + // 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); + } + + // Render the placeholder that is shown when all sections are empty. + // This call is after rendering the sections, because its visibility + // is controlled through the general sibling combinator relative to + // the sections (section ~). + let message = this.createEmptyListMessage(); + frag.appendChild(message); + + // 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("moz-message-bar"); + this.messageBar.addEventListener("message-bar:user-dismissed", this); + } + } + + handleEvent(e) { + 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, "themes"); + break; + } + } +} +customElements.define("recommended-themes-footer", RecommendedThemesFooter, { + extends: "footer", +}); + +/** + * This element will handle showing recommendations with a + * and a