diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/mozapps/extensions/content/OpenH264-license.txt | 59 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/aboutaddons.css | 768 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/aboutaddons.html | 780 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/aboutaddons.js | 4232 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/aboutaddonsCommon.js | 275 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/abuse-report-frame.html | 202 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/abuse-report-panel.css | 185 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/abuse-report-panel.js | 886 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/abuse-reports.js | 317 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/drag-drop-addon-installer.js | 81 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/rating-star.css | 41 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/shortcuts.css | 138 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/shortcuts.js | 659 | ||||
-rw-r--r-- | toolkit/mozapps/extensions/content/view-controller.js | 201 |
14 files changed, 8824 insertions, 0 deletions
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..86766610d8 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.css @@ -0,0 +1,768 @@ +/* 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; + --card-border-zap-gradient: linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%); + --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; +} + +#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"] 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 { + font-size: 17px; + font-weight: 600; + 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 { + /* 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: 600; + 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: 13px; + 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; +} + +.discopane-notice-content { + padding-block: 6px; +} + +.discopane-notice-content > span { + flex-grow: 1; + margin-inline-end: 4px; +} + +.discopane-notice-content > button { + flex-grow: 0; + flex-shrink: 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(--in-content-link-color) !important; + cursor: pointer; + border: none; +} + +.button-link:hover { + color: var(--in-content-link-color-hover) !important; + text-decoration: underline; +} + +.button-link:active { + color: var(--in-content-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..c377aac135 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.html @@ -0,0 +1,780 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <title data-l10n-id="addons-page-title"></title> + + <!-- Bug 1571346 Remove 'unsafe-inline' from style-src within about:addons --> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; style-src chrome: 'unsafe-inline'; img-src chrome: file: jar: https: http:; connect-src chrome: data: https: http:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <link rel="stylesheet" href="chrome://global/content/tabprompts.css" /> + <link rel="stylesheet" href="chrome://global/skin/tabprompts.css" /> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://mozapps/content/extensions/aboutaddons.css" + /> + <link + rel="stylesheet" + href="chrome://mozapps/content/extensions/shortcuts.css" + /> + + <link + rel="shortcut icon" + href="chrome://mozapps/skin/extensions/extension.svg" + /> + + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutAddons.ftl" /> + <link rel="localization" href="toolkit/about/abuseReports.ftl" /> + + <!-- Defer scripts so all the templates are loaded by the time they run. --> + <script + defer + src="chrome://mozapps/content/extensions/aboutaddonsCommon.js" + ></script> + <script + defer + src="chrome://mozapps/content/extensions/abuse-reports.js" + ></script> + <script + defer + src="chrome://mozapps/content/extensions/shortcuts.js" + ></script> + <script + defer + src="chrome://mozapps/content/extensions/drag-drop-addon-installer.js" + ></script> + <script + defer + src="chrome://mozapps/content/extensions/view-controller.js" + ></script> + <script + defer + src="chrome://mozapps/content/extensions/aboutaddons.js" + ></script> + <script + type="module" + src="chrome://global/content/elements/moz-toggle.mjs" + ></script> + <script + type="module" + src="chrome://global/content/elements/moz-support-link.mjs" + ></script> + </head> + <body> + <drag-drop-addon-installer></drag-drop-addon-installer> + <div id="full"> + <div id="sidebar"> + <categories-box id="categories" orientation="vertical"> + <button + is="discover-button" + viewid="addons://discover/" + class="category" + role="tab" + name="discover" + ></button> + <button + is="category-button" + viewid="addons://list/extension" + class="category" + role="tab" + name="extension" + ></button> + <button + is="category-button" + viewid="addons://list/theme" + class="category" + role="tab" + name="theme" + ></button> + <button + is="category-button" + viewid="addons://list/plugin" + class="category" + role="tab" + name="plugin" + ></button> + <button + is="category-button" + viewid="addons://list/dictionary" + class="category" + role="tab" + name="dictionary" + hidden + default-hidden + ></button> + <button + is="category-button" + viewid="addons://list/locale" + class="category" + role="tab" + name="locale" + hidden + default-hidden + ></button> + <button + is="category-button" + viewid="addons://list/sitepermission" + class="category" + role="tab" + name="sitepermission" + hidden + default-hidden + ></button> + <button + is="category-button" + viewid="addons://updates/available" + class="category" + role="tab" + name="available-updates" + hidden + default-hidden + ></button> + <button + is="category-button" + viewid="addons://updates/recent" + class="category" + role="tab" + name="recent-updates" + hidden + default-hidden + ></button> + </categories-box> + <div class="spacer"></div> + <sidebar-footer></sidebar-footer> + </div> + <div id="content"> + <addon-page-header + id="page-header" + page-options-id="page-options" + ></addon-page-header> + <addon-page-options id="page-options"></addon-page-options> + + <message-bar-stack + id="abuse-reports-messages" + reverse + max-message-bar-count="3" + > + </message-bar-stack> + + <div id="main"></div> + </div> + </div> + + <proxy-context-menu id="contentAreaContextMenu"></proxy-context-menu> + + <template name="addon-page-header"> + <div class="sticky-container"> + <div class="main-search"> + <label + for="search-addons" + class="search-label" + data-l10n-id="default-heading-search-label" + ></label> + <search-addons></search-addons> + </div> + <div class="main-heading"> + <button + class="back-button" + action="go-back" + data-l10n-id="header-back-button" + hidden + ></button> + <h1 class="header-name"></h1> + <div class="spacer"></div> + <addon-updates-message + id="updates-message" + hidden + ></addon-updates-message> + <div class="page-options-menu"> + <button + class="more-options-button" + action="page-options" + aria-haspopup="menu" + aria-expanded="false" + data-l10n-id="addon-page-options-button" + ></button> + </div> + </div> + </div> + <global-warnings></global-warnings> + </template> + + <template name="addon-page-options"> + <panel-list> + <panel-item + action="check-for-updates" + data-l10n-id="addon-updates-check-for-updates" + data-l10n-attrs="accesskey" + ></panel-item> + <panel-item + action="view-recent-updates" + data-l10n-id="addon-updates-view-updates" + data-l10n-attrs="accesskey" + ></panel-item> + <hr /> + <panel-item + action="install-from-file" + data-l10n-id="addon-install-from-file" + data-l10n-attrs="accesskey" + ></panel-item> + <panel-item + action="debug-addons" + data-l10n-id="addon-open-about-debugging" + data-l10n-attrs="accesskey" + ></panel-item> + <hr /> + <panel-item + action="set-update-automatically" + data-l10n-id="addon-updates-update-addons-automatically" + data-l10n-attrs="accesskey" + ></panel-item> + <panel-item + action="reset-update-states" + data-l10n-attrs="accesskey" + ></panel-item> + <hr /> + <panel-item + action="manage-shortcuts" + data-l10n-id="addon-manage-extensions-shortcuts" + data-l10n-attrs="accesskey" + ></panel-item> + </panel-list> + </template> + + <template name="addon-options"> + <panel-list> + <panel-item + data-l10n-id="remove-addon-button" + action="remove" + ></panel-item> + <panel-item + data-l10n-id="install-update-button" + action="install-update" + badged + ></panel-item> + <panel-item + data-l10n-id="preferences-addon-button" + action="preferences" + ></panel-item> + <hr /> + <panel-item + data-l10n-id="report-addon-button" + action="report" + ></panel-item> + <hr /> + <panel-item + data-l10n-id="manage-addon-button" + action="expand" + ></panel-item> + </panel-list> + </template> + + <template name="plugin-options"> + <panel-list> + <panel-item + data-l10n-id="always-activate-button" + action="always-activate" + ></panel-item> + <panel-item + data-l10n-id="never-activate-button" + action="never-activate" + ></panel-item> + <hr /> + <panel-item + data-l10n-id="preferences-addon-button" + action="preferences" + ></panel-item> + <hr /> + <panel-item + data-l10n-id="manage-addon-button" + action="expand" + ></panel-item> + </panel-list> + </template> + + <template name="addon-permissions-list"> + <div class="addon-permissions-required" hidden> + <h2 + class="permission-header" + data-l10n-id="addon-permissions-required" + ></h2> + <ul class="addon-permissions-list"></ul> + </div> + <div class="addon-permissions-optional" hidden> + <h2 + class="permission-header" + data-l10n-id="addon-permissions-optional" + ></h2> + <ul class="addon-permissions-list"></ul> + </div> + <div + class="addon-detail-row addon-permissions-empty" + data-l10n-id="addon-permissions-empty" + hidden + ></div> + <div class="addon-detail-row"> + <a + is="moz-support-link" + support-page="extension-permissions" + data-l10n-id="addon-permissions-learnmore" + ></a> + </div> + </template> + + <template name="addon-sitepermissions-list"> + <div class="addon-permissions-required" hidden> + <h2 + class="permission-header" + data-l10n-id="addon-sitepermissions-required" + > + <span + data-l10n-name="hostname" + class="addon-sitepermissions-host" + ></span> + </h2> + <ul class="addon-permissions-list"></ul> + </div> + </template> + + <template name="card"> + <div class="card addon"> + <img class="card-heading-image" role="presentation" /> + <div class="addon-card-collapsed"> + <img class="card-heading-icon addon-icon" alt="" /> + <div class="card-contents"> + <div class="addon-name-container"> + <a + class="addon-badge addon-badge-recommended" + is="moz-support-link" + support-page="add-on-badges" + utm-content="promoted-addon-badge" + data-l10n-id="addon-badge-recommended2" + hidden + > + </a> + <a + class="addon-badge addon-badge-line" + is="moz-support-link" + support-page="add-on-badges" + utm-content="promoted-addon-badge" + data-l10n-id="addon-badge-line3" + hidden + > + </a> + <a + class="addon-badge addon-badge-verified" + is="moz-support-link" + support-page="add-on-badges" + utm-content="promoted-addon-badge" + data-l10n-id="addon-badge-verified2" + hidden + > + </a> + <a + class="addon-badge addon-badge-private-browsing-allowed" + is="moz-support-link" + support-page="extensions-pb" + data-l10n-id="addon-badge-private-browsing-allowed2" + hidden + > + </a> + <div class="spacer"></div> + <button + class="theme-enable-button" + action="toggle-disabled" + hidden + ></button> + <moz-toggle + class="extension-enable-button" + action="toggle-disabled" + data-l10n-id="extension-enable-addon-button-label" + hidden + ></moz-toggle> + <button + class="more-options-button" + action="more-options" + data-l10n-id="addon-options-button" + aria-haspopup="menu" + aria-expanded="false" + ></button> + </div> + <!-- This ends up in the tab order when the ellipsis happens, but it isn't necessary. --> + <span class="addon-description" tabindex="-1"></span> + </div> + </div> + <message-bar class="update-postponed-bar" align="center" hidden> + <span + class="description" + data-l10n-id="install-postponed-message" + ></span> + <button + action="install-postponed" + data-l10n-id="install-postponed-button" + ></button> + </message-bar> + <message-bar class="addon-card-message" align="center" hidden> + <span></span> + <button action="link"></button> + </message-bar> + </div> + </template> + + <template name="addon-name-container-in-disco-card"> + <div class="disco-card-head"> + <h3 class="disco-addon-name"></h3> + <span class="disco-addon-author" + ><a data-l10n-name="author" target="_blank"></a + ></span> + </div> + <button class="disco-cta-button" action="install-addon"></button> + <button + class="disco-cta-button" + data-l10n-id="manage-addon-button" + action="manage-addon" + ></button> + </template> + + <template name="addon-description-in-disco-card"> + <div> + <span class="disco-description-main"></span> + </div> + <div class="disco-description-statistics"> + <five-star-rating></five-star-rating> + <span class="disco-user-count"></span> + </div> + </template> + + <template name="addon-details"> + <button-group class="tab-group"> + <button + is="named-deck-button" + deck="details-deck" + name="details" + data-l10n-id="details-addon-button" + class="tab-button ghost-button" + ></button> + <button + is="named-deck-button" + deck="details-deck" + name="preferences" + data-l10n-id="preferences-addon-button" + class="tab-button ghost-button" + ></button> + <button + is="named-deck-button" + deck="details-deck" + name="permissions" + data-l10n-id="permissions-addon-button" + class="tab-button ghost-button" + ></button> + <button + is="named-deck-button" + deck="details-deck" + name="release-notes" + data-l10n-id="release-notes-addon-button" + class="tab-button ghost-button" + ></button> + </button-group> + <named-deck id="details-deck" is-tabbed> + <section name="details"> + <div class="addon-detail-description-wrapper"> + <div class="addon-detail-description"></div> + <button + class="button-link addon-detail-description-toggle" + data-l10n-id="addon-detail-description-expand" + hidden + ></button> + </div> + <div class="addon-detail-contribute"> + <label data-l10n-id="detail-contributions-description"></label> + <button + class="addon-detail-contribute-button" + action="contribute" + data-l10n-id="detail-contributions-button" + data-l10n-attrs="accesskey" + ></button> + </div> + <div class="addon-detail-sitepermissions"> + <addon-sitepermissions-list></addon-sitepermissions-list> + </div> + <div class="addon-detail-row addon-detail-row-updates"> + <label data-l10n-id="addon-detail-updates-label"></label> + <div class="addon-detail-actions"> + <button + class="button-link" + data-l10n-id="addon-detail-update-check-label" + action="update-check" + hidden + ></button> + <label class="radio-container-with-text"> + <input type="radio" name="autoupdate" value="1" /> + <span data-l10n-id="addon-detail-updates-radio-default"></span> + </label> + <label class="radio-container-with-text"> + <input type="radio" name="autoupdate" value="2" /> + <span data-l10n-id="addon-detail-updates-radio-on"></span> + </label> + <label class="radio-container-with-text"> + <input type="radio" name="autoupdate" value="0" /> + <span data-l10n-id="addon-detail-updates-radio-off"></span> + </label> + </div> + </div> + <div + class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing" + hidden + > + <label data-l10n-id="detail-private-browsing-label"></label> + <div class="addon-detail-actions"> + <label class="radio-container-with-text"> + <input type="radio" name="private-browsing" value="1" /> + <span data-l10n-id="addon-detail-private-browsing-allow"></span> + </label> + <label class="radio-container-with-text"> + <input type="radio" name="private-browsing" value="0" /> + <span + data-l10n-id="addon-detail-private-browsing-disallow" + ></span> + </label> + </div> + </div> + <div + class="addon-detail-row addon-detail-help-row" + data-l10n-id="addon-detail-private-browsing-help" + hidden + > + <a + is="moz-support-link" + support-page="extensions-pb" + data-l10n-name="learn-more" + ></a> + </div> + <div + class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-disallowed" + hidden + > + <label data-l10n-id="detail-private-disallowed-label"></label> + </div> + <div + class="addon-detail-row addon-detail-help-row" + data-l10n-id="detail-private-disallowed-description2" + hidden + > + <a + is="moz-support-link" + data-l10n-name="learn-more" + support-page="extensions-pb" + ></a> + </div> + <div + class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-required" + hidden + > + <label + class="learn-more-label-link" + data-l10n-id="detail-private-required-label" + ></label> + </div> + <div + class="addon-detail-row addon-detail-help-row" + data-l10n-id="detail-private-required-description2" + hidden + > + <a + is="moz-support-link" + data-l10n-name="learn-more" + support-page="extensions-pb" + ></a> + </div> + <div class="addon-detail-row addon-detail-row-author"> + <label data-l10n-id="addon-detail-author-label"></label> + <a target="_blank"></a> + </div> + <div class="addon-detail-row addon-detail-row-version"> + <label data-l10n-id="addon-detail-version-label"></label> + </div> + <div class="addon-detail-row addon-detail-row-lastUpdated"> + <label data-l10n-id="addon-detail-last-updated-label"></label> + </div> + <div class="addon-detail-row addon-detail-row-homepage"> + <label data-l10n-id="addon-detail-homepage-label"></label> + <!-- URLs should always be displayed as LTR. --> + <a target="_blank" dir="ltr"></a> + </div> + <div class="addon-detail-row addon-detail-row-rating"> + <label data-l10n-id="addon-detail-rating-label"></label> + <div class="addon-detail-rating"> + <five-star-rating></five-star-rating> + <a target="_blank"></a> + </div> + </div> + </section> + <inline-options-browser name="preferences"></inline-options-browser> + <addon-permissions-list name="permissions"></addon-permissions-list> + <update-release-notes name="release-notes"></update-release-notes> + </named-deck> + </template> + + <template name="five-star-rating"> + <link + rel="stylesheet" + href="chrome://mozapps/content/extensions/rating-star.css" + /> + <span class="rating-star"></span> + <span class="rating-star"></span> + <span class="rating-star"></span> + <span class="rating-star"></span> + <span class="rating-star"></span> + </template> + + <template name="taar-notice"> + <message-bar class="discopane-notice" dismissable> + <div class="discopane-notice-content"> + <span data-l10n-id="discopane-notice-recommendations"></span> + <a + is="moz-support-link" + support-page="personalized-addons" + data-l10n-id="discopane-notice-learn-more" + action="notice-learn-more" + ></a> + </div> + </message-bar> + </template> + + <template name="recommended-footer"> + <div class="amo-link-container view-footer-item"> + <button + class="primary" + action="open-amo" + data-l10n-id="find-more-addons" + ></button> + </div> + <div class="view-footer-item"> + <a + class="privacy-policy-link" + data-l10n-id="privacy-policy" + target="_blank" + ></a> + </div> + </template> + + <template name="discopane"> + <header> + <p> + <span data-l10n-id="discopane-intro"> + <a + class="discopane-intro-learn-more-link" + is="moz-support-link" + support-page="recommended-extensions-program" + data-l10n-name="learn-more-trigger" + > + </a> + </span> + </p> + </header> + <taar-notice></taar-notice> + <recommended-addon-list></recommended-addon-list> + <footer is="recommended-footer" class="view-footer"></footer> + </template> + + <template name="recommended-extensions-section"> + <h2 + data-l10n-id="recommended-extensions-heading" + class="header-name recommended-heading" + ></h2> + <taar-notice></taar-notice> + <recommended-addon-list + type="extension" + hide-installed + ></recommended-addon-list> + <footer is="recommended-footer" class="view-footer"></footer> + </template> + + <template name="recommended-themes-footer"> + <p data-l10n-id="recommended-theme-1" class="theme-recommendation"> + <a data-l10n-name="link" target="_blank"></a> + </p> + <div class="amo-link-container view-footer-item"> + <button + class="primary" + action="open-amo" + data-l10n-id="find-more-themes" + ></button> + </div> + </template> + + <template name="recommended-themes-section"> + <h2 + data-l10n-id="recommended-themes-heading" + class="header-name recommended-heading" + ></h2> + <recommended-addon-list + type="theme" + hide-installed + ></recommended-addon-list> + <footer is="recommended-themes-footer" class="view-footer"></footer> + </template> + + <template id="shortcut-view"> + <div class="error-message"> + <img + class="error-message-icon" + src="chrome://global/skin/arrow/panelarrow-vertical.svg" + /> + <div class="error-message-label"></div> + </div> + <message-bar-stack + id="duplicate-warning-messages" + reverse + max-message-bar-count="5" + > + </message-bar-stack> + </template> + + <template id="shortcut-card-template"> + <div class="card shortcut"> + <div class="card-heading"> + <img class="card-heading-icon addon-icon" /> + <h2 class="addon-name"></h2> + </div> + </div> + </template> + + <template id="shortcut-row-template"> + <div class="shortcut-row"> + <label class="shortcut-label"></label> + <input + class="shortcut-input" + data-l10n-id="shortcuts-input" + type="text" + readonly + /> + <button class="shortcut-remove-button ghost-button"></button> + </div> + </template> + + <template id="expand-row-template"> + <div class="expand-row"> + <button class="expand-button"></button> + </div> + </template> + + <template id="shortcuts-no-addons"> + <div data-l10n-id="shortcuts-no-addons"></div> + </template> + + <template id="shortcuts-no-commands-template"> + <div data-l10n-id="shortcuts-no-commands"></div> + <ul class="shortcuts-no-commands-list"></ul> + </template> + </body> +</html> diff --git a/toolkit/mozapps/extensions/content/aboutaddons.js b/toolkit/mozapps/extensions/content/aboutaddons.js new file mode 100644 index 0000000000..8275a2afef --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.js @@ -0,0 +1,4232 @@ +/* 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, { + 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.defineLazyGetter(this, "extensionStylesheets", () => { + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + return ExtensionParent.extensionStylesheets; +}); + +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(), + messageId: "details-notification-blocked", + messageArgs: { name }, + type: "error", + }; + } else if (isDisabledUnsigned(addon)) { + return { + linkUrl: SUPPORT_URL + "unsigned-addons", + messageId: "details-notification-unsigned-and-disabled", + messageArgs: { name }, + type: "error", + }; + } else if ( + !addon.isCompatible && + (AddonManager.checkCompatibility || + addon.blocklistState !== STATE_SOFTBLOCKED) + ) { + return { + messageId: "details-notification-incompatible", + messageArgs: { name, version: Services.appinfo.version }, + type: "warning", + }; + } else if (!isCorrectlySigned(addon)) { + return { + linkUrl: SUPPORT_URL + "unsigned-addons", + messageId: "details-notification-unsigned", + messageArgs: { name }, + type: "warning", + }; + } else if (addon.blocklistState === STATE_SOFTBLOCKED) { + return { + linkUrl: await addon.getBlocklistURL(), + messageId: "details-notification-softblocked", + messageArgs: { name }, + type: "warning", + }; + } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) { + return { + messageId: "details-notification-gmp-pending", + 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<boolean, Promise> Promises from fetching the API results with or + // without a client ID. The `false` (no client ID) case could actually + // have been fetched with a client ID. See getResults() for more info. + _resultPromises: new Map(), + + /** + * Fetch the list of recommended add-ons. The results are cached. + * + * Pending requests are coalesced, so there is only one request at any given + * time. If a request fails, the pending promises are rejected, but a new + * call will result in a new request. A succesful response is cached for the + * lifetime of the document. + * + * @param {boolean} preferClientId + * A boolean indicating a preference for using a client ID. + * This will not overwrite the user preference but will + * avoid sending a client ID if no request has been made yet. + * @returns {Promise<DiscoAddonWrapper[]>} + */ + async getResults(preferClientId = true) { + // Allow a caller to set preferClientId to false, but not true if discovery + // is disabled. + preferClientId = preferClientId && this.clientIdDiscoveryEnabled; + + // Reuse a request for this preference first. + let resultPromise = + this._resultPromises.get(preferClientId) || + // If the client ID isn't preferred, we can still reuse a request with the + // client ID. + (!preferClientId && this._resultPromises.get(true)); + + if (resultPromise) { + return resultPromise; + } + + // Nothing is prepared for this preference, make a new request. + resultPromise = this._fetchRecommendedAddons(preferClientId).catch(e => { + // Delete the pending promise, so _fetchRecommendedAddons can be + // called again at the next property access. + this._resultPromises.delete(preferClientId); + Cu.reportError(e); + throw e; + }); + + // Store the new result for the preference. + this._resultPromises.set(preferClientId, resultPromise); + + return resultPromise; + }, + + get clientIdDiscoveryEnabled() { + // These prefs match Discovery.jsm for enabling clientId cookies. + return ( + Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) && + Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) && + !PrivateBrowsingUtils.isContentWindowPrivate(window) + ); + }, + + async _fetchRecommendedAddons(useClientId) { + let discoveryApiUrl = new URL( + Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL) + ); + + if (useClientId) { + let clientId = await ClientID.getClientIdHash(); + discoveryApiUrl.searchParams.set("telemetry-client-id", clientId); + } + let res = await fetch(discoveryApiUrl.href, { + credentials: "omit", + }); + if (!res.ok) { + throw new Error(`Failed to fetch recommended add-ons, ${res.status}`); + } + let { results } = await res.json(); + return results.map(details => new DiscoAddonWrapper(details)); + }, +}; + +class 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 { + this.removeWarning(); + } + } + + setWarning(type, opts) { + if ( + this.globalWarning && + this.globalWarning.getAttribute("warning-type") !== type + ) { + this.removeWarning(); + } + if (!this.globalWarning) { + this.globalWarning = document.createElement("message-bar"); + this.globalWarning.setAttribute("warning-type", type); + let textContainer = document.createElement("span"); + document.l10n.setAttributes(textContainer, `extensions-warning-${type}`); + this.globalWarning.appendChild(textContainer); + if (opts && opts.action) { + let button = document.createElement("button"); + document.l10n.setAttributes( + button, + `extensions-warning-${type}-button` + ); + button.setAttribute("action", type); + this.globalWarning.appendChild(button); + } + this.appendChild(this.globalWarning); + } + } + + removeWarning() { + if (this.globalWarning) { + this.globalWarning.remove(); + this.globalWarning = null; + } + } + + handleEvent(e) { + if (e.type === "click") { + switch (e.target.getAttribute("action")) { + case "update-security": + AddonManager.checkUpdateSecurity = true; + break; + case "check-compatibility": + AddonManager.checkCompatibility = true; + break; + } + } + } + + /** + * AddonManager listener events. + */ + + onCompatibilityModeChanged() { + this.refresh(); + } + + onCheckUpdateSecurityChanged() { + this.refresh(); + } +} +customElements.define("global-warnings", GlobalWarnings); + +class AddonPageHeader extends HTMLElement { + connectedCallback() { + if (this.childElementCount === 0) { + this.appendChild(importTemplate("addon-page-header")); + this.heading = this.querySelector(".header-name"); + this.backButton = this.querySelector(".back-button"); + this.pageOptionsMenuButton = this.querySelector( + '[action="page-options"]' + ); + // The addon-page-options element is outside of this element since this is + // position: sticky and that would break the positioning of the menu. + this.pageOptionsMenu = document.getElementById( + this.getAttribute("page-options-id") + ); + } + 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.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + this.pageOptionsMenu.toggle(e); + } + break; + } + } else if ( + e.type == "mousedown" && + e.target == pageOptionsMenuButton && + e.button == 0 + ) { + this.pageOptionsMenu.toggle(e); + } else if ( + e.target == pageOptionsMenu.panel && + (e.type == "shown" || e.type == "hidden") + ) { + this.pageOptionsMenuButton.setAttribute( + "aria-expanded", + this.pageOptionsMenu.open + ); + } 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 FiveStarRating extends HTMLElement { + static get observedAttributes() { + return ["rating"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.append(importTemplate("five-star-rating")); + } + + set rating(v) { + this.setAttribute("rating", v); + } + + get rating() { + let v = parseFloat(this.getAttribute("rating"), 10); + if (v >= 0 && v <= 5) { + return v; + } + return 0; + } + + get ratingBuckets() { + // 0 <= x < 0.25 = empty + // 0.25 <= x < 0.75 = half + // 0.75 <= x <= 1 = full + // ... et cetera, until x <= 5. + let { rating } = this; + return [0, 1, 2, 3, 4].map(ratingStart => { + let distanceToFull = rating - ratingStart; + if (distanceToFull < 0.25) { + return "empty"; + } + if (distanceToFull < 0.75) { + return "half"; + } + return "full"; + }); + } + + connectedCallback() { + this.renderRating(); + } + + attributeChangedCallback() { + this.renderRating(); + } + + renderRating() { + let starElements = this.shadowRoot.querySelectorAll(".rating-star"); + for (let [i, part] of this.ratingBuckets.entries()) { + starElements[i].setAttribute("fill", part); + } + document.l10n.setAttributes(this, "five-star-rating", { + rating: this.rating, + }); + } +} +customElements.define("five-star-rating", FiveStarRating); + +class 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) { + browserOptions.stylesheets = extensionStylesheets; + } + + 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 <all_urls>, 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(); + } + }); + } + + 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; + } + + // Author. + let creatorRow = this.querySelector(".addon-detail-row-author"); + if (addon.creator) { + let link = creatorRow.querySelector("a"); + link.hidden = !addon.creator.url; + if (link.hidden) { + creatorRow.appendChild(new Text(addon.creator.name)); + } else { + link.href = formatUTMParams( + "addons-manager-user-profile-link", + addon.creator.url + ); + link.target = "_blank"; + link.textContent = addon.creator.name; + } + } else { + creatorRow.hidden = true; + } + + // Version. Don't show a version for LWTs. + let version = this.querySelector(".addon-detail-row-version"); + if (addon.version && !/@personas\.mozilla\.org/.test(addon.id)) { + version.appendChild(new Text(addon.version)); + } else { + version.hidden = true; + } + + // Last updated. + let updateDate = this.querySelector(".addon-detail-row-lastUpdated"); + if (addon.updateDate) { + let lastUpdated = addon.updateDate.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + updateDate.appendChild(new Text(lastUpdated)); + } else { + updateDate.hidden = true; + } + + // Homepage. + let homepageRow = this.querySelector(".addon-detail-row-homepage"); + if (addon.homepageURL) { + let homepageURL = homepageRow.querySelector("a"); + homepageURL.href = addon.homepageURL; + homepageURL.textContent = addon.homepageURL; + } else { + homepageRow.hidden = true; + } + + // Rating. + let ratingRow = this.querySelector(".addon-detail-row-rating"); + if (addon.averageRating) { + ratingRow.querySelector("five-star-rating").rating = addon.averageRating; + let reviews = ratingRow.querySelector("a"); + reviews.href = formatUTMParams( + "addons-manager-reviews-link", + addon.reviewURL + ); + document.l10n.setAttributes(reviews, "addon-detail-reviews-link", { + numberOfReviews: addon.reviewCount, + }); + } else { + ratingRow.hidden = true; + } + + this.update(); + } + + showPrefs() { + if (getOptionsType(this.addon) == "inline") { + this.deck.selectedViewName = "preferences"; + this.inlineOptions.ensureBrowserCreated(); + } + } +} +customElements.define("addon-details", AddonDetails); + +/** + * A card component for managing an add-on. It should be initialized by setting + * the add-on with `setAddon()` before being connected to the document. + * + * let card = document.createElement("addon-card"); + * card.setAddon(addon); + * document.body.appendChild(card); + */ +class AddonCard extends HTMLElement { + connectedCallback() { + // If we've already rendered we can just update, otherwise render. + if (this.children.length) { + this.update(); + } else { + this.render(); + } + this.registerListeners(); + } + + disconnectedCallback() { + this.removeListeners(); + } + + get expanded() { + return this.hasAttribute("expanded"); + } + + set expanded(val) { + if (val) { + this.setAttribute("expanded", "true"); + } else { + this.removeAttribute("expanded"); + } + } + + get updateInstall() { + return this._updateInstall; + } + + set updateInstall(install) { + this._updateInstall = install; + if (this.children.length) { + this.update(); + } + } + + get reloading() { + return this.hasAttribute("reloading"); + } + + set reloading(val) { + this.toggleAttribute("reloading", val); + } + + /** + * Set the add-on for this card. The card will be populated based on the + * add-on when it is connected to the DOM. + * + * @param {AddonWrapper} addon The add-on to use. + */ + setAddon(addon) { + this.addon = addon; + let install = getUpdateInstall(addon); + if ( + install && + (isInState(install, "available") || isInState(install, "postponed")) + ) { + this.updateInstall = install; + } else { + this.updateInstall = null; + } + if (this.children.length) { + this.render(); + } + } + + async setAddonPermission(permission, type, action) { + let { addon } = this; + let origins = [], + permissions = []; + if (!["add", "remove"].includes(action)) { + throw new Error("invalid action for permission change"); + } + if (type == "permission") { + if ( + action == "add" && + !addon.optionalPermissions.permissions.includes(permission) + ) { + throw new Error("permission missing from manifest"); + } + permissions = [permission]; + } else if (type == "origin") { + if (action === "add") { + 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.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + this.panel.toggle(e); + } + break; + case "report": + this.panel.hide(); + openAbuseReport({ addonId: addon.id, reportEntryPoint: "menu" }); + break; + case "link": + if (e.target.getAttribute("url")) { + windowRoot.ownerGlobal.openWebLinkIn( + e.target.getAttribute("url"), + "tab" + ); + } + break; + default: + // Handle a click on the card itself. + if ( + !this.expanded && + (e.target === this.addonNameEl || !e.target.closest("a")) + ) { + e.preventDefault(); + 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; + if (name == "autoupdate") { + addon.applyBackgroundUpdates = e.target.value; + } else if (name == "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(); + } + } + } 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, + messageId, + messageArgs, + type = "", + } = await getAddonMessageInfo(this.addon); + + if (messageId) { + document.l10n.pauseObserving(); + document.l10n.setAttributes( + messageBar.querySelector("span"), + messageId, + messageArgs + ); + + const link = messageBar.querySelector("button"); + if (linkUrl) { + document.l10n.setAttributes(link, `${messageId}-link`); + link.setAttribute("url", linkUrl); + link.hidden = false; + } else { + 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(); + } + } + + /* 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 `<recommended-addon-list>`. It should be initialized + * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been + * installed, and call `setAddon(null)` upon uninstall. + * + * let discoAddon = new DiscoAddonWrapper({ ... }); + * let card = document.createElement("recommended-addon-card"); + * card.setDiscoAddon(discoAddon); + * document.body.appendChild(card); + * + * AddonManager.getAddonsByID(discoAddon.id) + * .then(addon => card.setAddon(addon)); + */ +class RecommendedAddonCard extends HTMLElement { + /** + * @param {DiscoAddonWrapper} addon + * The details of the add-on that should be rendered in the card. + */ + setDiscoAddon(addon) { + this.addonId = addon.id; + + // Save the information so we can install. + this.discoAddon = addon; + + let card = importTemplate("card").firstElementChild; + let heading = card.querySelector(".addon-name-container"); + heading.textContent = ""; + heading.append(importTemplate("addon-name-container-in-disco-card")); + + this.setCardContent(card, addon); + if (addon.type != "theme") { + card + .querySelector(".addon-description") + .append(importTemplate("addon-description-in-disco-card")); + this.setCardDescription(card, addon); + } + this.registerButtons(card, addon); + + this.textContent = ""; + this.append(card); + + // We initially assume that the add-on is not installed. + this.setAddon(null); + } + + /** + * Fills in all static parts of the card. + * + * @param {HTMLElement} card + * The primary content of this card. + * @param {DiscoAddonWrapper} addon + */ + setCardContent(card, addon) { + // Set the icon. + if (addon.type == "theme") { + card.querySelector(".addon-icon").hidden = true; + } else { + card.querySelector(".addon-icon").src = AddonManager.getPreferredIconURL( + addon, + 32, + window + ); + } + + // Set the theme preview. + let preview = card.querySelector(".card-heading-image"); + if (addon.type == "theme") { + let screenshotUrl = getScreenshotUrlForAddon(addon); + if (screenshotUrl) { + preview.src = screenshotUrl; + preview.hidden = false; + } + } else { + preview.hidden = true; + } + + // Set the name. + card.querySelector(".disco-addon-name").textContent = addon.name; + + // Set the author name and link to AMO. + if (addon.creator) { + let authorInfo = card.querySelector(".disco-addon-author"); + document.l10n.setAttributes(authorInfo, "created-by-author", { + author: addon.creator.name, + }); + // This is intentionally a link to the add-on listing instead of the + // author page, because the add-on listing provides more relevant info. + authorInfo.querySelector("a").href = formatUTMParams( + "discopane-entry-link", + addon.amoListingUrl + ); + authorInfo.hidden = false; + } + } + + setCardDescription(card, addon) { + // Set the description. Note that this is the editorial description, not + // the add-on's original description that would normally appear on a card. + card.querySelector(".disco-description-main").textContent = + addon.editorialDescription; + + let hasStats = false; + if (addon.averageRating) { + hasStats = true; + card.querySelector("five-star-rating").rating = addon.averageRating; + } else { + card.querySelector("five-star-rating").hidden = true; + } + + if (addon.dailyUsers) { + hasStats = true; + let userCountElem = card.querySelector(".disco-user-count"); + document.l10n.setAttributes(userCountElem, "user-count", { + dailyUsers: addon.dailyUsers, + }); + } + + card.querySelector(".disco-description-statistics").hidden = !hasStats; + } + + registerButtons(card, addon) { + let installButton = card.querySelector("[action='install-addon']"); + if (addon.type == "theme") { + document.l10n.setAttributes(installButton, "install-theme-button"); + } else { + document.l10n.setAttributes(installButton, "install-extension-button"); + } + + this.addEventListener("click", this); + } + + handleEvent(event) { + let action = event.target.getAttribute("action"); + switch (action) { + case "install-addon": + 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 <browser> in about:addons, but we can just use the + // main tab's browser since all of it is using the system principal. + let browser = window.docShell.chromeEventHandler; + AddonManager.installAddonFromWebpage( + "application/x-xpinstall", + browser, + Services.scriptSecurityManager.getSystemPrincipal(), + install + ); + } + + /** + * @param {AddonWrapper|null} addon + * The add-on that has been installed; null if it has been removed. + */ + setAddon(addon) { + let card = this.firstElementChild; + card.querySelector("[action='install-addon']").hidden = !!addon; + card.querySelector("[action='manage-addon']").hidden = !addon; + + this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing. + } +} +customElements.define("recommended-addon-card", RecommendedAddonCard); + +/** + * A list view for add-ons of a certain type. It should be initialized with the + * type of add-on to render and have section data set before being connected to + * the document. + * + * let list = document.createElement("addon-list"); + * list.type = "plugin"; + * list.setSections([{ + * headingId: "plugin-section-heading", + * filterFn: addon => !addon.isSystem, + * }]); + * document.body.appendChild(list); + */ +class AddonList extends HTMLElement { + constructor() { + super(); + this.sections = []; + this.pendingUninstallAddons = new Set(); + this._addonsToUpdate = new Set(); + this._userFocusListenersAdded = false; + } + + async connectedCallback() { + // Register the listener and get the add-ons, these operations should + // happpen as close to each other as possible. + this.registerListener(); + // Don't render again if we were rendered prior to being inserted. + if (!this.children.length) { + // Render the initial view. + this.render(); + } + } + + disconnectedCallback() { + // Remove content and stop listening until this is connected again. + this.textContent = ""; + this.removeListener(); + + // Process any pending uninstall related to this list. + for (const addon of this.pendingUninstallAddons) { + if (isPending(addon, "uninstall")) { + addon.uninstall(); + } + } + this.pendingUninstallAddons.clear(); + } + + /** + * Configure the sections in the list. + * + * @param {object[]} sections + * The options for the section. Each entry in the array should have: + * headingId: The fluent id for the section's heading. + * filterFn: A function that determines if an add-on belongs in + * the section. + */ + setSections(sections) { + this.sections = sections.map(section => Object.assign({}, section)); + } + + /** + * Set the add-on type for this list. This will be used to filter the add-ons + * that are displayed. + * + * @param {string} val The type to filter on. + */ + set type(val) { + this.setAttribute("type", val); + } + + get type() { + return this.getAttribute("type"); + } + + getSection(index) { + return this.sections[index].node; + } + + getCards(section) { + return section.querySelectorAll("addon-card"); + } + + getCard(addon) { + return this.querySelector(`addon-card[addon-id="${addon.id}"]`); + } + + getPendingUninstallBar(addon) { + return this.querySelector(`message-bar[addon-id="${addon.id}"]`); + } + + sortByFn(aAddon, bAddon) { + return aAddon.name.localeCompare(bAddon.name); + } + + async getAddons() { + if (!this.type) { + throw new Error(`type must be set to find add-ons`); + } + + // Find everything matching our type, null will find all types. + let type = this.type == "all" ? null : [this.type]; + let addons = await AddonManager.getAddonsByTypes(type); + + 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("message-bar"); + mb.setAttribute("addon-id", addon.id); + mb.setAttribute("type", "generic"); + + const addonName = document.createElement("span"); + addonName.setAttribute("data-l10n-name", "addon-name"); + const message = document.createElement("span"); + message.append(addonName); + const undo = document.createElement("button"); + undo.setAttribute("action", "undo"); + undo.addEventListener("click", () => { + addon.cancelUninstall(); + }); + + document.l10n.setAttributes(message, "pending-uninstall-description", { + addon: addon.name, + }); + document.l10n.setAttributes(undo, "pending-uninstall-undo-button"); + + mb.append(message, undo); + stack.append(mb); + } + + removePendingUninstallBar(addon) { + const messagebar = this.getPendingUninstallBar(addon); + if (messagebar) { + messagebar.remove(); + } + } + + createSectionHeading(headingIndex) { + let { headingId, 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("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 + * <recommended-addon-list> and a <footer>. The footer will be hidden until + * the <recommended-addon-list> is done making its request so the footer + * doesn't move around. + * + * Subclass this element to use it and define a `template` property to pull + * the template from. Expected template: + * + * <h1>My extra content can go here.</h1> + * <p>It can be anything but a footer or recommended-addon-list.</p> + * <recommended-addon-list></recommended-addon-list> + * <footer>My custom footer</footer> + */ +class RecommendedSection extends HTMLElement { + connectedCallback() { + if (this.childElementCount == 0) { + this.render(); + } + } + + get list() { + return this.querySelector("recommended-addon-list"); + } + + get footer() { + return this.querySelector("footer"); + } + + render() { + this.appendChild(importTemplate(this.template)); + + // Hide footer until the cards are loaded, to prevent the content from + // suddenly shifting when the user attempts to interact with it. + let { footer } = this; + footer.hidden = true; + this.list.loadCardsIfNeeded().finally(() => { + footer.hidden = false; + }); + } +} + +class RecommendedExtensionsSection extends RecommendedSection { + get template() { + return "recommended-extensions-section"; + } +} +customElements.define( + "recommended-extensions-section", + RecommendedExtensionsSection +); + +class RecommendedThemesSection extends RecommendedSection { + get template() { + return "recommended-themes-section"; + } +} +customElements.define("recommended-themes-section", RecommendedThemesSection); + +class DiscoveryPane extends RecommendedSection { + get template() { + return "discopane"; + } +} +customElements.define("discovery-pane", DiscoveryPane); + +// Define views +gViewController.defineView("list", async type => { + if (!AddonManager.hasAddonType(type)) { + return null; + } + + let frag = document.createDocumentFragment(); + let list = document.createElement("addon-list"); + list.type = type; + + let sections = [ + { + headingId: type + "-enabled-heading", + sectionClass: `${type}-enabled-section`, + filterFn: addon => + !addon.hidden && addon.isActive && !isPending(addon, "uninstall"), + }, + ]; + + const disabledAddonsFilterFn = addon => + !addon.hidden && !addon.isActive && !isPending(addon, "uninstall"); + + sections.push({ + headingId: getL10nIdMapping(`${type}-disabled-heading`), + sectionClass: `${type}-disabled-section`, + filterFn: disabledAddonsFilterFn, + }); + + list.setSections(sections); + frag.appendChild(list); + + // Show recommendations for themes and extensions. + if ( + LIST_RECOMMENDATIONS_ENABLED && + (type == "extension" || type == "theme") + ) { + let elementName = + type == "extension" + ? "recommended-extensions-section" + : "recommended-themes-section"; + let recommendations = document.createElement(elementName); + // Start loading the recommendations. This can finish after the view load + // event is sent. + recommendations.render(); + frag.appendChild(recommendations); + } + + await list.render(); + + return frag; +}); + +gViewController.defineView("detail", async param => { + let [id, selectedTab] = param.split("/"); + let addon = await AddonManager.getAddonByID(id); + + if (!addon) { + return null; + } + + let card = document.createElement("addon-card"); + + // Ensure the category for this add-on type is selected. + document.querySelector("categories-box").selectType(addon.type); + + // Go back to the list view when the add-on is removed. + card.addEventListener("remove", () => + gViewController.loadView(`list/${addon.type}`) + ); + + card.setAddon(addon); + card.expand(); + await card.render(); + if (selectedTab === "preferences" && (await isAddonOptionsUIAllowed(addon))) { + card.showPrefs(); + } + + return card; +}); + +gViewController.defineView("updates", async param => { + let list = document.createElement("addon-list"); + list.type = "all"; + if (param == "available") { + list.setSections([ + { + headingId: "available-updates-heading", + filterFn: addon => { + // Filter the addons visible in the updates view using the same + // criteria that is being used to compute the counter on the + // available updates category button badge. + const install = getUpdateInstall(addon); + return install && isManualUpdate(install) && !install.installed; + }, + }, + ]); + } else if (param == "recent") { + list.sortByFn = (a, b) => { + if (a.updateDate > b.updateDate) { + return -1; + } + if (a.updateDate < b.updateDate) { + return 1; + } + return 0; + }; + let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN; + list.setSections([ + { + headingId: "recent-updates-heading", + filterFn: addon => + !addon.hidden && addon.updateDate && addon.updateDate > updateLimit, + }, + ]); + } else { + throw new Error(`Unknown updates view ${param}`); + } + + await list.render(); + return list; +}); + +gViewController.defineView("discover", async () => { + let discopane = document.createElement("discovery-pane"); + discopane.render(); + await document.l10n.translateFragment(discopane); + return discopane; +}); + +gViewController.defineView("shortcuts", async () => { + // Force the extension category to be selected, in the case of a reload, + // restart, or if the view was opened from another category's page. + document.querySelector("categories-box").selectType("extension"); + + let view = document.createElement("addon-shortcuts"); + await view.render(); + await document.l10n.translateFragment(view); + return view; +}); + +/** + * @param {Element} el The button element. + */ +function openAmoInTab(el, path) { + let amoUrl = Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ); + + if (path) { + amoUrl += path; + } + + amoUrl = formatUTMParams("find-more-link-bottom", amoUrl); + windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab"); +} + +/** + * Called when about:addons is loaded. + */ +async function initialize() { + window.addEventListener( + "unload", + () => { + // Clear out the document so the disconnectedCallback will trigger + // properly and all of the custom elements can cleanup. + document.body.textContent = ""; + AddonManagerListenerHandler.shutdown(); + }, + { once: true } + ); + + // Init UI and view management + gViewController.initialize(document.getElementById("main")); + + document.querySelector("categories-box").initialize(); + AddonManagerListenerHandler.startup(); + + // browser.js may call loadView here if it expects an EM-loaded notification + gViewController.notifyEMLoaded(); + + // Select an initial view if no listener has set one so far + if (!gViewController.currentViewId) { + if (history.state) { + // If there is a history state to restore then use that + await gViewController.renderState(history.state); + } else { + // Fallback to the last category or first valid category view otherwise. + await gViewController.loadView( + Services.prefs.getStringPref( + PREF_UI_LASTCATEGORY, + gViewController.defaultViewId + ) + ); + } + } +} + +window.promiseInitialized = new Promise(resolve => { + window.addEventListener( + "load", + () => { + initialize().then(resolve); + }, + { once: true } + ); +}); diff --git a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js new file mode 100644 index 0000000000..739e7629d7 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js @@ -0,0 +1,275 @@ +/* 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] */ + +"use strict"; + +/* exported attachUpdateHandler, detachUpdateHandler, gBrowser, + * getBrowserElement, installAddonsFromFilePicker, + * isCorrectlySigned, isDisabledUnsigned, isDiscoverEnabled, + * isPending, loadReleaseNotes, openOptionsInTab, promiseEvent, + * shouldShowPermissionsPrompt, showPermissionsPrompt, + * PREF_UI_LASTCATEGORY */ + +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +ChromeUtils.defineESModuleGetters(this, { + Extension: "resource://gre/modules/Extension.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "XPINSTALL_ENABLED", + "xpinstall.enabled", + true +); + +const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane"; +const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory"; + +function isDiscoverEnabled() { + try { + if (!Services.prefs.getBoolPref(PREF_DISCOVER_ENABLED)) { + return false; + } + } catch (e) {} + + if (!XPINSTALL_ENABLED) { + return false; + } + + return true; +} + +function getBrowserElement() { + return window.docShell.chromeEventHandler; +} + +function promiseEvent(event, target, capture = false) { + return new Promise(resolve => { + target.addEventListener(event, resolve, { capture, once: true }); + }); +} + +function installPromptHandler(info) { + const install = this; + + let oldPerms = info.existingAddon.userPermissions; + if (!oldPerms) { + // Updating from a legacy add-on, let it proceed + return Promise.resolve(); + } + + let newPerms = info.addon.userPermissions; + + let difference = Extension.comparePermissions(oldPerms, newPerms); + + // If there are no new permissions, just proceed + if (!difference.origins.length && !difference.permissions.length) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let subject = { + wrappedJSObject: { + target: getBrowserElement(), + info: { + type: "update", + addon: info.addon, + icon: info.addon.iconURL, + // Reference to the related AddonInstall object (used in + // AMTelemetry to link the recorded event to the other events from + // the same install flow). + install, + permissions: difference, + resolve, + reject, + }, + }, + }; + Services.obs.notifyObservers(subject, "webextension-permission-prompt"); + }); +} + +function attachUpdateHandler(install) { + install.promptHandler = installPromptHandler; +} + +function detachUpdateHandler(install) { + if (install?.promptHandler === installPromptHandler) { + install.promptHandler = null; + } +} + +async function loadReleaseNotes(uri) { + const res = await fetch(uri.spec, { credentials: "omit" }); + + if (!res.ok) { + throw new Error("Error loading release notes"); + } + + // Load the content. + const text = await res.text(); + + // Setup the content sanitizer. + const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + const flags = + ParserUtils.SanitizerDropMedia | + ParserUtils.SanitizerDropNonCSSPresentation | + ParserUtils.SanitizerDropForms; + + // Sanitize and parse the content to a fragment. + const context = document.createElementNS(HTML_NS, "div"); + return ParserUtils.parseFragment(text, flags, false, uri, context); +} + +function openOptionsInTab(optionsURL) { + let mainWindow = window.windowRoot.ownerGlobal; + if ("switchToTabHavingURI" in mainWindow) { + mainWindow.switchToTabHavingURI(optionsURL, true, { + relatedToCurrent: true, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + return true; + } + return false; +} + +function shouldShowPermissionsPrompt(addon) { + if (!addon.isWebExtension || addon.seen) { + return false; + } + + let perms = addon.userPermissions; + return perms?.origins.length || perms?.permissions.length; +} + +function showPermissionsPrompt(addon) { + return new Promise(resolve => { + const permissions = addon.userPermissions; + const target = getBrowserElement(); + + const onAddonEnabled = () => { + // The user has just enabled a sideloaded extension, if the permission + // can be changed for the extension, show the post-install panel to + // give the user that opportunity. + if ( + addon.permissions & AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS + ) { + Services.obs.notifyObservers( + { addon, target }, + "webextension-install-notify" + ); + } + resolve(); + }; + + const subject = { + wrappedJSObject: { + target, + info: { + type: "sideload", + addon, + icon: addon.iconURL, + permissions, + resolve() { + addon.markAsSeen(); + addon.enable().then(onAddonEnabled); + }, + reject() { + // Ignore a cancelled permission prompt. + }, + }, + }, + }; + Services.obs.notifyObservers(subject, "webextension-permission-prompt"); + }); +} + +// Stub tabbrowser implementation for use by the tab-modal alert code +// when an alert/prompt/confirm method is called in a WebExtensions options_ui +// page (See Bug 1385548 for rationale). +var gBrowser = { + getTabModalPromptBox(browser) { + const parentWindow = window.docShell.chromeEventHandler.ownerGlobal; + + if (parentWindow.gBrowser) { + return parentWindow.gBrowser.getTabModalPromptBox(browser); + } + + return null; + }, +}; + +function isCorrectlySigned(addon) { + // Add-ons without an "isCorrectlySigned" property are correctly signed as + // they aren't the correct type for signing. + return addon.isCorrectlySigned !== false; +} + +function isDisabledUnsigned(addon) { + let signingRequired = + addon.type == "locale" + ? AddonSettings.LANGPACKS_REQUIRE_SIGNING + : AddonSettings.REQUIRE_SIGNING; + return signingRequired && !isCorrectlySigned(addon); +} + +function isPending(addon, action) { + const amAction = AddonManager["PENDING_" + action.toUpperCase()]; + return !!(addon.pendingOperations & amAction); +} + +async function installAddonsFromFilePicker() { + let [dialogTitle, filterName] = await document.l10n.formatMessages([ + { id: "addon-install-from-file-dialog-title" }, + { id: "addon-install-from-file-filter-name" }, + ]); + const nsIFilePicker = Ci.nsIFilePicker; + var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); + fp.init(window, dialogTitle.value, nsIFilePicker.modeOpenMultiple); + try { + fp.appendFilter(filterName.value, "*.xpi;*.jar;*.zip"); + fp.appendFilters(nsIFilePicker.filterAll); + } catch (e) {} + + return new Promise(resolve => { + fp.open(async result => { + if (result != nsIFilePicker.returnOK) { + return; + } + + let installTelemetryInfo = { + source: "about:addons", + method: "install-from-file", + }; + + let browser = getBrowserElement(); + let installs = []; + for (let file of fp.files) { + let install = await AddonManager.getInstallForFile( + file, + null, + installTelemetryInfo + ); + AddonManager.installAddonFromAOM( + browser, + document.documentURIObject, + install + ); + installs.push(install); + } + resolve(installs); + }); + }); +} diff --git a/toolkit/mozapps/extensions/content/abuse-report-frame.html b/toolkit/mozapps/extensions/content/abuse-report-frame.html new file mode 100644 index 0000000000..03285fb165 --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-frame.html @@ -0,0 +1,202 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <title></title> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://mozapps/content/extensions/aboutaddons.css" + /> + <link + rel="stylesheet" + href="chrome://mozapps/content/extensions/abuse-report-panel.css" + /> + + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutAddons.ftl" /> + <link rel="localization" href="toolkit/about/abuseReports.ftl" /> + + <script + defer + src="chrome://mozapps/content/extensions/abuse-report-panel.js" + ></script> + </head> + + <body> + <addon-abuse-report></addon-abuse-report> + + <!-- WebComponents Templates --> + <template id="tmpl-modal"> + <div class="modal-overlay-outer"></div> + <div class="modal-panel-container"></div> + </template> + + <template id="tmpl-abuse-report"> + <form class="addon-abuse-report" onsubmit="return false;"> + <div class="abuse-report-header"> + <img class="card-heading-icon addon-icon" /> + <div class="card-contents"> + <span class="addon-name"></span> + <span + class="addon-author-box" + data-l10n-args='{"author-name": "author placeholder"}' + data-l10n-id="abuse-report-addon-authored-by" + > + <a + data-l10n-name="author-name" + class="author" + href="#" + target="_blank" + ></a> + </span> + </div> + </div> + <button class="abuse-report-close-icon" type="button"></button> + <div class="abuse-report-contents"> + <abuse-report-reasons-panel></abuse-report-reasons-panel> + <abuse-report-submit-panel hidden></abuse-report-submit-panel> + </div> + <div class="abuse-report-buttons"> + <div class="abuse-report-reasons-buttons"> + <button + class="abuse-report-cancel" + type="button" + data-l10n-id="abuse-report-cancel-button" + ></button> + <button + class="primary abuse-report-next" + type="button" + data-l10n-id="abuse-report-next-button" + ></button> + </div> + <div class="abuse-report-submit-buttons" hidden> + <button + class="abuse-report-goback" + type="button" + data-l10n-id="abuse-report-goback-button" + ></button> + <button + class="primary abuse-report-submit" + type="button" + data-l10n-id="abuse-report-submit-button" + ></button> + </div> + </div> + </form> + </template> + + <template id="tmpl-reasons-panel"> + <h2 class="abuse-report-title"></h2> + <hr /> + <p class="abuse-report-subtitle" data-l10n-id="abuse-report-subtitle"></p> + <ul class="abuse-report-reasons"> + <li is="abuse-report-reason-listitem" report-reason="other"></li> + </ul> + <p data-l10n-id="abuse-report-learnmore"> + <a + class="abuse-report-learnmore" + target="_blank" + data-l10n-name="learnmore-link" + > + </a> + </p> + </template> + + <template id="tmpl-submit-panel"> + <h2 class="abuse-reason-title"></h2> + <abuse-report-reason-suggestions></abuse-report-reason-suggestions> + <hr /> + <p + class="abuse-report-subtitle" + data-l10n-id="abuse-report-submit-description" + ></p> + <textarea name="message" data-l10n-id="abuse-report-textarea"></textarea> + <p class="abuse-report-note" data-l10n-id="abuse-report-submit-note"></p> + </template> + + <template id="tmpl-reason-listitem"> + <label> + <input type="radio" name="reason" class="radio" /> + <span class="reason-description"></span> + <span hidden class="abuse-report-note reason-example"></span> + </label> + </template> + + <template id="tmpl-suggestions-settings"> + <p data-l10n-id="abuse-report-settings-suggestions"></p> + <p></p> + <ul> + <li> + <a + class="abuse-settings-search-learnmore" + target="_blank" + data-l10n-id="abuse-report-settings-suggestions-search" + > + </a> + </li> + <li> + <a + class="abuse-settings-homepage-learnmore" + target="_blank" + data-l10n-id="abuse-report-settings-suggestions-homepage" + > + </a> + </li> + </ul> + </template> + + <template id="tmpl-suggestions-policy"> + <p data-l10n-id="abuse-report-policy-suggestions"> + <a + class="abuse-policy-learnmore" + target="_blank" + data-l10n-name="report-infringement-link" + > + </a> + </p> + </template> + + <template id="tmpl-suggestions-broken-extension"> + <p data-l10n-id="abuse-report-broken-suggestions-extension"> + <a + class="extension-support-link" + target="_blank" + data-l10n-name="support-link" + > + </a> + </p> + + <p></p + ></template> + + <template id="tmpl-suggestions-broken-theme"> + <p data-l10n-id="abuse-report-broken-suggestions-theme"> + <a + class="extension-support-link" + target="_blank" + data-l10n-name="support-link" + > + </a> + </p> + + <p></p + ></template> + + <template id="tmpl-suggestions-broken-sitepermission"> + <p data-l10n-id="abuse-report-broken-suggestions-sitepermission"> + <a + class="extension-support-link" + target="_blank" + data-l10n-name="support-link" + > + </a> + </p> + + <p></p + ></template> + </body> +</html> diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.css b/toolkit/mozapps/extensions/content/abuse-report-panel.css new file mode 100644 index 0000000000..977717146e --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-panel.css @@ -0,0 +1,185 @@ +/* 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/. */ + +/* Abuse Reports card */ + +:root { + --close-icon-url: url("chrome://global/skin/icons/close.svg"); + --close-icon-size: 20px; + + --modal-panel-min-width: 60%; + --modal-panel-margin-top: 36px; + --modal-panel-margin-bottom: 36px; + --modal-panel-margin: 20%; + --modal-panel-padding: 40px; + + --line-height: 20px; + --textarea-height: 220px; + --button-padding: 52px; + --listitem-padding-bottom: 14px; + --list-radio-column-size: 28px; + --note-font-size: 14px; + --note-font-weight: 400; + --subtitle-font-size: 16px; + --subtitle-font-weight: 600; +} + +/* Ensure that the document (embedded in the XUL about:addons using a + XUL browser) has a transparent background */ +html { + background-color: transparent; +} + +.modal-overlay-outer { + background: var(--grey-90-a60); + width: 100%; + height: 100%; + position: fixed; + z-index: -1; +} + +.modal-panel-container { + padding-top: var(--modal-panel-margin-top); + padding-bottom: var(--modal-panel-margin-bottom); + padding-left: var(--modal-panel-margin); + padding-right: var(--modal-panel-margin); +} + +.addon-abuse-report { + min-width: var(--modal-panel-min-width); + padding: var(--modal-panel-padding); + display: flex; + flex-direction: column; + position: relative; +} + +.addon-abuse-report:hover { + /* Avoid the card box highlighting on hover. */ + box-shadow: none; +} + +.addon-abuse-report button { + padding: 0 var(--button-padding); +} + +.abuse-report-close-icon { + /* position the close button in the panel upper-right corner */ + position: absolute; + top: 12px; + inset-inline-end: 16px; +} + +button.abuse-report-close-icon { + background: var(--close-icon-url) no-repeat center center; + -moz-context-properties: fill; + color: inherit !important; + fill: currentColor; + min-width: auto; + min-height: auto; + width: var(--close-icon-size); + height: var(--close-icon-size); + margin: 0; + padding: 0; +} + +button.abuse-report-close-icon:hover { + fill-opacity: 0.1; +} + +button.abuse-report-close-icon:hover:active { + fill-opacity: 0.2; +} + +.abuse-report-header { + display: flex; + flex-direction: row; +} + +.abuse-report-contents, +.abuse-report-contents > hr { + width: 100%; +} + +.abuse-report-note { + color: var(--text-color-deemphasized); + font-size: var(--note-font-size); + font-weight: var(--note-font-weight); + line-height: var(--line-height); +} + +.abuse-report-subtitle { + font-size: var(--subtitle-font-size); + font-weight: var(--subtitle-font-weight); + line-height: var(--line-height); +} + +ul.abuse-report-reasons { + list-style-type: none; + padding-inline-start: 0; +} + +ul.abuse-report-reasons > li { + display: flex; + padding-bottom: var(--listitem-padding-bottom); +} + +ul.abuse-report-reasons > li > label { + display: grid; + grid-template-columns: var(--list-radio-column-size) auto; + grid-template-rows: 50% auto; + width: 100%; + line-height: var(--line-height); + font-size: var(--subtitle-font-size); + font-weight: var(--note-font-weight); + margin-inline-start: 4px; +} + +ul.abuse-report-reasons > li > label > [type="radio"] { + grid-column: 1; +} + +ul.abuse-report-reasons > li > label > span { + grid-column: 2; +} + +abuse-report-submit-panel textarea { + width: 100%; + height: var(--textarea-height); + resize: none; + box-sizing: border-box; +} + +/* Adapt styles for the panel opened in its own dialog window */ + +html.dialog-window { + background-color: var(--in-content-box-background); + height: 100%; + min-width: 740px; +} + +html.dialog-window body { + overflow: hidden; + min-height: 100%; + display: flex; + flex-direction: column; +} + +html.dialog-window .abuse-report-close-icon { + display: none; +} + +html.dialog-window addon-abuse-report { + flex-grow: 1; + display: flex; + /* Ensure that the dialog window starts from a reasonable initial size */ + --modal-panel-min-width: 700px; +} + +html.dialog-window addon-abuse-report form { + display: flex; +} + +html.dialog-window addon-abuse-report form .abuse-report-contents { + flex-grow: 1; +} diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.js b/toolkit/mozapps/extensions/content/abuse-report-panel.js new file mode 100644 index 0000000000..d1647ae184 --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-panel.js @@ -0,0 +1,886 @@ +/* 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] */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", +}); + +const IS_DIALOG_WINDOW = window.arguments && window.arguments.length; + +let openWebLink = IS_DIALOG_WINDOW + ? window.arguments[0].wrappedJSObject.openWebLink + : url => { + window.windowRoot.ownerGlobal.openWebLinkIn(url, "tab", { + relatedToCurrent: true, + }); + }; + +const showOnAnyType = () => false; +const hideOnAnyType = () => true; +const hideOnAddonTypes = hideForTypes => { + return addonType => hideForTypes.includes(addonType); +}; + +// The reasons string used as a key in this Map is expected to stay in sync +// with the reasons string used in the "abuseReports.ftl" locale file and +// the suggestions templates included in abuse-report-frame.html. +const ABUSE_REASONS = (window.ABUSE_REPORT_REASONS = { + damage: { + isExampleHidden: showOnAnyType, + isReasonHidden: hideOnAddonTypes(["theme"]), + }, + spam: { + isExampleHidden: showOnAnyType, + isReasonHidden: hideOnAddonTypes(["sitepermission"]), + }, + settings: { + hasSuggestions: true, + isExampleHidden: hideOnAnyType, + isReasonHidden: hideOnAddonTypes(["theme", "sitepermission"]), + }, + deceptive: { + isExampleHidden: showOnAnyType, + isReasonHidden: hideOnAddonTypes(["sitepermission"]), + }, + broken: { + hasAddonTypeL10nId: true, + hasAddonTypeSuggestionTemplate: true, + hasSuggestions: true, + isExampleHidden: hideOnAddonTypes(["theme"]), + isReasonHidden: showOnAnyType, + requiresSupportURL: true, + }, + policy: { + hasSuggestions: true, + isExampleHidden: hideOnAnyType, + isReasonHidden: hideOnAddonTypes(["sitepermission"]), + }, + unwanted: { + isExampleHidden: showOnAnyType, + isReasonHidden: hideOnAddonTypes(["theme"]), + }, + other: { + isExampleHidden: hideOnAnyType, + isReasonHidden: showOnAnyType, + }, +}); + +// Maps the reason id to the last version of the related fluent id. +// NOTE: when changing the localized string, increase the `-vN` suffix +// in the abuseReports.ftl fluent file and update this mapping table. +const REASON_L10N_STRING_MAPPING = { + "abuse-report-damage-reason": "abuse-report-damage-reason-v2", + "abuse-report-spam-reason": "abuse-report-spam-reason-v2", + "abuse-report-settings-reason": "abuse-report-settings-reason-v2", + "abuse-report-deceptive-reason": "abuse-report-deceptive-reason-v2", + "abuse-report-broken-reason-extension": + "abuse-report-broken-reason-extension-v2", + "abuse-report-broken-reason-sitepermission": + "abuse-report-broken-reason-sitepermission-v2", + "abuse-report-broken-reason-theme": "abuse-report-broken-reason-theme-v2", + "abuse-report-policy-reason": "abuse-report-policy-reason-v2", + "abuse-report-unwanted-reason": "abuse-report-unwanted-reason-v2", +}; + +function getReasonL10nId(reason, addonType) { + let reasonId = `abuse-report-${reason}-reason`; + // Special case reasons that have a addonType-specific + // l10n id. + if (ABUSE_REASONS[reason].hasAddonTypeL10nId) { + reasonId += `-${addonType}`; + } + // Map the reason to the corresponding versionized fluent string, using the + // mapping table above, if available. + return REASON_L10N_STRING_MAPPING[reasonId] || reasonId; +} + +function getSuggestionsTemplate({ addonType, reason, supportURL }) { + const reasonInfo = ABUSE_REASONS[reason]; + + if ( + !addonType || + !reasonInfo.hasSuggestions || + (reasonInfo.requiresSupportURL && !supportURL) + ) { + return null; + } + + let templateId = `tmpl-suggestions-${reason}`; + // Special case reasons that have a addonType-specific + // suggestion template. + if (reasonInfo.hasAddonTypeSuggestionTemplate) { + templateId += `-${addonType}`; + } + + return document.getElementById(templateId); +} + +// Map of the learnmore links metadata, keyed by link element class. +const LEARNMORE_LINKS = { + ".abuse-report-learnmore": { + path: "reporting-extensions-and-themes-abuse", + }, + ".abuse-settings-search-learnmore": { + path: "prefs-search", + }, + ".abuse-settings-homepage-learnmore": { + path: "prefs-homepage", + }, + ".abuse-policy-learnmore": { + baseURL: "https://www.mozilla.org/%LOCALE%/", + path: "about/legal/report-infringement/", + }, +}; + +// Format links that match the selector in the LEARNMORE_LINKS map +// found in a given container element. +function formatLearnMoreURLs(containerEl) { + for (const [linkClass, linkInfo] of Object.entries(LEARNMORE_LINKS)) { + for (const element of containerEl.querySelectorAll(linkClass)) { + const baseURL = linkInfo.baseURL + ? Services.urlFormatter.formatURL(linkInfo.baseURL) + : Services.urlFormatter.formatURLPref("app.support.baseURL"); + + element.href = baseURL + linkInfo.path; + } + } +} + +// Define a set of getters from a Map<propertyName, selector>. +function defineElementSelectorsGetters(object, propsMap) { + const props = Object.entries(propsMap).reduce((acc, entry) => { + const [name, selector] = entry; + acc[name] = { get: () => object.querySelector(selector) }; + return acc; + }, {}); + Object.defineProperties(object, props); +} + +// Define a set of properties getters and setters for a +// Map<propertyName, attributeName>. +function defineElementAttributesProperties(object, propsMap) { + const props = Object.entries(propsMap).reduce((acc, entry) => { + const [name, attr] = entry; + acc[name] = { + get: () => object.getAttribute(attr), + set: value => { + object.setAttribute(attr, value); + }, + }; + return acc; + }, {}); + Object.defineProperties(object, props); +} + +// Return an object with properties associated to elements +// found using the related selector in the propsMap. +function getElements(containerEl, propsMap) { + return Object.entries(propsMap).reduce((acc, entry) => { + const [name, selector] = entry; + let elements = containerEl.querySelectorAll(selector); + acc[name] = elements.length > 1 ? elements : elements[0]; + return acc; + }, {}); +} + +function dispatchCustomEvent(el, eventName, detail) { + el.dispatchEvent(new CustomEvent(eventName, { detail })); +} + +// This WebComponent extends the li item to represent an abuse report reason +// and it is responsible for: +// - embedding a photon styled radio buttons +// - localizing the reason list item +// - optionally embedding a localized example, positioned +// below the reason label, and adjusts the item height +// accordingly +class AbuseReasonListItem extends HTMLLIElement { + constructor() { + super(); + defineElementAttributesProperties(this, { + addonType: "addon-type", + reason: "report-reason", + checked: "checked", + }); + } + + connectedCallback() { + this.update(); + } + + async update() { + if (this.reason !== "other" && !this.addonType) { + return; + } + + const { reason, checked, addonType } = this; + + this.textContent = ""; + const content = document.importNode(this.template.content, true); + + if (reason) { + const reasonId = `abuse-reason-${reason}`; + const reasonInfo = ABUSE_REASONS[reason] || {}; + + const { labelEl, descriptionEl, radioEl } = getElements(content, { + labelEl: "label", + descriptionEl: ".reason-description", + radioEl: "input[type=radio]", + }); + + labelEl.setAttribute("for", reasonId); + radioEl.id = reasonId; + radioEl.value = reason; + radioEl.checked = !!checked; + + // This reason has a different localized description based on the + // addon type. + document.l10n.setAttributes( + descriptionEl, + getReasonL10nId(reason, addonType) + ); + + // Show the reason example if supported for the addon type. + if (!reasonInfo.isExampleHidden(addonType)) { + const exampleEl = content.querySelector(".reason-example"); + document.l10n.setAttributes( + exampleEl, + `abuse-report-${reason}-example` + ); + exampleEl.hidden = false; + } + } + + formatLearnMoreURLs(content); + + this.appendChild(content); + } + + get template() { + return document.getElementById("tmpl-reason-listitem"); + } +} + +// This WebComponents implements the first step of the abuse +// report submission and embeds a randomized reasons list. +class AbuseReasonsPanel extends HTMLElement { + constructor() { + super(); + defineElementAttributesProperties(this, { + addonType: "addon-type", + }); + } + + connectedCallback() { + this.update(); + } + + update() { + if (!this.isConnected || !this.addonType) { + return; + } + + const { addonType } = this; + + this.textContent = ""; + const content = document.importNode(this.template.content, true); + + const { titleEl, listEl } = getElements(content, { + titleEl: ".abuse-report-title", + listEl: "ul.abuse-report-reasons", + }); + + // Change the title l10n-id if the addon type is theme. + document.l10n.setAttributes(titleEl, `abuse-report-title-${addonType}`); + + // Create the randomized list of reasons. + const reasons = Object.keys(ABUSE_REASONS) + .filter(reason => reason !== "other") + .sort(() => Math.random() - 0.5); + + for (const reason of reasons) { + const reasonInfo = ABUSE_REASONS[reason]; + if (!reasonInfo || reasonInfo.isReasonHidden(addonType)) { + // Skip an extension only reason while reporting a theme. + continue; + } + const item = document.createElement("li", { + is: "abuse-report-reason-listitem", + }); + item.reason = reason; + item.addonType = addonType; + + listEl.prepend(item); + } + + listEl.firstElementChild.checked = true; + formatLearnMoreURLs(content); + + this.appendChild(content); + } + + get template() { + return document.getElementById("tmpl-reasons-panel"); + } +} + +// This WebComponent is responsible for the suggestions, which are: +// - generated based on a template keyed by abuse report reason +// - localized by assigning fluent ids generated from the abuse report reason +// - learn more and extension support url are then generated when the +// specific reason expects it +class AbuseReasonSuggestions extends HTMLElement { + constructor() { + super(); + defineElementAttributesProperties(this, { + extensionSupportURL: "extension-support-url", + reason: "report-reason", + }); + } + + update() { + const { addonType, extensionSupportURL, reason } = this; + + this.textContent = ""; + + let template = getSuggestionsTemplate({ + addonType, + reason, + supportURL: extensionSupportURL, + }); + + if (template) { + let content = document.importNode(template.content, true); + + formatLearnMoreURLs(content); + + let extSupportLink = content.querySelector("a.extension-support-link"); + if (extSupportLink) { + extSupportLink.href = extensionSupportURL; + } + + this.appendChild(content); + this.hidden = false; + } else { + this.hidden = true; + } + } + + get LEARNMORE_LINKS() { + return Object.keys(LEARNMORE_LINKS); + } +} + +// This WebComponents implements the last step of the abuse report submission. +class AbuseSubmitPanel extends HTMLElement { + constructor() { + super(); + defineElementAttributesProperties(this, { + addonType: "addon-type", + reason: "report-reason", + extensionSupportURL: "extensionSupportURL", + }); + defineElementSelectorsGetters(this, { + _textarea: "textarea", + _title: ".abuse-reason-title", + _suggestions: "abuse-report-reason-suggestions", + }); + } + + connectedCallback() { + this.render(); + } + + render() { + this.textContent = ""; + this.appendChild(document.importNode(this.template.content, true)); + } + + update() { + if (!this.isConnected || !this.addonType) { + return; + } + const { addonType, reason, _suggestions, _title } = this; + document.l10n.setAttributes(_title, getReasonL10nId(reason, addonType)); + _suggestions.reason = reason; + _suggestions.addonType = addonType; + _suggestions.extensionSupportURL = this.extensionSupportURL; + _suggestions.update(); + } + + clear() { + this._textarea.value = ""; + } + + get template() { + return document.getElementById("tmpl-submit-panel"); + } +} + +// This WebComponent provides the abuse report +class AbuseReport extends HTMLElement { + constructor() { + super(); + this._report = null; + defineElementSelectorsGetters(this, { + _form: "form", + _textarea: "textarea", + _radioCheckedReason: "[type=radio]:checked", + _reasonsPanel: "abuse-report-reasons-panel", + _submitPanel: "abuse-report-submit-panel", + _reasonsPanelButtons: ".abuse-report-reasons-buttons", + _submitPanelButtons: ".abuse-report-submit-buttons", + _iconClose: ".abuse-report-close-icon", + _btnNext: "button.abuse-report-next", + _btnCancel: "button.abuse-report-cancel", + _btnGoBack: "button.abuse-report-goback", + _btnSubmit: "button.abuse-report-submit", + _addonAuthorContainer: ".abuse-report-header .addon-author-box", + _addonIconElement: ".abuse-report-header img.addon-icon", + _addonNameElement: ".abuse-report-header .addon-name", + _linkAddonAuthor: ".abuse-report-header .addon-author-box a.author", + }); + } + + connectedCallback() { + this.render(); + + this.addEventListener("click", this); + + // Start listening to keydown events (to close the modal + // when Escape has been pressed and to handling the keyboard + // navigation). + document.addEventListener("keydown", this); + } + + disconnectedCallback() { + this.textContent = ""; + this.removeEventListener("click", this); + document.removeEventListener("keydown", this); + } + + handleEvent(evt) { + if (!this.isConnected || !this.addon) { + return; + } + + switch (evt.type) { + case "keydown": + if (evt.key === "Escape") { + // Prevent Esc to close the panel if the textarea is + // empty. + if (this.message && !this._submitPanel.hidden) { + return; + } + this.cancel(); + } + if (!IS_DIALOG_WINDOW) { + // Workaround keyboard navigation issues when + // the panel is running in its own dialog window. + this.handleKeyboardNavigation(evt); + } + break; + case "click": + if (evt.target === this._iconClose || evt.target === this._btnCancel) { + // NOTE: clear the focus on the clicked element to ensure that + // -moz-focusring pseudo class is not still set on the element + // when the panel is going to be shown again (See Bug 1560949). + evt.target.blur(); + this.cancel(); + } + if (evt.target === this._btnNext) { + this.switchToSubmitMode(); + } + if (evt.target === this._btnGoBack) { + this.switchToListMode(); + } + if (evt.target === this._btnSubmit) { + this.submit(); + } + if (evt.target.localName === "a") { + evt.preventDefault(); + evt.stopPropagation(); + const url = evt.target.getAttribute("href"); + // Ignore if url is empty. + if (url) { + openWebLink(url); + } + } + break; + } + } + + handleKeyboardNavigation(evt) { + if ( + evt.keyCode !== evt.DOM_VK_TAB || + evt.altKey || + evt.controlKey || + evt.metaKey + ) { + return; + } + + const fm = Services.focus; + const backward = evt.shiftKey; + + const isFirstFocusableElement = el => { + // Also consider the document body as a valid first focusable element. + if (el === document.body) { + return true; + } + // XXXrpl unfortunately there is no way to get the first focusable element + // without asking the focus manager to move focus to it (similar strategy + // is also being used in about:prefereces subdialog.js). + const rv = el == fm.moveFocus(window, null, fm.MOVEFOCUS_FIRST, 0); + fm.setFocus(el, 0); + return rv; + }; + + // If the focus is exiting the panel while navigating + // backward, focus the previous element sibling on the + // Firefox UI. + if (backward && isFirstFocusableElement(evt.target)) { + evt.preventDefault(); + evt.stopImmediatePropagation(); + const chromeWin = window.windowRoot.ownerGlobal; + Services.focus.moveFocus( + chromeWin, + null, + Services.focus.MOVEFOCUS_BACKWARD, + Services.focus.FLAG_BYKEY + ); + } + } + + render() { + this.textContent = ""; + const formTemplate = document.importNode(this.template.content, true); + if (IS_DIALOG_WINDOW) { + this.appendChild(formTemplate); + } else { + // Append the report form inside a modal overlay when the report panel + // is a sub-frame of the about:addons tab. + const modalTemplate = document.importNode( + this.modalTemplate.content, + true + ); + + this.appendChild(modalTemplate); + this.querySelector(".modal-panel-container").appendChild(formTemplate); + + // Add the card styles to the form. + this.querySelector("form").classList.add("card"); + } + } + + async update() { + if (!this.addon) { + return; + } + + const { + addonId, + addonType, + _addonAuthorContainer, + _addonIconElement, + _addonNameElement, + _linkAddonAuthor, + _reasonsPanel, + _submitPanel, + } = this; + + // Ensure that the first step of the abuse submission is the one + // currently visible. + this.switchToListMode(); + + // Cancel the abuse report if the addon is not an extension or theme. + if (!AbuseReporter.isSupportedAddonType(addonType)) { + Cu.reportError( + new Error( + `Closing abuse report panel on unexpected addon type: ${addonType}` + ) + ); + this.cancel(); + return; + } + + _addonNameElement.textContent = this.addonName; + + if (this.authorName) { + _linkAddonAuthor.href = this.authorURL || this.homepageURL; + _linkAddonAuthor.textContent = this.authorName; + document.l10n.setAttributes( + _linkAddonAuthor.parentNode, + "abuse-report-addon-authored-by", + { "author-name": this.authorName } + ); + _addonAuthorContainer.hidden = false; + } else { + _addonAuthorContainer.hidden = true; + } + + _addonIconElement.setAttribute("src", this.iconURL); + + _reasonsPanel.addonType = this.addonType; + _reasonsPanel.update(); + + _submitPanel.addonType = this.addonType; + _submitPanel.reason = this.reason; + _submitPanel.extensionSupportURL = this.supportURL; + _submitPanel.update(); + + this.focus(); + + dispatchCustomEvent(this, "abuse-report:updated", { + addonId, + panel: "reasons", + }); + } + + setAbuseReport(abuseReport) { + this._report = abuseReport; + // Clear the textarea from any previously entered content. + this._submitPanel.clear(); + + if (abuseReport) { + this.update(); + this.hidden = false; + } else { + this.hidden = true; + } + } + + focus() { + if (!this.isConnected || !this.addon) { + return; + } + if (this._reasonsPanel.hidden) { + const { _textarea } = this; + _textarea.focus(); + _textarea.select(); + } else { + const { _radioCheckedReason } = this; + if (_radioCheckedReason) { + _radioCheckedReason.focus(); + } + } + } + + cancel() { + if (!this.isConnected || !this.addon) { + return; + } + this._report = null; + dispatchCustomEvent(this, "abuse-report:cancel"); + } + + submit() { + if (!this.isConnected || !this.addon) { + return; + } + this._report.setMessage(this.message); + this._report.setReason(this.reason); + dispatchCustomEvent(this, "abuse-report:submit", { + addonId: this.addonId, + report: this._report, + }); + } + + switchToSubmitMode() { + if (!this.isConnected || !this.addon) { + return; + } + this._submitPanel.reason = this.reason; + this._submitPanel.update(); + this._reasonsPanel.hidden = true; + this._reasonsPanelButtons.hidden = true; + this._submitPanel.hidden = false; + this._submitPanelButtons.hidden = false; + // Adjust the focused element when switching to the submit panel. + this.focus(); + dispatchCustomEvent(this, "abuse-report:updated", { + addonId: this.addonId, + panel: "submit", + }); + } + + switchToListMode() { + if (!this.isConnected || !this.addon) { + return; + } + this._submitPanel.hidden = true; + this._submitPanelButtons.hidden = true; + this._reasonsPanel.hidden = false; + this._reasonsPanelButtons.hidden = false; + // Adjust the focused element when switching back to the list of reasons. + this.focus(); + dispatchCustomEvent(this, "abuse-report:updated", { + addonId: this.addonId, + panel: "reasons", + }); + } + + get addon() { + return this._report?.addon; + } + + get addonId() { + return this.addon?.id; + } + + get addonName() { + return this.addon?.name; + } + + get addonType() { + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based + // implementation is also removed. + if (this.addon?.type === "sitepermission-deprecated") { + return "sitepermission"; + } + return this.addon?.type; + } + + get addonCreator() { + return this.addon?.creator; + } + + get homepageURL() { + return this.addon?.homepageURL || this.authorURL || ""; + } + + get authorName() { + // The author name may be missing on some of the test extensions + // (or for temporarily installed add-ons). + return this.addonCreator?.name || ""; + } + + get authorURL() { + return this.addonCreator?.url || ""; + } + + get iconURL() { + if (this.addonType === "sitepermission") { + return "chrome://mozapps/skin/extensions/category-sitepermission.svg"; + } + return ( + this.addon?.iconURL || + // Some extensions (e.g. static theme addons) may not have an icon, + // and so we fallback to use the generic extension icon. + "chrome://mozapps/skin/extensions/extensionGeneric.svg" + ); + } + + get supportURL() { + let url = this.addon?.supportURL || this.homepageURL || ""; + if (!url && this.addonType === "sitepermission" && this.addon?.siteOrigin) { + return this.addon.siteOrigin; + } + return url; + } + + get message() { + return this._form.elements.message.value; + } + + get reason() { + return this._form.elements.reason.value; + } + + get modalTemplate() { + return document.getElementById("tmpl-modal"); + } + + get template() { + return document.getElementById("tmpl-abuse-report"); + } +} + +customElements.define("abuse-report-reason-listitem", AbuseReasonListItem, { + extends: "li", +}); +customElements.define( + "abuse-report-reason-suggestions", + AbuseReasonSuggestions +); +customElements.define("abuse-report-reasons-panel", AbuseReasonsPanel); +customElements.define("abuse-report-submit-panel", AbuseSubmitPanel); +customElements.define("addon-abuse-report", AbuseReport); + +// The panel has been opened in a new dialog window. +if (IS_DIALOG_WINDOW) { + // CSS customizations when panel is in its own window + // (vs. being an about:addons subframe). + document.documentElement.className = "dialog-window"; + + const { report, deferredReport, deferredReportPanel } = + window.arguments[0].wrappedJSObject; + + window.addEventListener( + "unload", + () => { + // If the window has been closed resolve the deferredReport + // promise and reject the deferredReportPanel one, in case + // they haven't been resolved yet. + deferredReport.resolve({ userCancelled: true }); + deferredReportPanel.reject(new Error("report dialog closed")); + }, + { once: true } + ); + + document.l10n.setAttributes( + document.querySelector("head > title"), + "abuse-report-dialog-title", + { + "addon-name": report.addon.name, + } + ); + + const el = document.querySelector("addon-abuse-report"); + el.addEventListener("abuse-report:submit", () => { + deferredReport.resolve({ + userCancelled: false, + report, + }); + }); + el.addEventListener( + "abuse-report:cancel", + () => { + // Resolve the report panel deferred (in case the report + // has been cancelled automatically before it has been fully + // rendered, e.g. in case of non-supported addon types). + deferredReportPanel.resolve(el); + // Resolve the deferred report as cancelled. + deferredReport.resolve({ userCancelled: true }); + }, + { once: true } + ); + + // Adjust window size (if needed) once the fluent strings have been + // added to the document and the document has been flushed. + el.addEventListener( + "abuse-report:updated", + async () => { + const form = document.querySelector("form"); + await document.l10n.translateFragment(form); + const { clientWidth, clientHeight } = await window.promiseDocumentFlushed( + () => form + ); + // Resolve promiseReportPanel once the panel completed the initial render + // (used in tests). + deferredReportPanel.resolve(el); + if ( + window.innerWidth !== clientWidth || + window.innerheight !== clientHeight + ) { + window.resizeTo(clientWidth, clientHeight); + } + }, + { once: true } + ); + el.setAbuseReport(report); +} diff --git a/toolkit/mozapps/extensions/content/abuse-reports.js b/toolkit/mozapps/extensions/content/abuse-reports.js new file mode 100644 index 0000000000..cf5fe27ee5 --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-reports.js @@ -0,0 +1,317 @@ +/* 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 */ +/* exported openAbuseReport */ + +/** + * This script is part of the HTML about:addons page and it provides some + * helpers used for the Abuse Reporting submission (and related message bars). + */ + +const { AbuseReporter } = ChromeUtils.importESModule( + "resource://gre/modules/AbuseReporter.sys.mjs" +); + +// Message Bars definitions. +const ABUSE_REPORT_MESSAGE_BARS = { + // Idle message-bar (used while the submission is still ongoing). + submitting: { id: "submitting", actions: ["cancel"] }, + // Submitted report message-bar. + submitted: { + id: "submitted", + actionAddonTypeSuffix: true, + actions: ["remove", "keep"], + dismissable: true, + }, + // Submitted report message-bar (with no remove actions). + "submitted-no-remove-action": { + id: "submitted-noremove", + dismissable: true, + }, + // Submitted report and remove addon message-bar. + "submitted-and-removed": { + id: "removed", + addonTypeSuffix: true, + dismissable: true, + }, + // The "aborted report" message bar is rendered as a generic informative one, + // because aborting a report is triggered by a user choice. + ERROR_ABORTED_SUBMIT: { + id: "aborted", + type: "generic", + dismissable: true, + }, + // Errors message bars. + ERROR_ADDON_NOTFOUND: { + id: "error", + type: "error", + dismissable: true, + }, + ERROR_CLIENT: { + id: "error", + type: "error", + dismissable: true, + }, + ERROR_NETWORK: { + id: "error", + actions: ["retry", "cancel"], + type: "error", + }, + ERROR_RECENT_SUBMIT: { + id: "error-recent-submit", + actions: ["retry", "cancel"], + type: "error", + }, + ERROR_SERVER: { + id: "error", + actions: ["retry", "cancel"], + type: "error", + }, + ERROR_UNKNOWN: { + id: "error", + actions: ["retry", "cancel"], + type: "error", + }, +}; + +async function openAbuseReport({ addonId, reportEntryPoint }) { + try { + const reportDialog = await AbuseReporter.openDialog( + addonId, + reportEntryPoint, + window.docShell.chromeEventHandler + ); + + // Warn the user before the about:addons tab while an + // abuse report dialog is still open, and close the + // report dialog if the user choose to close the related + // about:addons tab. + const beforeunloadListener = evt => evt.preventDefault(); + const unloadListener = () => reportDialog.close(); + const clearUnloadListeners = () => { + window.removeEventListener("beforeunload", beforeunloadListener); + window.removeEventListener("unload", unloadListener); + }; + window.addEventListener("beforeunload", beforeunloadListener); + window.addEventListener("unload", unloadListener); + + reportDialog.promiseReport + .then( + report => { + if (report) { + submitReport({ report }); + } + }, + err => { + Cu.reportError( + `Unexpected abuse report panel error: ${err} :: ${err.stack}` + ); + reportDialog.close(); + } + ) + .then(clearUnloadListeners); + } catch (err) { + // Log the detailed error to the browser console. + Cu.reportError(err); + document.dispatchEvent( + new CustomEvent("abuse-report:create-error", { + detail: { + addonId, + addon: err.addon, + errorType: err.errorType, + }, + }) + ); + } +} + +window.openAbuseReport = openAbuseReport; + +// Helper function used to create abuse report message bars in the +// HTML about:addons page. +function createReportMessageBar( + definitionId, + { addonId, addonName, addonType }, + { onclose, onaction } = {} +) { + const getMessageL10n = id => `abuse-report-messagebar-${id}`; + const getActionL10n = action => getMessageL10n(`action-${action}`); + + const barInfo = ABUSE_REPORT_MESSAGE_BARS[definitionId]; + if (!barInfo) { + throw new Error(`message-bar definition not found: ${definitionId}`); + } + const { id, dismissable, actions, type } = barInfo; + const messageEl = document.createElement("span"); + + // The message element includes an addon-name span (also filled by + // Fluent), which can be used to apply custom styles to the addon name + // included in the message bar (if needed). + const addonNameEl = document.createElement("span"); + addonNameEl.setAttribute("data-l10n-name", "addon-name"); + messageEl.append(addonNameEl); + + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based + // implementation is also removed. + const mappingAddonType = + addonType === "sitepermission-deprecated" ? "sitepermission" : addonType; + + document.l10n.setAttributes( + messageEl, + getMessageL10n(barInfo.addonTypeSuffix ? `${id}-${mappingAddonType}` : id), + { "addon-name": addonName || addonId } + ); + + const barActions = actions + ? actions.map(action => { + // Some of the message bars require a different per addonType + // Fluent id for their actions. + const actionId = barInfo.actionAddonTypeSuffix + ? `${action}-${mappingAddonType}` + : action; + const buttonEl = document.createElement("button"); + buttonEl.addEventListener("click", () => onaction && onaction(action)); + document.l10n.setAttributes(buttonEl, getActionL10n(actionId)); + return buttonEl; + }) + : []; + + const messagebar = document.createElement("message-bar"); + messagebar.setAttribute("type", type || "generic"); + if (dismissable) { + messagebar.setAttribute("dismissable", ""); + } + messagebar.append(messageEl, ...barActions); + messagebar.addEventListener("message-bar:close", onclose, { once: true }); + + document.getElementById("abuse-reports-messages").append(messagebar); + + document.dispatchEvent( + new CustomEvent("abuse-report:new-message-bar", { + detail: { definitionId, messagebar }, + }) + ); + return messagebar; +} + +async function submitReport({ report }) { + const { addon } = report; + const addonId = addon.id; + const addonName = addon.name; + const addonType = addon.type; + + // Ensure that the tab that originated the report dialog is selected + // when the user is submitting the report. + const { gBrowser } = window.windowRoot.ownerGlobal; + if (gBrowser && gBrowser.getTabForBrowser) { + let tab = gBrowser.getTabForBrowser(window.docShell.chromeEventHandler); + gBrowser.selectedTab = tab; + } + + // Create a message bar while we are still submitting the report. + const mbSubmitting = createReportMessageBar( + "submitting", + { addonId, addonName, addonType }, + { + onaction: action => { + if (action === "cancel") { + report.abort(); + mbSubmitting.remove(); + } + }, + } + ); + + try { + await report.submit(); + mbSubmitting.remove(); + + // Create a submitted message bar when the submission has been + // successful. + let barId; + if ( + !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL) && + !isPending(addon, "uninstall") + ) { + // Do not offer remove action if the addon can't be uninstalled. + barId = "submitted-no-remove-action"; + } else if (report.reportEntryPoint === "uninstall") { + // With reportEntryPoint "uninstall" a specific message bar + // is going to be used. + barId = "submitted-and-removed"; + } else { + // All the other reportEntryPoint ("menu" and "toolbar_context_menu") + // use the same kind of message bar. + barId = "submitted"; + } + + const mbInfo = createReportMessageBar( + barId, + { + addonId, + addonName, + addonType, + }, + { + onaction: action => { + mbInfo.remove(); + // action "keep" doesn't require any further action, + // just handle "remove". + if (action === "remove") { + report.addon.uninstall(true); + } + }, + } + ); + } catch (err) { + // Log the complete error in the console. + console.error("Error submitting abuse report for", addonId, err); + mbSubmitting.remove(); + // The report has a submission error, create a error message bar which + // may optionally allow the user to retry to submit the same report. + const barId = + err.errorType in ABUSE_REPORT_MESSAGE_BARS + ? err.errorType + : "ERROR_UNKNOWN"; + + const mbError = createReportMessageBar( + barId, + { + addonId, + addonName, + addonType, + }, + { + onaction: action => { + mbError.remove(); + switch (action) { + case "retry": + submitReport({ report }); + break; + case "cancel": + report.abort(); + break; + } + }, + } + ); + } +} + +document.addEventListener("abuse-report:submit", ({ detail }) => { + submitReport(detail); +}); + +document.addEventListener("abuse-report:create-error", ({ detail }) => { + const { addonId, addon, errorType } = detail; + const barId = + errorType in ABUSE_REPORT_MESSAGE_BARS ? errorType : "ERROR_UNKNOWN"; + createReportMessageBar(barId, { + addonId, + addonName: addon && addon.name, + addonType: addon && addon.type, + }); +}); diff --git a/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js new file mode 100644 index 0000000000..0a4f3f749c --- /dev/null +++ b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js @@ -0,0 +1,81 @@ +/* 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/. */ +/* import-globals-from aboutaddonsCommon.js */ + +"use strict"; + +class DragDropAddonInstaller extends HTMLElement { + connectedCallback() { + window.addEventListener("drop", this); + } + + disconnectedCallback() { + window.removeEventListener("drop", this); + } + + canInstallFromEvent(e) { + let types = e.dataTransfer.types; + return ( + types.includes("text/uri-list") || + types.includes("text/x-moz-url") || + types.includes("application/x-moz-file") + ); + } + + handleEvent(e) { + if (!XPINSTALL_ENABLED) { + // Nothing to do if we can't install add-ons. + return; + } + + if (e.type == "drop" && this.canInstallFromEvent(e)) { + this.onDrop(e); + } + } + + async onDrop(e) { + e.preventDefault(); + + let dataTransfer = e.dataTransfer; + let browser = getBrowserElement(); + let urls = []; + + // Convert every dropped item into a url, without this should be sync. + for (let i = 0; i < dataTransfer.mozItemCount; i++) { + let url = dataTransfer.mozGetDataAt("text/uri-list", i); + if (!url) { + url = dataTransfer.mozGetDataAt("text/x-moz-url", i); + } + if (url) { + url = url.split("\n")[0]; + } else { + let file = dataTransfer.mozGetDataAt("application/x-moz-file", i); + if (file) { + url = Services.io.newFileURI(file).spec; + } + } + + if (url) { + urls.push(url); + } + } + + // Install the add-ons, the await clears the event data so we do this last. + for (let url of urls) { + let install = await AddonManager.getInstallForURL(url, { + telemetryInfo: { + source: "about:addons", + method: "drag-and-drop", + }, + }); + + AddonManager.installAddonFromAOM( + browser, + document.documentURIObject, + install + ); + } + } +} +customElements.define("drag-drop-addon-installer", DragDropAddonInstaller); diff --git a/toolkit/mozapps/extensions/content/rating-star.css b/toolkit/mozapps/extensions/content/rating-star.css new file mode 100644 index 0000000000..b3a463b61a --- /dev/null +++ b/toolkit/mozapps/extensions/content/rating-star.css @@ -0,0 +1,41 @@ +/* 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/. */ + +:host { + --rating-star-size: 1em; + --rating-star-spacing: 0.3ch; + + display: inline-grid; + grid-template-columns: repeat(5, var(--rating-star-size)); + grid-column-gap: var(--rating-star-spacing); + align-content: center; +} + +:host([hidden]) { + display: none; +} + +.rating-star { + display: inline-block; + width: var(--rating-star-size); + height: var(--rating-star-size); + background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#empty"); + background-position: center; + background-repeat: no-repeat; + background-size: 100%; + + fill: currentColor; + -moz-context-properties: fill; +} + +.rating-star[fill="half"] { + background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#half"); +} +.rating-star[fill="full"] { + background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#full"); +} + +.rating-star[fill="half"]:dir(rtl) { + transform: scaleX(-1); +} diff --git a/toolkit/mozapps/extensions/content/shortcuts.css b/toolkit/mozapps/extensions/content/shortcuts.css new file mode 100644 index 0000000000..d96845f9f9 --- /dev/null +++ b/toolkit/mozapps/extensions/content/shortcuts.css @@ -0,0 +1,138 @@ +/* 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/. */ + +.shortcut.card { + margin-bottom: 16px; +} + +.shortcut.card:first-of-type { + margin-top: 8px; +} + +.shortcut.card:hover { + box-shadow: var(--card-shadow); +} + +.shortcut.card .card-heading-icon { + width: 24px; + height: 24px; + margin-inline-end: 16px; + -moz-context-properties: fill; + fill: currentColor; +} + +.card-heading { + display: flex; + font-weight: 600; +} + +.shortcuts-empty-label { + margin-top: 16px; +} + +.shortcut-row { + display: flex; + align-items: center; + margin-top: 10px; +} + +.shortcut.card:not([expanded]) > .shortcut-row[hide-before-expand] { + display: none; +} + +.shortcut-label { + flex-grow: 1; +} + +.shortcut-remove-button { + background-image: url("chrome://global/skin/icons/delete.svg"); + background-position: center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; + min-width: 32px; +} + +.shortcut-input[shortcut=""] + .shortcut-remove-button { + visibility: hidden; +} + +.expand-row { + display: flex; + justify-content: center; +} + +.expand-button { + margin: 8px 0 0; +} + +.expand-button[warning]:not(:focus) { + outline: 2px solid var(--yellow-60); + outline-offset: -1px; + box-shadow: 0 0 0 4px var(--yellow-60-a30); +} + +.shortcut-input { + /* Shortcuts should always be left-to-right. */ + direction: ltr; + text-align: match-parent; +} + +.extension-heading { + display: flex; +} + +.error-message { + --error-background: var(--red-60); + --warning-background: var(--yellow-50); + --warning-text-color: var(--yellow-90); + color: white; + display: flex; + flex-direction: column; + position: absolute; + visibility: hidden; +} + +.error-message-icon { + margin-inline-start: 10px; + width: 14px; + height: 8px; + fill: var(--error-background); + stroke: var(--error-background); + -moz-context-properties: fill, stroke; +} + +.error-message[type="warning"] > .error-message-icon { + fill: var(--warning-background); + stroke: var(--warning-background); +} + +.error-message-label { + background-color: var(--error-background); + border-radius: 2px; + margin: 0; + margin-inline-end: 8px; + max-width: 300px; + padding: 5px 10px; + word-wrap: break-word; +} + +.error-message[type="warning"] > .error-message-label { + background-color: var(--warning-background); + color: var(--warning-text-color); +} + +.error-message-arrow { + background-color: var(--error-background); + content: ""; + max-height: 8px; + width: 8px; + transform: translate(4px, -6px) rotate(45deg); + position: absolute; +} + +/* The margin between message bars. */ +message-bar-stack > * { + margin-bottom: 8px; +} diff --git a/toolkit/mozapps/extensions/content/shortcuts.js b/toolkit/mozapps/extensions/content/shortcuts.js new file mode 100644 index 0000000000..516ed21088 --- /dev/null +++ b/toolkit/mozapps/extensions/content/shortcuts.js @@ -0,0 +1,659 @@ +/* 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/. */ +/* import-globals-from aboutaddonsCommon.js */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionShortcutKeyMap: "resource://gre/modules/ExtensionShortcuts.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +{ + const FALLBACK_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + const COLLAPSE_OPTIONS = { + limit: 5, // We only want to show 5 when collapsed. + allowOver: 1, // Avoid collapsing to hide 1 row. + }; + + let templatesLoaded = false; + let shortcutKeyMap = new ExtensionShortcutKeyMap(); + const templates = {}; + + function loadTemplates() { + if (templatesLoaded) { + return; + } + templatesLoaded = true; + + templates.view = document.getElementById("shortcut-view"); + templates.card = document.getElementById("shortcut-card-template"); + templates.row = document.getElementById("shortcut-row-template"); + templates.noAddons = document.getElementById("shortcuts-no-addons"); + templates.expandRow = document.getElementById("expand-row-template"); + templates.noShortcutAddons = document.getElementById( + "shortcuts-no-commands-template" + ); + } + + function extensionForAddonId(id) { + let policy = WebExtensionPolicy.getByID(id); + return policy && policy.extension; + } + + let builtInNames = new Map([ + ["_execute_action", "shortcuts-browserAction2"], + ["_execute_browser_action", "shortcuts-browserAction2"], + ["_execute_page_action", "shortcuts-pageAction"], + ["_execute_sidebar_action", "shortcuts-sidebarAction"], + ]); + let getCommandDescriptionId = command => { + if (!command.description && builtInNames.has(command.name)) { + return builtInNames.get(command.name); + } + return null; + }; + + const _functionKeys = [ + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + ]; + const functionKeys = new Set(_functionKeys); + const validKeys = new Set([ + "Home", + "End", + "PageUp", + "PageDown", + "Insert", + "Delete", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ..._functionKeys, + "MediaNextTrack", + "MediaPlayPause", + "MediaPrevTrack", + "MediaStop", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "Up", + "Down", + "Left", + "Right", + "Comma", + "Period", + "Space", + ]); + + /** + * Trim a valid prefix from an event string. + * + * "Digit3" ~> "3" + * "ArrowUp" ~> "Up" + * "W" ~> "W" + * + * @param {string} string The input string. + * @returns {string} The trimmed string, or unchanged. + */ + function trimPrefix(string) { + return string.replace(/^(?:Digit|Numpad|Arrow)/, ""); + } + + const remapKeys = { + ",": "Comma", + ".": "Period", + " ": "Space", + }; + /** + * Map special keys to their shortcut name. + * + * "," ~> "Comma" + * " " ~> "Space" + * + * @param {string} string The input string. + * @returns {string} The remapped string, or unchanged. + */ + function remapKey(string) { + if (remapKeys.hasOwnProperty(string)) { + return remapKeys[string]; + } + return string; + } + + const keyOptions = [ + e => String.fromCharCode(e.which), // A letter? + e => e.code.toUpperCase(), // A letter. + e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9. + e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9. + e => remapKey(e.key), // Comma, Period, Space. + ]; + /** + * Map a DOM event to a shortcut string character. + * + * For example: + * + * "a" ~> "A" + * "Digit3" ~> "3" + * "," ~> "Comma" + * + * @param {object} event A KeyboardEvent. + * @returns {string} A string corresponding to the pressed key. + */ + function getStringForEvent(event) { + for (let option of keyOptions) { + let value = option(event); + if (validKeys.has(value)) { + return value; + } + } + + return ""; + } + + function getShortcutValue(shortcut) { + if (!shortcut) { + // Ensure the shortcut is a string, even if it is unset. + return null; + } + + let modifiers = shortcut.split("+"); + let key = modifiers.pop(); + + if (modifiers.length) { + let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers); + let displayString = + ShortcutUtils.getModifierString(modifiersAttribute) + key; + return displayString; + } + + if (functionKeys.has(key)) { + return key; + } + + return null; + } + + let error; + + function setError(...args) { + setInputMessage("error", ...args); + } + + function setWarning(...args) { + setInputMessage("warning", ...args); + } + + function setInputMessage(type, input, messageId, args) { + let { x, y, height, right } = input.getBoundingClientRect(); + error.style.top = `${y + window.scrollY + height - 5}px`; + + if (document.dir == "ltr") { + error.style.left = `${x}px`; + error.style.right = null; + } else { + error.style.right = `${document.documentElement.clientWidth - right}px`; + error.style.left = null; + } + + error.setAttribute("type", type); + document.l10n.setAttributes( + error.querySelector(".error-message-label"), + messageId, + args + ); + error.style.visibility = "visible"; + } + + function inputBlurred(e) { + error.style.visibility = "hidden"; + e.target.value = getShortcutValue(e.target.getAttribute("shortcut")); + } + + function onFocus(e) { + e.target.value = ""; + + let warning = e.target.getAttribute("warning"); + if (warning) { + setWarning(e.target, warning); + } + } + + function getShortcutForEvent(e) { + let modifierMap; + + if (AppConstants.platform == "macosx") { + modifierMap = { + MacCtrl: e.ctrlKey, + Alt: e.altKey, + Command: e.metaKey, + Shift: e.shiftKey, + }; + } else { + modifierMap = { + Ctrl: e.ctrlKey, + Alt: e.altKey, + Shift: e.shiftKey, + }; + } + + return Object.entries(modifierMap) + .filter(([key, isDown]) => isDown) + .map(([key]) => key) + .concat(getStringForEvent(e)) + .join("+"); + } + + async function buildDuplicateShortcutsMap(addons) { + await shortcutKeyMap.buildForAddonIds(addons.map(addon => addon.id)); + } + + function recordShortcut(shortcut, addonName, commandName) { + shortcutKeyMap.recordShortcut(shortcut, addonName, commandName); + } + + function removeShortcut(shortcut, addonName, commandName) { + shortcutKeyMap.removeShortcut(shortcut, addonName, commandName); + } + + function getAddonName(shortcut) { + return shortcutKeyMap.getFirstAddonName(shortcut); + } + + function setDuplicateWarnings() { + let warningHolder = document.getElementById("duplicate-warning-messages"); + clearWarnings(warningHolder); + for (let [shortcut, addons] of shortcutKeyMap) { + if (addons.size > 1) { + warningHolder.appendChild(createDuplicateWarningBar(shortcut)); + markDuplicates(shortcut); + } + } + } + + function clearWarnings(warningHolder) { + warningHolder.textContent = ""; + let inputs = document.querySelectorAll(".shortcut-input[warning]"); + for (let input of inputs) { + input.removeAttribute("warning"); + let row = input.closest(".shortcut-row"); + if (row.hasAttribute("hide-before-expand")) { + row + .closest(".card") + .querySelector(".expand-button") + .removeAttribute("warning"); + } + } + } + + function createDuplicateWarningBar(shortcut) { + let messagebar = document.createElement("message-bar"); + messagebar.setAttribute("type", "warning"); + + let message = document.createElement("span"); + document.l10n.setAttributes( + message, + "shortcuts-duplicate-warning-message", + { shortcut } + ); + + messagebar.append(message); + return messagebar; + } + + function markDuplicates(shortcut) { + let inputs = document.querySelectorAll( + `.shortcut-input[shortcut="${shortcut}"]` + ); + for (let input of inputs) { + input.setAttribute("warning", "shortcuts-duplicate"); + let row = input.closest(".shortcut-row"); + if (row.hasAttribute("hide-before-expand")) { + row + .closest(".card") + .querySelector(".expand-button") + .setAttribute("warning", "shortcuts-duplicate"); + } + } + } + + function onShortcutChange(e) { + let input = e.target; + + if (e.key == "Escape") { + input.blur(); + return; + } + + if (e.key == "Tab") { + return; + } + + if (!e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { + if (e.key == "Delete" || e.key == "Backspace") { + // Avoid triggering back-navigation. + e.preventDefault(); + assignShortcutToInput(input, ""); + return; + } + } + + e.preventDefault(); + e.stopPropagation(); + + // Some system actions aren't in the keyset, handle them independantly. + if (ShortcutUtils.getSystemActionForEvent(e)) { + e.defaultCancelled = true; + setError(input, "shortcuts-system"); + return; + } + + let shortcutString = getShortcutForEvent(e); + input.value = getShortcutValue(shortcutString); + + if (e.type == "keyup" || !shortcutString.length) { + return; + } + + let validation = ShortcutUtils.validate(shortcutString); + switch (validation) { + case ShortcutUtils.IS_VALID: + // Show an error if this is already a system shortcut. + let chromeWindow = window.windowRoot.ownerGlobal; + if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) { + setError(input, "shortcuts-system"); + break; + } + + // Check if shortcut is already assigned. + if (shortcutKeyMap.has(shortcutString)) { + setError(input, "shortcuts-exists", { + addon: getAddonName(shortcutString), + }); + } else { + // Update the shortcut if it isn't reserved or assigned. + assignShortcutToInput(input, shortcutString); + } + break; + case ShortcutUtils.MODIFIER_REQUIRED: + if (AppConstants.platform == "macosx") { + setError(input, "shortcuts-modifier-mac"); + } else { + setError(input, "shortcuts-modifier-other"); + } + break; + case ShortcutUtils.INVALID_COMBINATION: + setError(input, "shortcuts-invalid"); + break; + case ShortcutUtils.INVALID_KEY: + setError(input, "shortcuts-letter"); + break; + } + } + + function onShortcutRemove(e) { + let removeButton = e.target; + let input = removeButton.parentNode.querySelector(".shortcut-input"); + if (input.getAttribute("shortcut")) { + input.value = ""; + assignShortcutToInput(input, ""); + } + } + + function assignShortcutToInput(input, shortcutString) { + let addonId = input.closest(".card").getAttribute("addon-id"); + let extension = extensionForAddonId(addonId); + + let oldShortcut = input.getAttribute("shortcut"); + let addonName = input.closest(".card").getAttribute("addon-name"); + let commandName = input.getAttribute("name"); + + removeShortcut(oldShortcut, addonName, commandName); + recordShortcut(shortcutString, addonName, commandName); + + // This is async, but we're not awaiting it to keep the handler sync. + extension.shortcuts.updateCommand({ + name: commandName, + shortcut: shortcutString, + }); + input.setAttribute("shortcut", shortcutString); + input.blur(); + setDuplicateWarnings(); + } + + function renderNoShortcutAddons(addons) { + let fragment = document.importNode( + templates.noShortcutAddons.content, + true + ); + let list = fragment.querySelector(".shortcuts-no-commands-list"); + for (let addon of addons) { + let addonItem = document.createElement("li"); + addonItem.textContent = addon.name; + addonItem.setAttribute("addon-id", addon.id); + list.appendChild(addonItem); + } + + return fragment; + } + + async function renderAddons(addons) { + let frag = document.createDocumentFragment(); + let noShortcutAddons = []; + + await buildDuplicateShortcutsMap(addons); + + let isDuplicate = command => { + if (command.shortcut) { + let dupes = shortcutKeyMap.get(command.shortcut); + return dupes.size > 1; + } + return false; + }; + + for (let addon of addons) { + let extension = extensionForAddonId(addon.id); + + // Skip this extension if it isn't a webextension. + if (!extension) { + continue; + } + + if (extension.shortcuts) { + let card = document.importNode( + templates.card.content, + true + ).firstElementChild; + let icon = AddonManager.getPreferredIconURL(addon, 24, window); + card.setAttribute("addon-id", addon.id); + card.setAttribute("addon-name", addon.name); + card.querySelector(".addon-icon").src = icon || FALLBACK_ICON; + card.querySelector(".addon-name").textContent = addon.name; + + let commands = await extension.shortcuts.allCommands(); + + // Sort the commands so the ones with shortcuts are at the top. + commands.sort((a, b) => { + if (isDuplicate(a) && isDuplicate(b)) { + return 0; + } + if (isDuplicate(a)) { + return -1; + } + if (isDuplicate(b)) { + return 1; + } + // Boolean compare the shortcuts to see if they're both set or unset. + if (!a.shortcut == !b.shortcut) { + return 0; + } + if (a.shortcut) { + return -1; + } + return 1; + }); + + let { limit, allowOver } = COLLAPSE_OPTIONS; + let willHideCommands = commands.length > limit + allowOver; + let firstHiddenInput; + + for (let i = 0; i < commands.length; i++) { + let command = commands[i]; + + let row = document.importNode( + templates.row.content, + true + ).firstElementChild; + + if (willHideCommands && i >= limit) { + row.setAttribute("hide-before-expand", "true"); + } + + let label = row.querySelector(".shortcut-label"); + let descriptionId = getCommandDescriptionId(command); + if (descriptionId) { + document.l10n.setAttributes(label, descriptionId); + } else { + label.textContent = command.description || command.name; + } + let input = row.querySelector(".shortcut-input"); + input.value = getShortcutValue(command.shortcut); + input.setAttribute("name", command.name); + input.setAttribute("shortcut", command.shortcut); + input.addEventListener("keydown", onShortcutChange); + input.addEventListener("keyup", onShortcutChange); + input.addEventListener("blur", inputBlurred); + input.addEventListener("focus", onFocus); + + let removeButton = row.querySelector(".shortcut-remove-button"); + removeButton.addEventListener("click", onShortcutRemove); + + if (willHideCommands && i == limit) { + firstHiddenInput = input; + } + + card.appendChild(row); + } + + // Add an expand button, if needed. + if (willHideCommands) { + let row = document.importNode(templates.expandRow.content, true); + let button = row.querySelector(".expand-button"); + let numberToShow = commands.length - limit; + let setLabel = type => { + document.l10n.setAttributes( + button, + `shortcuts-card-${type}-button`, + { + numberToShow, + } + ); + }; + + setLabel("expand"); + button.addEventListener("click", event => { + let expanded = card.hasAttribute("expanded"); + if (expanded) { + card.removeAttribute("expanded"); + setLabel("expand"); + } else { + card.setAttribute("expanded", "true"); + setLabel("collapse"); + // If this as a keyboard event then focus the next input. + if (event.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + firstHiddenInput.focus(); + } + } + }); + card.appendChild(row); + } + + frag.appendChild(card); + } else if (!addon.hidden) { + noShortcutAddons.push({ id: addon.id, name: addon.name }); + } + } + + if (noShortcutAddons.length) { + frag.appendChild(renderNoShortcutAddons(noShortcutAddons)); + } + + return frag; + } + + class AddonShortcuts extends HTMLElement { + connectedCallback() { + setDuplicateWarnings(); + } + + disconnectedCallback() { + error = null; + } + + async render() { + loadTemplates(); + let allAddons = await AddonManager.getAddonsByTypes(["extension"]); + let addons = allAddons + .filter(addon => addon.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); + let frag; + + if (addons.length) { + frag = await renderAddons(addons); + } else { + frag = document.importNode(templates.noAddons.content, true); + } + + this.textContent = ""; + this.appendChild(document.importNode(templates.view.content, true)); + error = this.querySelector(".error-message"); + this.appendChild(frag); + } + } + customElements.define("addon-shortcuts", AddonShortcuts); +} diff --git a/toolkit/mozapps/extensions/content/view-controller.js b/toolkit/mozapps/extensions/content/view-controller.js new file mode 100644 index 0000000000..7e9516024c --- /dev/null +++ b/toolkit/mozapps/extensions/content/view-controller.js @@ -0,0 +1,201 @@ +/* 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/. */ + +"use strict"; + +/* import-globals-from /toolkit/content/customElements.js */ +/* import-globals-from aboutaddonsCommon.js */ +/* exported loadView */ + +// Used by external callers to load a specific view into the manager +function loadView(viewId) { + if (!gViewController.readyForLoadView) { + throw new Error("loadView called before about:addons is initialized"); + } + gViewController.loadView(viewId); +} + +/** + * Helper for saving and restoring the scroll offsets when a previously loaded + * view is accessed again. + */ +var ScrollOffsets = { + _key: null, + _offsets: new Map(), + canRestore: true, + + setView(historyEntryId) { + this._key = historyEntryId; + this.canRestore = true; + }, + + getPosition() { + if (!this.canRestore) { + return { top: 0, left: 0 }; + } + let { scrollTop: top, scrollLeft: left } = document.documentElement; + return { top, left }; + }, + + save() { + if (this._key) { + this._offsets.set(this._key, this.getPosition()); + } + }, + + restore() { + let { top = 0, left = 0 } = this._offsets.get(this._key) || {}; + window.scrollTo({ top, left, behavior: "auto" }); + }, +}; + +var gViewController = { + currentViewId: null, + readyForLoadView: false, + get defaultViewId() { + if (!isDiscoverEnabled()) { + return "addons://list/extension"; + } + return "addons://discover/"; + }, + isLoading: true, + // All historyEntryId values must be unique within one session, because the + // IDs are used to map history entries to page state. It is not possible to + // see whether a historyEntryId was used in history entries before this page + // was loaded, so start counting from a random value to avoid collisions. + // This is used for scroll offsets in aboutaddons.js + nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32), + views: {}, + + initialize(container) { + this.container = container; + + window.addEventListener("popstate", this); + window.addEventListener("unload", this, { once: true }); + Services.obs.addObserver(this, "EM-ping"); + }, + + handleEvent(e) { + if (e.type == "popstate") { + this.renderState(e.state); + return; + } + + if (e.type == "unload") { + Services.obs.removeObserver(this, "EM-ping"); + // eslint-disable-next-line no-useless-return + return; + } + }, + + observe(subject, topic, data) { + if (topic == "EM-ping") { + this.readyForLoadView = true; + Services.obs.notifyObservers(window, "EM-pong"); + } + }, + + notifyEMLoaded() { + this.readyForLoadView = true; + Services.obs.notifyObservers(window, "EM-loaded"); + }, + + notifyEMUpdateCheckFinished() { + // Notify the observer about a completed update check (currently only used in tests). + Services.obs.notifyObservers(null, "EM-update-check-finished"); + }, + + defineView(viewName, renderFunction) { + if (this.views[viewName]) { + throw new Error( + `about:addons view ${viewName} should not be defined twice` + ); + } + this.views[viewName] = renderFunction; + }, + + parseViewId(viewId) { + const matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/; + const [, viewType, viewParam] = viewId.match(matchRegex) || []; + return { type: viewType, param: decodeURIComponent(viewParam) }; + }, + + loadView(viewId, replace = false) { + viewId = viewId.startsWith("addons://") ? viewId : `addons://${viewId}`; + if (viewId == this.currentViewId) { + return Promise.resolve(); + } + + // Always rewrite history state instead of pushing incorrect state for initial load. + replace = replace || !this.currentViewId; + + const state = { + view: viewId, + previousView: replace ? null : this.currentViewId, + historyEntryId: ++this.nextHistoryEntryId, + }; + if (replace) { + history.replaceState(state, ""); + } else { + history.pushState(state, ""); + } + return this.renderState(state); + }, + + async renderState(state) { + let { param, type } = this.parseViewId(state.view); + + if (!type || this.views[type] == null) { + console.warn(`No view for ${type} ${param}, switching to default`); + this.resetState(); + return; + } + + this.currentViewId = state.view; + this.isLoading = true; + + // Perform tasks before view load + document.dispatchEvent( + new CustomEvent("view-selected", { + detail: { id: state.view, param, type }, + }) + ); + + // Render the fragment + this.container.setAttribute("current-view", type); + let fragment = await this.views[type](param); + + // Clear and append the fragment + if (fragment) { + ScrollOffsets.save(); + ScrollOffsets.setView(state.historyEntryId); + + this.container.textContent = ""; + this.container.append(fragment); + + // Most content has been rendered at this point. The only exception are + // recommendations in the discovery pane and extension/theme list, because + // they rely on remote data. If loaded before, then these may be rendered + // within one tick, so wait a frame before restoring scroll offsets. + await new Promise(resolve => { + window.requestAnimationFrame(() => { + ScrollOffsets.restore(); + resolve(); + }); + }); + } else { + // Reset to default view if no given content + this.resetState(); + return; + } + + this.isLoading = false; + + document.dispatchEvent(new CustomEvent("view-loaded")); + }, + + resetState() { + return this.loadView(this.defaultViewId, true); + }, +}; |