diff options
Diffstat (limited to 'toolkit/mozapps/extensions/content')
26 files changed, 10183 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..c7dbf90af6 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.css @@ -0,0 +1,739 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --addon-icon-size: 32px; + --main-margin-start: 28px; + --section-width: 664px; + --sidebar-width: var(--in-content-sidebar-width); + --z-index-sticky-container: 1; + --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; +} + +.category[badge-count]::after { + display: inline-block; + min-width: 20px; + background-color: var(--blue-50); + color: #fff; + 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://global/skin/plugins/plugin.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"); +} + +.header-name { + user-select: initial; +} + +.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; +} + +.section { + margin-bottom: 32px; +} + +/* Add-on cards */ + +.addon.card { + margin-bottom: 16px; +} + +addon-card:not([expanded]) > .addon.card[active="false"] { + opacity: 0.6; + transition: opacity 150ms, box-shadow 150ms; +} + +addon-card:not([expanded])[panelopen] > .addon.card[active="false"], +addon-card:not([expanded]) > .addon.card[active="false"]:focus-within, +addon-card:not([expanded]) > .addon.card[active="false"]:hover { + opacity: 1; +} + +.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-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://browser/skin/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; + height: auto; + font-size: 13px; + min-height: auto; + height: 24px; + margin: 0; +} + +.addon-description { + font-size: 14px; + line-height: 20px; + color: var(--in-content-deemphasized-text); + 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; +} + +.disco-cta-button[action="install-addon"]::before { + content: "+"; + padding-inline-end: 4px; +} + +.discopane-notice { + margin: 24px 0; +} + +.discopane-notice-content { + align-items: center; + display: flex; + width: 100%; +} + +.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(--in-content-deemphasized-text); +} + +.addon-detail-description { + margin: 16px 0; +} + +.addon-detail-contribute { + display: flex; + padding: var(--card-padding); + border: 1px solid var(--in-content-box-border-color); + border-radius: var(--panel-border-radius); + 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-row { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--in-content-box-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(--in-content-deemphasized-text); + 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-rating { + display: flex; +} + +.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: var(--blue-50); +} + +panel-item { + -moz-context-properties: fill; + fill: currentColor; +} + +panel-item[action="remove"] { + --icon: url("chrome://global/skin/icons/delete.svg"); +} + +panel-item[action="install-update"] { + --icon: url("chrome://global/skin/icons/update-icon.svg"); +} + +panel-item[action="report"] { + --icon: url(chrome://global/skin/icons/warning.svg); +} + +panel-item-separator { + display: block; + height: 1px; + background: var(--in-content-box-border-color); + padding: 0; + margin: 6px 0; +} + +.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; +} + +/* justify the permission toggle */ +li.permission-info > label { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* 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-box-border-color); + border-top: 1px solid var(--in-content-box-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; + color: var(--in-content-text-color); +} + +button.tab-button:hover { + background-color: var(--in-content-button-background); + border-top-color: var(--in-content-box-border-color); +} + +button.tab-button:hover:active { + background-color: var(--in-content-button-background-hover); +} + +button.tab-button[selected] { + border-top-color: var(--in-content-border-highlight); + color: var(--in-content-category-text-selected) !important; +} + +button.tab-button:-moz-focusring { + outline-offset: -2px; + -moz-outline-radius: 0; +} + +.tab-group[last-input-type="mouse"] > button.tab-button:-moz-focusring { + outline: none; + box-shadow: none; +} + +panel-list { + font-size: 13px; +} + +@media (max-width: 830px) { + .category[badge-count]::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + min-width: auto; + padding: 0; + } +} diff --git a/toolkit/mozapps/extensions/content/aboutaddons.html b/toolkit/mozapps/extensions/content/aboutaddons.html new file mode 100644 index 0000000000..5d9ebd3e50 --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.html @@ -0,0 +1,427 @@ +<!-- 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'"> + <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://global/skin/in-content/toggle-button.css"> + <link rel="stylesheet" href="chrome://mozapps/content/extensions/aboutaddons.css"> + <link rel="stylesheet" href="chrome://mozapps/content/extensions/shortcuts.css"> + + <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/named-deck.js"></script> + <script defer src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"></script> + <script defer src="chrome://mozapps/content/extensions/message-bar.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/aboutaddons.js"></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://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> + + <!-- Include helpers for the inline options browser select and context menus. --> + <content-select-dropdown></content-select-dropdown> + <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 data-l10n-id="search-header-shortcut" data-l10n-attrs="key"></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> + <panel-item-separator></panel-item-separator> + <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> + <panel-item-separator></panel-item-separator> + <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> + <panel-item-separator></panel-item-separator> + <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> + <panel-item-separator></panel-item-separator> + <panel-item data-l10n-id="report-addon-button" action="report"></panel-item> + <panel-item-separator></panel-item-separator> + <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="ask-to-activate-button" action="ask-to-activate"></panel-item> + <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> + <panel-item-separator></panel-item-separator> + <panel-item data-l10n-id="preferences-addon-button" action="preferences"></panel-item> + <panel-item-separator></panel-item-separator> + <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="support-link" support-page="extension-permissions" data-l10n-id="addon-permissions-learnmore"></a> + </div> + </template> + + <template name="card"> + <div class="card addon"> + <img class="card-heading-image"> + <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="support-link" + support-page="add-on-badges" + utmcontent="promoted-addon-badge" + data-l10n-id="addon-badge-recommended2" + hidden> + </a> + <a class="addon-badge addon-badge-line" + is="support-link" + support-page="add-on-badges" + utmcontent="promoted-addon-badge" + data-l10n-id="addon-badge-line3" + hidden> + </a> + <a class="addon-badge addon-badge-verified" + is="support-link" + support-page="add-on-badges" + utmcontent="promoted-addon-badge" + data-l10n-id="addon-badge-verified2" + hidden> + </a> + <a class="addon-badge addon-badge-private-browsing-allowed" + is="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> + <input type="checkbox" class="toggle-button extension-enable-button" action="toggle-disabled" data-l10n-id="extension-enable-addon-button-label" hidden> + <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 primary" 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"></button> + <button is="named-deck-button" deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button" class="tab-button"></button> + <button is="named-deck-button" deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button" class="tab-button"></button> + <button is="named-deck-button" deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button" class="tab-button"></button> + </button-group> + <named-deck id="details-deck"> + <section name="details"> + <div class="addon-detail-description"></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-row addon-detail-row-updates"> + <label data-l10n-id="addon-detail-updates-label"></label> + <div> + <button class="button-link" data-l10n-id="addon-detail-update-check-label" action="update-check" hidden></button> + <label> + <input type="radio" name="autoupdate" value="1" data-telemetry-value="default"> + <span data-l10n-id="addon-detail-updates-radio-default"></span> + </label> + <label> + <input type="radio" name="autoupdate" value="2" data-telemetry-value="enabled"> + <span data-l10n-id="addon-detail-updates-radio-on"></span> + </label> + <label> + <input type="radio" name="autoupdate" value="0" data-telemetry-value=""> + <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> + <label> + <input type="radio" name="private-browsing" value="1" data-telemetry-value="on"> + <span data-l10n-id="addon-detail-private-browsing-allow"></span> + </label> + <label> + <input type="radio" name="private-browsing" value="0" data-telemetry-value="off"> + <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="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="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="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" data-telemetry-name="author"></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" data-telemetry-name="homepage" 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" data-telemetry-name="rating"></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="panel-list"> + <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-list.css"> + <div class="arrow top" role="presentation"></div> + <div class="list" role="presentation"> + <slot></slot> + </div> + <div class="arrow bottom" role="presentation"></div> + </template> + + <template name="taar-notice"> + <message-bar class="discopane-notice" dismissable> + <div class="discopane-notice-content"> + <span data-l10n-id="discopane-notice-recommendations"></span> + <button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button> + </div> + </message-bar> + </template> + + <template name="recommended-footer"> + <div class="amo-link-container view-footer-item"> + <button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button> + </div> + <div class="view-footer-item"> + <a class="privacy-policy-link" data-l10n-id="privacy-policy" target="_blank"></a> + </div> + </template> + + <template name="discopane"> + <header> + <p> + <span data-l10n-id="discopane-intro"> + <a + class="discopane-intro-learn-more-link" + is="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-addons"></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..0ad41fd91c --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddons.js @@ -0,0 +1,4811 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint max-len: ["error", 80] */ +/* exported hide, initialize, show */ +/* import-globals-from aboutaddonsCommon.js */ +/* import-globals-from abuse-reports.js */ +/* global MozXULElement, MessageBarStackElement, windowRoot */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm", + AMTelemetry: "resource://gre/modules/AddonManager.jsm", + ClientID: "resource://gre/modules/ClientID.jsm", + DeferredTask: "resource://gre/modules/DeferredTask.jsm", + E10SUtils: "resource://gre/modules/E10SUtils.jsm", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "browserBundle", () => { + return Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +}); +XPCOMUtils.defineLazyGetter(this, "brandBundle", () => { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); +XPCOMUtils.defineLazyGetter(this, "extBundle", function() { + return Services.strings.createBundle( + "chrome://mozapps/locale/extensions/extensions.properties" + ); +}); +XPCOMUtils.defineLazyGetter(this, "extensionStylesheets", () => { + const { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" + ); + return ExtensionParent.extensionStylesheets; +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "allowPrivateBrowsingByDefault", + "extensions.allowPrivateBrowsingByDefault", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "SUPPORT_URL", + "app.support.baseURL", + "", + null, + val => Services.urlFormatter.formatURL(val) +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "XPINSTALL_ENABLED", + "xpinstall.enabled", + true +); + +const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds) + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "ABUSE_REPORT_ENABLED", + "extensions.abuseReport.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "LIST_RECOMMENDATIONS_ENABLED", + "extensions.htmlaboutaddons.recommendations.enabled", + false +); + +const PLUGIN_ICON_URL = "chrome://global/skin/plugins/plugin.svg"; +const EXTENSION_ICON_URL = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; +const BUILTIN_THEME_PREVIEWS = new Map([ + [ + "default-theme@mozilla.org", + "chrome://mozapps/content/extensions/default-theme.svg", + ], + [ + "firefox-compact-light@mozilla.org", + "chrome://mozapps/content/extensions/firefox-compact-light.svg", + ], + [ + "firefox-compact-dark@mozilla.org", + "chrome://mozapps/content/extensions/firefox-compact-dark.svg", + ], + [ + "firefox-alpenglow@mozilla.org", + "chrome://mozapps/content/extensions/firefox-alpenglow.svg", + ], +]); + +const PERMISSION_MASKS = { + "ask-to-activate": AddonManager.PERM_CAN_ASK_TO_ACTIVATE, + enable: AddonManager.PERM_CAN_ENABLE, + "always-activate": AddonManager.PERM_CAN_ENABLE, + disable: AddonManager.PERM_CAN_DISABLE, + "never-activate": AddonManager.PERM_CAN_DISABLE, + uninstall: AddonManager.PERM_CAN_UNINSTALL, + upgrade: AddonManager.PERM_CAN_UPGRADE, + "change-privatebrowsing": AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS, +}; + +const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url"; +const PREF_THEME_RECOMMENDATION_URL = + "extensions.recommendations.themeRecommendationUrl"; +const PREF_RECOMMENDATION_HIDE_NOTICE = "extensions.recommendations.hideNotice"; +const PREF_PRIVACY_POLICY_URL = "extensions.recommendations.privacyPolicyUrl"; +const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled"; +const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled"; +const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed"; +const PRIVATE_BROWSING_PERMS = { + permissions: [PRIVATE_BROWSING_PERM_NAME], + origins: [], +}; + +function shouldSkipAnimations() { + return ( + document.body.hasAttribute("skip-animations") || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function callListeners(name, args, listeners) { + for (let listener of listeners) { + try { + if (name in listener) { + listener[name](...args); + } + } catch (e) { + Cu.reportError(e); + } + } +} + +function getUpdateInstall(addon) { + return ( + // Install object for a pending update. + addon.updateInstall || + // Install object for a postponed upgrade (only for extensions, + // because is the only addon type that can postpone their own + // updates). + (addon.type === "extension" && + addon.pendingUpgrade && + addon.pendingUpgrade.install) + ); +} + +function isManualUpdate(install) { + let isManual = + install.existingAddon && + !AddonManager.shouldAutoUpdate(install.existingAddon); + let isExtension = + install.existingAddon && install.existingAddon.type == "extension"; + return ( + (isManual && isInState(install, "available")) || + (isExtension && isInState(install, "postponed")) + ); +} + +const AddonManagerListenerHandler = { + listeners: new Set(), + + addListener(listener) { + this.listeners.add(listener); + }, + + removeListener(listener) { + this.listeners.delete(listener); + }, + + delegateEvent(name, args) { + callListeners(name, args, this.listeners); + }, + + startup() { + this._listener = new Proxy( + {}, + { + has: () => true, + get: (_, name) => (...args) => this.delegateEvent(name, args), + } + ); + AddonManager.addAddonListener(this._listener); + AddonManager.addInstallListener(this._listener); + AddonManager.addManagerListener(this._listener); + this._permissionHandler = (type, data) => { + if (type == "change-permissions") { + this.delegateEvent("onChangePermissions", [data]); + } + }; + ExtensionPermissions.addListener(this._permissionHandler); + }, + + shutdown() { + AddonManager.removeAddonListener(this._listener); + AddonManager.removeInstallListener(this._listener); + AddonManager.removeManagerListener(this._listener); + ExtensionPermissions.removeListener(this._permissionHandler); + }, +}; + +/** + * This object wires the AddonManager event listeners into addon-card and + * addon-details elements rather than needing to add/remove listeners all the + * time as the view changes. + */ +const AddonCardListenerHandler = new Proxy( + {}, + { + has: () => true, + get(_, name) { + return (...args) => { + let elements = []; + let addonId; + + // We expect args[0] to be of type: + // - AddonInstall, on AddonManager install events + // - AddonWrapper, on AddonManager addon events + // - undefined, on AddonManager manage events + if (args[0]) { + addonId = + args[0].addon?.id || + args[0].existingAddon?.id || + args[0].extensionId || + args[0].id; + } + + if (addonId) { + let cardSelector = `addon-card[addon-id="${addonId}"]`; + elements = document.querySelectorAll( + `${cardSelector}, ${cardSelector} addon-details` + ); + } else if (name == "onUpdateModeChanged") { + elements = document.querySelectorAll("addon-card"); + } + + callListeners(name, args, elements); + }; + }, + } +); +AddonManagerListenerHandler.addListener(AddonCardListenerHandler); + +function isAbuseReportSupported(addon) { + return ( + ABUSE_REPORT_ENABLED && + ["extension", "theme"].includes(addon.type) && + !(addon.isBuiltin || addon.isSystem) + ); +} + +async function isAllowedInPrivateBrowsing(addon) { + // Use the Promise directly so this function stays sync for the other case. + let perms = await ExtensionPermissions.get(addon.id); + return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME); +} + +function hasPermission(addon, permission) { + return !!(addon.permissions & PERMISSION_MASKS[permission]); +} + +function isInState(install, state) { + return install.state == AddonManager["STATE_" + state.toUpperCase()]; +} + +async function getAddonMessageInfo(addon) { + const { name } = addon; + const appName = brandBundle.GetStringFromName("brandShortName"); + const { + STATE_BLOCKED, + STATE_OUTDATED, + STATE_SOFTBLOCKED, + STATE_VULNERABLE_UPDATE_AVAILABLE, + STATE_VULNERABLE_NO_UPDATE, + } = Ci.nsIBlocklistService; + + const formatString = (name, args) => + extBundle.formatStringFromName( + `details.notification.${name}`, + args, + args.length + ); + const getString = name => + extBundle.GetStringFromName(`details.notification.${name}`); + + if (addon.blocklistState === STATE_BLOCKED) { + return { + linkText: getString("blocked.link"), + linkUrl: await addon.getBlocklistURL(), + message: formatString("blocked", [name]), + type: "error", + }; + } else if (isDisabledUnsigned(addon)) { + return { + linkText: getString("unsigned.link"), + linkUrl: SUPPORT_URL + "unsigned-addons", + message: formatString("unsignedAndDisabled", [name, appName]), + type: "error", + }; + } else if ( + !addon.isCompatible && + (AddonManager.checkCompatibility || + addon.blocklistState !== STATE_SOFTBLOCKED) + ) { + return { + message: formatString("incompatible", [ + name, + appName, + Services.appinfo.version, + ]), + type: "warning", + }; + } else if (!isCorrectlySigned(addon)) { + return { + linkText: getString("unsigned.link"), + linkUrl: SUPPORT_URL + "unsigned-addons", + message: formatString("unsigned", [name, appName]), + type: "warning", + }; + } else if (addon.blocklistState === STATE_SOFTBLOCKED) { + return { + linkText: getString("softblocked.link"), + linkUrl: await addon.getBlocklistURL(), + message: formatString("softblocked", [name]), + type: "warning", + }; + } else if (addon.blocklistState === STATE_OUTDATED) { + return { + linkText: getString("outdated.link"), + linkUrl: await addon.getBlocklistURL(), + message: formatString("outdated", [name]), + type: "warning", + }; + } else if (addon.blocklistState === STATE_VULNERABLE_UPDATE_AVAILABLE) { + return { + linkText: getString("vulnerableUpdatable.link"), + linkUrl: await addon.getBlocklistURL(), + message: formatString("vulnerableUpdatable", [name]), + type: "error", + }; + } else if (addon.blocklistState === STATE_VULNERABLE_NO_UPDATE) { + return { + linkText: getString("vulnerableNoUpdate.link"), + linkUrl: await addon.getBlocklistURL(), + message: formatString("vulnerableNoUpdate", [name]), + type: "error", + }; + } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) { + return { + message: formatString("gmpPending", [name]), + type: "warning", + }; + } + return {}; +} + +function checkForUpdate(addon) { + return new Promise(resolve => { + let listener = { + onUpdateAvailable(addon, install) { + if (AddonManager.shouldAutoUpdate(addon)) { + // Make sure that an update handler is attached to all the install + // objects when updated xpis are going to be installed automatically. + attachUpdateHandler(install); + + let failed = () => { + detachUpdateHandler(install); + install.removeListener(updateListener); + resolve({ installed: false, pending: false, found: true }); + }; + let updateListener = { + onDownloadFailed: failed, + onInstallCancelled: failed, + onInstallFailed: failed, + onInstallEnded: (...args) => { + detachUpdateHandler(install); + install.removeListener(updateListener); + resolve({ installed: true, pending: false, found: true }); + }, + onInstallPostponed: (...args) => { + detachUpdateHandler(install); + install.removeListener(updateListener); + resolve({ installed: false, pending: true, found: true }); + }, + }; + install.addListener(updateListener); + install.install(); + } else { + resolve({ installed: false, pending: true, found: true }); + } + }, + onNoUpdateAvailable() { + resolve({ found: false }); + }, + }; + addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED); + }); +} + +async function checkForUpdates() { + let addons = await AddonManager.getAddonsByTypes(null); + addons = addons.filter(addon => hasPermission(addon, "upgrade")); + let updates = await Promise.all(addons.map(addon => checkForUpdate(addon))); + Services.obs.notifyObservers(null, "EM-update-check-finished"); + return updates.reduce( + (counts, update) => ({ + installed: counts.installed + (update.installed ? 1 : 0), + pending: counts.pending + (update.pending ? 1 : 0), + found: counts.found + (update.found ? 1 : 0), + }), + { installed: 0, pending: 0, found: 0 } + ); +} + +// Don't change how we handle this while the page is open. +const INLINE_OPTIONS_ENABLED = Services.prefs.getBoolPref( + "extensions.htmlaboutaddons.inline-options.enabled" +); +const OPTIONS_TYPE_MAP = { + [AddonManager.OPTIONS_TYPE_TAB]: "tab", + [AddonManager.OPTIONS_TYPE_INLINE_BROWSER]: INLINE_OPTIONS_ENABLED + ? "inline" + : "tab", +}; + +// Check if an add-on has the provided options type, accounting for the pref +// to disable inline options. +function getOptionsType(addon, type) { + return OPTIONS_TYPE_MAP[addon.optionsType]; +} + +// Check whether the options page can be loaded in the current browser window. +async function isAddonOptionsUIAllowed(addon) { + if (addon.type !== "extension" || !getOptionsType(addon)) { + // Themes never have options pages. + // Some plugins have preference pages, and they can always be shown. + // Extensions do not need to be checked if they do not have options pages. + return true; + } + if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) { + return true; + } + if (addon.incognito === "not_allowed") { + return false; + } + // The current page is in a private browsing window, and the add-on does not + // have the permission to access private browsing windows. Block access. + return ( + allowPrivateBrowsingByDefault || + // Note: This function is async because isAllowedInPrivateBrowsing is async. + isAllowedInPrivateBrowsing(addon) + ); +} + +/** + * This function is set in initialize() by the parent about:addons window. It + * is a helper for gViewController.loadView(). + * + * @param {string} type The view type to load. + * @param {string} param The (optional) param for the view. + */ +let loadViewFn; + +/** + * This function is set in initialize() by the parent about:addons window. It + * is a helper for gViewController.replaceView(defaultViewId). This should be + * used to reset the view if we try to load an invalid view. + */ +let replaceWithDefaultViewFn; + +let _templates = {}; + +/** + * Import a template from the main document. + */ +function importTemplate(name) { + if (!_templates.hasOwnProperty(name)) { + _templates[name] = document.querySelector(`template[name="${name}"]`); + } + let template = _templates[name]; + if (template) { + return document.importNode(template.content, true); + } + throw new Error(`Unknown template: ${name}`); +} + +function nl2br(text) { + let frag = document.createDocumentFragment(); + let hasAppended = false; + for (let part of text.split("\n")) { + if (hasAppended) { + frag.appendChild(document.createElement("br")); + } + frag.appendChild(new Text(part)); + hasAppended = true; + } + return frag; +} + +/** + * Select the screeenshot to display above an add-on card. + * + * @param {AddonWrapper|DiscoAddonWrapper} addon + * @returns {string|null} + * The URL of the best fitting screenshot, if any. + */ +function getScreenshotUrlForAddon(addon) { + if (BUILTIN_THEME_PREVIEWS.has(addon.id)) { + return BUILTIN_THEME_PREVIEWS.get(addon.id); + } + + let { screenshots } = addon; + if (!screenshots || !screenshots.length) { + return null; + } + + // The image size is defined at .card-heading-image in aboutaddons.css, and + // is based on the aspect ratio for a 680x92 image. Use the image if possible, + // and otherwise fall back to the first image and hope for the best. + let screenshot = screenshots.find(s => s.width === 680 && s.height === 92); + if (!screenshot) { + console.warn(`Did not find screenshot with desired size for ${addon.id}.`); + screenshot = screenshots[0]; + } + return screenshot.url; +} + +/** + * Adds UTM parameters to a given URL, if it is an AMO URL. + * + * @param {string} contentAttribute + * Identifies the part of the UI with which the link is associated. + * @param {string} url + * @returns {string} + * The url with UTM parameters if it is an AMO URL. + * Otherwise the url in unmodified form. + */ +function formatUTMParams(contentAttribute, url) { + let parsedUrl = new URL(url); + let domain = `.${parsedUrl.hostname}`; + if ( + !domain.endsWith(".mozilla.org") && + // For testing: addons-dev.allizom.org and addons.allizom.org + !domain.endsWith(".allizom.org") + ) { + return url; + } + + parsedUrl.searchParams.set("utm_source", "firefox-browser"); + parsedUrl.searchParams.set("utm_medium", "firefox-browser"); + parsedUrl.searchParams.set("utm_content", contentAttribute); + return parsedUrl.href; +} + +// A wrapper around an item from the "results" array from AMO's discovery API. +// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html +class DiscoAddonWrapper { + /** + * @param {object} details + * An item in the "results" array from AMO's discovery API. + */ + constructor(details) { + // Reuse AddonRepository._parseAddon to have the AMO response parsing logic + // in one place. + let repositoryAddon = AddonRepository._parseAddon(details.addon); + + // Note: Any property used by RecommendedAddonCard should appear here. + // The property names and values should have the same semantics as + // AddonWrapper, to ease the reuse of helper functions in this file. + this.id = repositoryAddon.id; + this.type = repositoryAddon.type; + this.name = repositoryAddon.name; + this.screenshots = repositoryAddon.screenshots; + this.sourceURI = repositoryAddon.sourceURI; + this.creator = repositoryAddon.creator; + this.averageRating = repositoryAddon.averageRating; + + this.dailyUsers = details.addon.average_daily_users; + + this.editorialDescription = details.description_text; + this.iconURL = details.addon.icon_url; + this.amoListingUrl = details.addon.url; + } +} + +/** + * A helper to retrieve the list of recommended add-ons via AMO's discovery API. + */ +var DiscoveryAPI = { + // Map<boolean, Promise> Promises from fetching the API results with or + // without a client ID. The `false` (no client ID) case could actually + // have been fetched with a client ID. See getResults() for more info. + _resultPromises: new Map(), + + /** + * Fetch the list of recommended add-ons. The results are cached. + * + * Pending requests are coalesced, so there is only one request at any given + * time. If a request fails, the pending promises are rejected, but a new + * call will result in a new request. A succesful response is cached for the + * lifetime of the document. + * + * @param {boolean} preferClientId + * A boolean indicating a preference for using a client ID. + * This will not overwrite the user preference but will + * avoid sending a client ID if no request has been made yet. + * @returns {Promise<DiscoAddonWrapper[]>} + */ + async getResults(preferClientId = true) { + // Allow a caller to set preferClientId to false, but not true if discovery + // is disabled. + preferClientId = preferClientId && this.clientIdDiscoveryEnabled; + + // Reuse a request for this preference first. + let resultPromise = + this._resultPromises.get(preferClientId) || + // If the client ID isn't preferred, we can still reuse a request with the + // client ID. + (!preferClientId && this._resultPromises.get(true)); + + if (resultPromise) { + return resultPromise; + } + + // Nothing is prepared for this preference, make a new request. + resultPromise = this._fetchRecommendedAddons(preferClientId).catch(e => { + // Delete the pending promise, so _fetchRecommendedAddons can be + // called again at the next property access. + this._resultPromises.delete(preferClientId); + Cu.reportError(e); + throw e; + }); + + // Store the new result for the preference. + this._resultPromises.set(preferClientId, resultPromise); + + return resultPromise; + }, + + get clientIdDiscoveryEnabled() { + // These prefs match Discovery.jsm for enabling clientId cookies. + return ( + Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) && + Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) && + !PrivateBrowsingUtils.isContentWindowPrivate(window) + ); + }, + + async _fetchRecommendedAddons(useClientId) { + let discoveryApiUrl = new URL( + Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL) + ); + + if (useClientId) { + let clientId = await ClientID.getClientIdHash(); + discoveryApiUrl.searchParams.set("telemetry-client-id", clientId); + } + let res = await fetch(discoveryApiUrl.href, { + credentials: "omit", + }); + if (!res.ok) { + throw new Error(`Failed to fetch recommended add-ons, ${res.status}`); + } + let { results } = await res.json(); + return results.map(details => new DiscoAddonWrapper(details)); + }, +}; + +class SupportLink extends HTMLAnchorElement { + static get observedAttributes() { + return ["support-page"]; + } + + connectedCallback() { + this.setHref(); + this.setAttribute("target", "_blank"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "support-page") { + this.setHref(); + } + } + + setHref() { + let base = SUPPORT_URL + this.getAttribute("support-page"); + this.href = this.hasAttribute("utmcontent") + ? formatUTMParams(this.getAttribute("utmcontent"), base) + : base; + } +} +customElements.define("support-link", SupportLink, { extends: "a" }); + +class PanelList extends HTMLElement { + static get observedAttributes() { + return ["open"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + // Ensure that the element is hidden even if its main stylesheet hasn't + // loaded yet. On initial load, or with cache disabled, the element could + // briefly flicker before the stylesheet is loaded without this. + let style = document.createElement("style"); + style.textContent = ` + :host(:not([open])) { + display: none; + } + `; + this.shadowRoot.appendChild(style); + this.shadowRoot.appendChild(importTemplate("panel-list")); + } + + connectedCallback() { + this.setAttribute("role", "menu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "open" && newVal != oldVal) { + if (this.open) { + this.onShow(); + } else { + this.onHide(); + } + } + } + + get open() { + return this.hasAttribute("open"); + } + + set open(val) { + this.toggleAttribute("open", val); + } + + show(triggeringEvent) { + this.triggeringEvent = triggeringEvent; + this.open = true; + } + + hide(triggeringEvent) { + let openingEvent = this.triggeringEvent; + this.triggeringEvent = triggeringEvent; + this.open = false; + // Refocus the button that opened the menu if we have one. + if (openingEvent && openingEvent.target) { + openingEvent.target.focus(); + } + } + + toggle(triggeringEvent) { + if (this.open) { + this.hide(triggeringEvent); + } else { + this.show(triggeringEvent); + } + } + + async setAlign() { + // Set the showing attribute to hide the panel until its alignment is set. + this.setAttribute("showing", "true"); + // Tell the parent node to hide any overflow in case the panel extends off + // the page before the alignment is set. + this.parentNode.style.overflow = "hidden"; + + // Wait for a layout flush, then find the bounds. + let { + anchorHeight, + anchorLeft, + anchorTop, + anchorWidth, + panelHeight, + panelWidth, + winHeight, + winScrollY, + winScrollX, + winWidth, + } = await new Promise(resolve => { + this.style.left = 0; + this.style.top = 0; + + requestAnimationFrame(() => + setTimeout(() => { + let anchorNode = + (this.triggeringEvent && this.triggeringEvent.target) || + this.parentNode; + // Use y since top is reserved. + let anchorBounds = window.windowUtils.getBoundsWithoutFlushing( + anchorNode + ); + let panelBounds = window.windowUtils.getBoundsWithoutFlushing(this); + resolve({ + anchorHeight: anchorBounds.height, + anchorLeft: anchorBounds.left, + anchorTop: anchorBounds.top, + anchorWidth: anchorBounds.width, + panelHeight: panelBounds.height, + panelWidth: panelBounds.width, + winHeight: innerHeight, + winWidth: innerWidth, + winScrollX: scrollX, + winScrollY: scrollY, + }); + }, 0) + ); + }); + + // Calculate the left/right alignment. + let align; + let leftOffset; + // The tip of the arrow is 25px from the edge of the panel, + // but 26px looks right. + let arrowOffset = 26; + let leftAlignX = anchorLeft + anchorWidth / 2 - arrowOffset; + let rightAlignX = anchorLeft + anchorWidth / 2 - panelWidth + arrowOffset; + if (Services.locale.isAppLocaleRTL) { + // Prefer aligning on the right. + align = rightAlignX < 0 ? "left" : "right"; + } else { + // Prefer aligning on the left. + align = leftAlignX + panelWidth > winWidth ? "right" : "left"; + } + leftOffset = align === "left" ? leftAlignX : rightAlignX; + + let bottomAlignY = anchorTop + anchorHeight; + let valign; + let topOffset; + if (bottomAlignY + panelHeight > winHeight) { + topOffset = anchorTop - panelHeight; + valign = "top"; + } else { + topOffset = bottomAlignY; + valign = "bottom"; + } + + // Set the alignments and show the panel. + this.setAttribute("align", align); + this.setAttribute("valign", valign); + this.parentNode.style.overflow = ""; + + this.style.left = `${leftOffset + winScrollX}px`; + this.style.top = `${topOffset + winScrollY}px`; + + this.removeAttribute("showing"); + } + + addHideListeners() { + // Hide when a panel-item is clicked in the list. + this.addEventListener("click", this); + document.addEventListener("keydown", this); + // Hide when a click is initiated outside the panel. + document.addEventListener("mousedown", this); + // Hide if focus changes and the panel isn't in focus. + document.addEventListener("focusin", this); + // Reset or focus tracking, we treat the first focusin differently. + this.focusHasChanged = false; + // Hide on resize, scroll or losing window focus. + window.addEventListener("resize", this); + window.addEventListener("scroll", this); + window.addEventListener("blur", this); + } + + removeHideListeners() { + this.removeEventListener("click", this); + document.removeEventListener("keydown", this); + document.removeEventListener("mousedown", this); + document.removeEventListener("focusin", this); + window.removeEventListener("resize", this); + window.removeEventListener("scroll", this); + window.removeEventListener("blur", this); + } + + handleEvent(e) { + // Ignore the event if it caused the panel to open. + if (e == this.triggeringEvent) { + return; + } + + switch (e.type) { + case "resize": + case "scroll": + case "blur": + this.hide(); + break; + case "click": + if (e.target.tagName == "PANEL-ITEM") { + this.hide(); + } else { + // Avoid falling through to the default click handler of the + // add-on card, which would expand the add-on card. + e.stopPropagation(); + } + break; + case "keydown": + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") { + // Ignore tabbing with a modifer other than shift. + if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + + // Don't scroll the page or let the regular tab order take effect. + e.preventDefault(); + + // Keep moving to the next/previous element sibling until we find a + // panel-item that isn't hidden. + let moveForward = + e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey); + + // If the menu is opened with the mouse, the active element might be + // somewhere else in the document. In that case we should ignore it + // to avoid walking unrelated DOM nodes. + this.focusWalker.currentNode = this.contains(document.activeElement) + ? document.activeElement + : this; + let nextItem = moveForward + ? this.focusWalker.nextNode() + : this.focusWalker.previousNode(); + + // If the next item wasn't found, try looping to the top/bottom. + if (!nextItem) { + this.focusWalker.currentNode = this; + if (moveForward) { + nextItem = this.focusWalker.firstChild(); + } else { + nextItem = this.focusWalker.lastChild(); + } + } + break; + } else if (e.key === "Escape") { + this.hide(); + } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { + // Check if any of the children have an accesskey for this letter. + let item = this.querySelector( + `[accesskey="${e.key.toLowerCase()}"], + [accesskey="${e.key.toUpperCase()}"]` + ); + if (item) { + item.click(); + } + } + break; + case "mousedown": + case "focusin": + // There will be a focusin after the mousedown that opens the panel + // using the mouse. Ignore the first focusin event if it's on the + // triggering target. + if ( + this.triggeringEvent && + e.target == this.triggeringEvent.target && + !this.focusHasChanged + ) { + this.focusHasChanged = true; + // If the target isn't in the panel, hide. This will close when focus + // moves out of the panel, or there's a click started outside the + // panel. + } else if (!e.target || e.target.closest("panel-list") != this) { + this.hide(); + // Just record that there was a focusin event. + } else { + this.focusHasChanged = true; + } + break; + } + } + + /** + * A TreeWalker that can be used to focus elements. The returned element will + * be the element that has gained focus based on the requested movement + * through the tree. + * + * Example: + * + * this.focusWalker.currentNode = this; + * // Focus and get the first focusable child. + * let focused = this.focusWalker.nextNode(); + * // Focus the second focusable child. + * this.focusWalker.nextNode(); + */ + get focusWalker() { + if (!this._focusWalker) { + this._focusWalker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + // No need to look at hidden nodes. + if (node.hidden) { + return NodeFilter.FILTER_REJECT; + } + + // Focus the node, if it worked then this is the node we want. + node.focus(); + if (node === document.activeElement) { + return NodeFilter.FILTER_ACCEPT; + } + + // Continue into child nodes if the parent couldn't be focused. + return NodeFilter.FILTER_SKIP; + }, + } + ); + } + return this._focusWalker; + } + + async onShow() { + this.sendEvent("showing"); + this.addHideListeners(); + await this.setAlign(); + + // Wait until the next paint for the alignment to be set and panel to be + // visible. + requestAnimationFrame(() => { + // Focus the first focusable panel-item. + this.focusWalker.currentNode = this; + this.focusWalker.nextNode(); + + this.sendEvent("shown"); + }); + } + + onHide() { + requestAnimationFrame(() => this.sendEvent("hidden")); + this.removeHideListeners(); + } + + sendEvent(name, detail) { + this.dispatchEvent(new CustomEvent(name, { detail })); + } +} +customElements.define("panel-list", PanelList); + +class PanelItem extends HTMLElement { + static get observedAttributes() { + return ["accesskey"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://mozapps/content/extensions/panel-item.css"; + + this.button = document.createElement("button"); + this.button.setAttribute("role", "menuitem"); + + // Use a XUL label element to show the accesskey. + this.label = document.createXULElement("label"); + this.button.appendChild(this.label); + + let supportLinkSlot = document.createElement("slot"); + supportLinkSlot.name = "support-link"; + + let defaultSlot = document.createElement("slot"); + defaultSlot.style.display = "none"; + + this.shadowRoot.append(style, this.button, supportLinkSlot, defaultSlot); + + // When our content changes, move the text into the label. It doesn't work + // with a <slot>, unfortunately. + new MutationObserver(() => { + this.label.textContent = defaultSlot + .assignedNodes() + .map(node => node.textContent) + .join(""); + }).observe(this, { characterData: true, childList: true, subtree: true }); + } + + connectedCallback() { + this.panel = this.closest("panel-list"); + + if (this.panel) { + this.panel.addEventListener("hidden", this); + this.panel.addEventListener("shown", this); + } + } + + disconnectedCallback() { + if (this.panel) { + this.panel.removeEventListener("hidden", this); + this.panel.removeEventListener("shown", this); + this.panel = null; + } + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "accesskey") { + // Bug 1037709 - Accesskey doesn't work in shadow DOM. + // Ideally we'd have the accesskey set in shadow DOM, and on + // attributeChangedCallback we'd just update the shadow DOM accesskey. + + // Skip this change event if we caused it. + if (this._modifyingAccessKey) { + this._modifyingAccessKey = false; + return; + } + + this.label.accessKey = newVal || ""; + + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + if (!this.panel || !this.panel.open) { + // When the panel isn't open, just store the key for later. + this._accessKey = newVal || null; + this._modifyingAccessKey = true; + this.accessKey = ""; + } else { + this._accessKey = null; + } + } + } + + get disabled() { + return this.button.hasAttribute("disabled"); + } + + set disabled(val) { + this.button.toggleAttribute("disabled", val); + } + + get checked() { + return this.hasAttribute("checked"); + } + + set checked(val) { + this.toggleAttribute("checked", val); + } + + focus() { + this.button.focus(); + } + + handleEvent(e) { + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + switch (e.type) { + case "shown": + if (this._accessKey) { + this.accessKey = this._accessKey; + this._accessKey = null; + } + break; + case "hidden": + if (this.accessKey) { + this._accessKey = this.accessKey; + this._modifyingAccessKey = true; + this.accessKey = ""; + } + break; + } + } +} +customElements.define("panel-item", PanelItem); + +class SearchAddons extends HTMLElement { + connectedCallback() { + if (this.childElementCount === 0) { + this.input = document.createXULElement("search-textbox"); + this.input.setAttribute("searchbutton", true); + this.input.setAttribute("maxlength", 100); + this.input.setAttribute("data-l10n-attrs", "placeholder"); + document.l10n.setAttributes(this.input, "addons-heading-search-input"); + this.append(this.input); + } + this.input.addEventListener("command", this); + document.addEventListener("keypress", this); + } + + disconnectedCallback() { + this.input.removeEventListener("command", this); + document.removeEventListener("keypress", this); + } + + focus() { + this.input.focus(); + } + + get focusKey() { + return this.getAttribute("key"); + } + + handleEvent(e) { + if (e.type === "command") { + this.searchAddons(this.value); + } else if (e.type === "keypress") { + if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey) { + this.focus(); + } else if (e.key == this.focusKey) { + if (e.altKey || e.shiftKey) { + return; + } + + if (Services.appinfo.OS === "Darwin") { + if (e.metaKey && !e.ctrlKey) { + this.focus(); + } + } else if (e.ctrlKey && !e.metaKey) { + this.focus(); + } + } + } + } + + get value() { + return this.input.value; + } + + searchAddons(query) { + if (query.length === 0) { + return; + } + + let url = formatUTMParams( + "addons-manager-search", + AddonRepository.getSearchURL(query) + ); + + let browser = getBrowserElement(); + let chromewin = browser.ownerGlobal; + chromewin.openLinkIn(url, "tab", { + fromChrome: true, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value: "search", + extra: { + type: this.closest("addon-page-header").getAttribute("type"), + view: getTelemetryViewName(this), + }, + }); + } +} +customElements.define("search-addons", SearchAddons); + +class GlobalWarnings extends MessageBarStackElement { + constructor() { + super(); + // This won't change at runtime, but we'll want to fake it in tests. + this.inSafeMode = Services.appinfo.inSafeMode; + this.globalWarning = null; + } + + connectedCallback() { + this.refresh(); + this.addEventListener("click", this); + AddonManagerListenerHandler.addListener(this); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + AddonManagerListenerHandler.removeListener(this); + } + + refresh() { + if (this.inSafeMode) { + this.setWarning("safe-mode"); + } else if ( + AddonManager.checkUpdateSecurityDefault && + !AddonManager.checkUpdateSecurity + ) { + this.setWarning("update-security", { action: true }); + } else if (!AddonManager.checkCompatibility) { + this.setWarning("check-compatibility", { action: true }); + } else { + this.removeWarning(); + } + } + + setWarning(type, opts) { + if ( + this.globalWarning && + this.globalWarning.getAttribute("warning-type") !== type + ) { + this.removeWarning(); + } + if (!this.globalWarning) { + this.globalWarning = document.createElement("message-bar"); + this.globalWarning.setAttribute("warning-type", type); + let textContainer = document.createElement("span"); + document.l10n.setAttributes(textContainer, `extensions-warning-${type}`); + this.globalWarning.appendChild(textContainer); + if (opts && opts.action) { + let button = document.createElement("button"); + document.l10n.setAttributes( + button, + `extensions-warning-${type}-button` + ); + button.setAttribute("action", type); + this.globalWarning.appendChild(button); + } + this.appendChild(this.globalWarning); + } + } + + removeWarning() { + if (this.globalWarning) { + this.globalWarning.remove(); + this.globalWarning = null; + } + } + + handleEvent(e) { + if (e.type === "click") { + switch (e.target.getAttribute("action")) { + case "update-security": + AddonManager.checkUpdateSecurity = true; + break; + case "check-compatibility": + AddonManager.checkCompatibility = true; + break; + } + } + } + + /** + * AddonManager listener events. + */ + + onCompatibilityModeChanged() { + this.refresh(); + } + + onCheckUpdateSecurityChanged() { + this.refresh(); + } +} +customElements.define("global-warnings", GlobalWarnings); + +class AddonPageHeader extends HTMLElement { + connectedCallback() { + if (this.childElementCount === 0) { + this.appendChild(importTemplate("addon-page-header")); + this.heading = this.querySelector(".header-name"); + this.backButton = this.querySelector(".back-button"); + this.pageOptionsMenuButton = this.querySelector( + '[action="page-options"]' + ); + // The addon-page-options element is outside of this element since this is + // position: sticky and that would break the positioning of the menu. + this.pageOptionsMenu = document.getElementById( + this.getAttribute("page-options-id") + ); + } + this.addEventListener("click", this); + this.addEventListener("mousedown", this); + // Use capture since the event is actually triggered on the internal + // panel-list and it doesn't bubble. + this.pageOptionsMenu.addEventListener("shown", this, true); + this.pageOptionsMenu.addEventListener("hidden", this, true); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + this.removeEventListener("mousedown", this); + this.pageOptionsMenu.removeEventListener("shown", this, true); + this.pageOptionsMenu.removeEventListener("hidden", this, true); + } + + setViewInfo({ type, param }) { + this.setAttribute("current-view", type); + this.setAttribute("current-param", param); + let viewType = type === "list" ? param : type; + this.setAttribute("type", viewType); + + this.heading.hidden = viewType === "detail"; + this.backButton.hidden = viewType !== "detail" && viewType !== "shortcuts"; + + let { contentWindow } = getBrowserElement(); + this.backButton.disabled = !contentWindow.history.state?.previousView; + + if (viewType !== "detail") { + document.l10n.setAttributes(this.heading, `${viewType}-heading`); + } + } + + handleEvent(e) { + let { backButton, pageOptionsMenu, pageOptionsMenuButton } = this; + if (e.type === "click") { + switch (e.target) { + case backButton: + window.history.back(); + break; + case pageOptionsMenuButton: + if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + this.pageOptionsMenu.toggle(e); + } + break; + } + } else if ( + e.type == "mousedown" && + e.target == pageOptionsMenuButton && + e.button == 0 + ) { + this.pageOptionsMenu.toggle(e); + } else if ( + e.target == pageOptionsMenu.panel && + (e.type == "shown" || e.type == "hidden") + ) { + this.pageOptionsMenuButton.setAttribute( + "aria-expanded", + this.pageOptionsMenu.open + ); + } + } +} +customElements.define("addon-page-header", AddonPageHeader); + +class AddonUpdatesMessage extends HTMLElement { + static get observedAttributes() { + return ["state"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + let style = document.createElement("style"); + style.textContent = ` + @import "chrome://global/skin/in-content/common.css"; + button { + margin: 0; + } + `; + this.message = document.createElement("span"); + this.message.hidden = true; + this.button = document.createElement("button"); + this.button.addEventListener("click", e => { + if (e.button === 0) { + loadViewFn("updates/available"); + } + }); + this.button.hidden = true; + this.shadowRoot.append(style, this.message, this.button); + } + + connectedCallback() { + document.l10n.connectRoot(this.shadowRoot); + document.l10n.translateFragment(this.shadowRoot); + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "state" && oldVal !== newVal) { + let l10nId = `addon-updates-${newVal}`; + switch (newVal) { + case "updating": + case "installed": + case "none-found": + this.button.hidden = true; + this.message.hidden = false; + document.l10n.setAttributes(this.message, l10nId); + break; + case "manual-updates-found": + this.message.hidden = true; + this.button.hidden = false; + document.l10n.setAttributes(this.button, l10nId); + break; + } + } + } + + set state(val) { + this.setAttribute("state", val); + } +} +customElements.define("addon-updates-message", AddonUpdatesMessage); + +class AddonPageOptions extends HTMLElement { + connectedCallback() { + if (this.childElementCount === 0) { + this.render(); + } + this.addEventListener("click", this); + this.panel.addEventListener("showing", this); + AddonManagerListenerHandler.addListener(this); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + this.panel.removeEventListener("showing", this); + AddonManagerListenerHandler.removeListener(this); + } + + toggle(...args) { + return this.panel.toggle(...args); + } + + get open() { + return this.panel.open; + } + + render() { + this.appendChild(importTemplate("addon-page-options")); + this.panel = this.querySelector("panel-list"); + this.installFromFile = this.querySelector('[action="install-from-file"]'); + this.toggleUpdatesEl = this.querySelector( + '[action="set-update-automatically"]' + ); + this.resetUpdatesEl = this.querySelector('[action="reset-update-states"]'); + this.onUpdateModeChanged(); + } + + async handleEvent(e) { + if (e.type === "click") { + e.target.disabled = true; + try { + await this.onClick(e); + } finally { + e.target.disabled = false; + } + } else if (e.type === "showing") { + this.installFromFile.hidden = !XPINSTALL_ENABLED; + } + } + + async onClick(e) { + switch (e.target.getAttribute("action")) { + case "check-for-updates": + await this.checkForUpdates(); + break; + case "view-recent-updates": + loadViewFn("updates/recent"); + break; + case "install-from-file": + if (XPINSTALL_ENABLED) { + installAddonsFromFilePicker().then(installs => { + for (let install of installs) { + this.recordActionEvent({ + action: "installFromFile", + value: install.installId, + }); + } + }); + } + break; + case "debug-addons": + this.openAboutDebugging(); + break; + case "set-update-automatically": + await this.toggleAutomaticUpdates(); + break; + case "reset-update-states": + await this.resetAutomaticUpdates(); + break; + case "manage-shortcuts": + loadViewFn("shortcuts/shortcuts"); + break; + } + } + + async checkForUpdates(e) { + this.recordActionEvent({ action: "checkForUpdates" }); + let message = document.getElementById("updates-message"); + message.state = "updating"; + message.hidden = false; + let { installed, pending } = await checkForUpdates(); + if (pending > 0) { + message.state = "manual-updates-found"; + } else if (installed > 0) { + message.state = "installed"; + } else { + message.state = "none-found"; + } + } + + openAboutDebugging() { + let mainWindow = window.windowRoot.ownerGlobal; + this.recordLinkEvent({ value: "about:debugging" }); + if ("switchToTabHavingURI" in mainWindow) { + let principal = Services.scriptSecurityManager.getSystemPrincipal(); + mainWindow.switchToTabHavingURI( + `about:debugging#/runtime/this-firefox`, + true, + { + ignoreFragment: "whenComparing", + triggeringPrincipal: principal, + } + ); + } + } + + automaticUpdatesEnabled() { + return AddonManager.updateEnabled && AddonManager.autoUpdateDefault; + } + + toggleAutomaticUpdates() { + if (!this.automaticUpdatesEnabled()) { + // One or both of the prefs is false, i.e. the checkbox is not + // checked. Now toggle both to true. If the user wants us to + // auto-update add-ons, we also need to auto-check for updates. + AddonManager.updateEnabled = true; + AddonManager.autoUpdateDefault = true; + } else { + // Both prefs are true, i.e. the checkbox is checked. + // Toggle the auto pref to false, but don't touch the enabled check. + AddonManager.autoUpdateDefault = false; + } + // Record telemetry for changing the update policy. + let updatePolicy = []; + if (AddonManager.autoUpdateDefault) { + updatePolicy.push("default"); + } + if (AddonManager.updateEnabled) { + updatePolicy.push("enabled"); + } + this.recordActionEvent({ + action: "setUpdatePolicy", + value: updatePolicy.join(","), + }); + } + + async resetAutomaticUpdates() { + let addons = await AddonManager.getAllAddons(); + for (let addon of addons) { + if ("applyBackgroundUpdates" in addon) { + addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT; + } + } + this.recordActionEvent({ action: "resetUpdatePolicy" }); + } + + getTelemetryViewName() { + return getTelemetryViewName(document.getElementById("page-header")); + } + + recordActionEvent({ action, value }) { + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: this.getTelemetryViewName(), + action, + addon: this.addon, + value, + }); + } + + recordLinkEvent({ value }) { + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value, + extra: { + view: this.getTelemetryViewName(), + }, + }); + } + + /** + * AddonManager listener events. + */ + + onUpdateModeChanged() { + let updatesEnabled = this.automaticUpdatesEnabled(); + this.toggleUpdatesEl.checked = updatesEnabled; + let resetType = updatesEnabled ? "automatic" : "manual"; + let resetStringId = `addon-updates-reset-updates-to-${resetType}`; + document.l10n.setAttributes(this.resetUpdatesEl, resetStringId); + } +} +customElements.define("addon-page-options", AddonPageOptions); + +class CategoryButton extends HTMLButtonElement { + connectedCallback() { + if (this.childElementCount != 0) { + return; + } + + // Make sure the aria-selected attribute is set correctly. + this.selected = this.hasAttribute("selected"); + + document.l10n.setAttributes(this, `addon-category-${this.name}-title`); + + let text = document.createElement("span"); + text.classList.add("category-name"); + document.l10n.setAttributes(text, `addon-category-${this.name}`); + + this.append(text); + } + + load() { + loadViewFn(this.viewId); + } + + get isVisible() { + return true; + } + + get badgeCount() { + return parseInt(this.getAttribute("badge-count"), 10) || 0; + } + + set badgeCount(val) { + let count = parseInt(val, 10); + if (count) { + this.setAttribute("badge-count", count); + } else { + this.removeAttribute("badge-count"); + } + } + + get selected() { + return this.hasAttribute("selected"); + } + + set selected(val) { + this.toggleAttribute("selected", !!val); + this.setAttribute("aria-selected", !!val); + } + + get name() { + return this.getAttribute("name"); + } + + get viewId() { + return this.getAttribute("viewid"); + } + + // Just setting the hidden attribute isn't enough in case the category gets + // hidden while about:addons is closed since it could be the last active view + // which will unhide the button when it gets selected. + get defaultHidden() { + return this.hasAttribute("default-hidden"); + } +} +customElements.define("category-button", CategoryButton, { extends: "button" }); + +class DiscoverButton extends CategoryButton { + get isVisible() { + return isDiscoverEnabled(); + } +} +customElements.define("discover-button", DiscoverButton, { extends: "button" }); + +class CategoriesBox extends customElements.get("button-group") { + constructor() { + super(); + // This will resolve when the initial category states have been set from + // our cached prefs. This is intended for use in testing to verify that we + // are caching the previous state. + this.promiseRendered = new Promise(resolve => { + this._resolveRendered = resolve; + }); + // This will resolve when the final category states have been set by + // checking the AddonManager state and showing/hiding categories. The page + // won't be "initialized" until this resolves. + this.promiseInitialized = new Promise(resolve => { + this._resolveInitialized = resolve; + }); + } + + async initialize() { + let addonTypesObjects = AddonManager.addonTypes; + let addonTypes = new Set(); + for (let type in addonTypesObjects) { + addonTypes.add(type); + } + + let hiddenTypes = new Set([]); + + for (let button of this.children) { + let { defaultHidden, name } = button; + button.hidden = + !button.isVisible || (defaultHidden && this.shouldHideCategory(name)); + + if (defaultHidden && addonTypes.has(name)) { + hiddenTypes.add(name); + } + } + + let hiddenUpdated; + if (hiddenTypes.size) { + hiddenUpdated = this.updateHiddenCategories(Array.from(hiddenTypes)); + } + + this.updateAvailableCount(); + + this.addEventListener("click", e => { + let button = e.target.closest("[viewid]"); + if (button) { + button.load(); + } + }); + this.addEventListener("button-group:key-selected", e => { + this.activeChild.load(); + }); + + AddonManagerListenerHandler.addListener(this); + + this._resolveRendered(); + await hiddenUpdated; + this._resolveInitialized(); + } + + get initialViewId() { + let viewId = Services.prefs.getStringPref(PREF_UI_LASTCATEGORY, ""); + // If the pref value is a valid top-level view then use that viewId. + if (this.getButtonByViewId(viewId)) { + return viewId; + } + // Otherwise, use the first viewId that can be shown. + for (let button of this.children) { + if (!button.defaultHidden && !button.hidden && button.isVisible) { + return button.viewId; + } + } + // If there aren't any available views then there's nothing to load. This + // shouldn't happen though since the extension list should always be valid. + throw new Error("Couldn't find initial view to load"); + } + + shouldHideCategory(name) { + return Services.prefs.getBoolPref(`extensions.ui.${name}.hidden`, true); + } + + setShouldHideCategory(name, hide) { + Services.prefs.setBoolPref(`extensions.ui.${name}.hidden`, hide); + } + + getButtonByName(name) { + return this.querySelector(`[name="${name}"]`); + } + + getButtonByViewId(id) { + return this.querySelector(`[viewid="${id}"]`); + } + + get selectedChild() { + return this._selectedChild; + } + + set selectedChild(node) { + if (node && this.contains(node)) { + if (this._selectedChild) { + this._selectedChild.selected = false; + } + this._selectedChild = node; + this._selectedChild.selected = true; + } + } + + select(viewId) { + let button = this.querySelector(`[viewid="${viewId}"]`); + if (button) { + this.activeChild = button; + this.selectedChild = button; + button.hidden = false; + Services.prefs.setStringPref(PREF_UI_LASTCATEGORY, viewId); + } + } + + selectType(type) { + this.select(`addons://list/${type}`); + } + + onInstalled(addon) { + let button = this.getButtonByName(addon.type); + if (button) { + button.hidden = false; + this.setShouldHideCategory(addon.type, false); + } + this.updateAvailableCount(); + } + + onInstallStarted(install) { + this.onInstalled(install); + } + + onNewInstall() { + this.updateAvailableCount(); + } + + onInstallPostponed() { + this.updateAvailableCount(); + } + + onInstallCancelled() { + this.updateAvailableCount(); + } + + async updateAvailableCount() { + let installs = await AddonManager.getAllInstalls(); + var count = installs.filter(install => { + return isManualUpdate(install) && !install.installed; + }).length; + let availableButton = this.getButtonByName("available-updates"); + availableButton.hidden = !availableButton.selected && count == 0; + availableButton.badgeCount = count; + } + + async updateHiddenCategories(types) { + let hiddenTypes = new Set(types); + let getAddons = AddonManager.getAddonsByTypes(types); + let getInstalls = AddonManager.getInstallsByTypes(types); + + for (let addon of await getAddons) { + if (addon.hidden) { + continue; + } + + this.onInstalled(addon); + hiddenTypes.delete(addon.type); + + if (!hiddenTypes.size) { + return; + } + } + + for (let install of await getInstalls) { + if ( + install.existingAddon || + install.state == AddonManager.STATE_AVAILABLE + ) { + continue; + } + + this.onInstalled(install); + hiddenTypes.delete(install.type); + + if (!hiddenTypes.size) { + return; + } + } + + for (let type of hiddenTypes) { + let button = this.getButtonByName(type); + if (button.selected) { + // Cancel the load if this view should be hidden. + replaceWithDefaultViewFn(); + } + this.setShouldHideCategory(type, true); + button.hidden = true; + } + } +} +customElements.define("categories-box", CategoriesBox); + +class SidebarFooter extends HTMLElement { + connectedCallback() { + let list = document.createElement("ul"); + list.classList.add("sidebar-footer-list"); + + let prefsItem = document.createElement("li"); + prefsItem.classList.add("sidebar-footer-item"); + let prefsLink = document.createElement("a"); + prefsLink.classList.add("sidebar-footer-link", "preferences-icon"); + prefsLink.id = "preferencesButton"; + prefsLink.href = "about:preferences"; + document.l10n.setAttributes(prefsLink, "sidebar-preferences-button-title"); + let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + prefsLink.addEventListener("click", e => { + e.preventDefault(); + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value: "about:preferences", + extra: { + view: getTelemetryViewName(this), + }, + }); + windowRoot.ownerGlobal.switchToTabHavingURI("about:preferences", true, { + ignoreFragment: "whenComparing", + triggeringPrincipal: systemPrincipal, + }); + }); + let prefsText = document.createElement("span"); + prefsText.classList.add("sidebar-footer-link-text"); + document.l10n.setAttributes(prefsText, "preferences"); + prefsLink.append(prefsText); + prefsItem.append(prefsLink); + + let supportItem = document.createElement("li"); + supportItem.classList.add("sidebar-footer-item"); + let supportLink = document.createElement("a", { is: "support-link" }); + document.l10n.setAttributes(supportLink, "sidebar-help-button-title"); + supportLink.classList.add("sidebar-footer-link", "help-icon"); + supportLink.id = "help-button"; + supportLink.setAttribute("support-page", "addons-help"); + supportLink.addEventListener("click", e => { + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value: "support", + extra: { + view: getTelemetryViewName(this), + }, + }); + }); + let supportText = document.createElement("span"); + supportText.classList.add("sidebar-footer-link-text"); + document.l10n.setAttributes(supportText, "help-button"); + supportLink.append(supportText); + supportItem.append(supportLink); + + list.append(prefsItem, supportItem); + this.append(list); + } +} +customElements.define("sidebar-footer", SidebarFooter, { extends: "footer" }); + +class AddonOptions extends HTMLElement { + connectedCallback() { + if (!this.children.length) { + this.render(); + } + } + + get panel() { + return this.querySelector("panel-list"); + } + + updateSeparatorsVisibility() { + let lastSeparator; + let elWasVisible = false; + + // Collect the panel-list children that are not already hidden. + const children = Array.from(this.panel.children).filter(el => !el.hidden); + + for (let child of children) { + if (child.tagName == "PANEL-ITEM-SEPARATOR") { + child.hidden = !elWasVisible; + if (!child.hidden) { + lastSeparator = child; + } + elWasVisible = false; + } else { + elWasVisible = true; + } + } + if (!elWasVisible && lastSeparator) { + lastSeparator.hidden = true; + } + } + + get template() { + return "addon-options"; + } + + render() { + this.appendChild(importTemplate(this.template)); + } + + setElementState(el, card, addon, updateInstall) { + switch (el.getAttribute("action")) { + case "remove": + if (hasPermission(addon, "uninstall")) { + // Regular add-on that can be uninstalled. + el.disabled = false; + el.hidden = false; + document.l10n.setAttributes(el, "remove-addon-button"); + } else if (addon.isBuiltin) { + // Likely the built-in themes, can't be removed, that's fine. + el.hidden = true; + } else { + // Likely sideloaded, mention that it can't be removed with a link. + el.hidden = false; + el.disabled = true; + if (!el.querySelector('[slot="support-link"]')) { + let link = document.createElement("a", { is: "support-link" }); + link.setAttribute("data-l10n-name", "link"); + link.setAttribute("support-page", "cant-remove-addon"); + link.setAttribute("slot", "support-link"); + el.appendChild(link); + document.l10n.setAttributes(el, "remove-addon-disabled-button"); + } + } + break; + case "report": + el.hidden = !isAbuseReportSupported(addon); + break; + case "install-update": + el.hidden = !updateInstall; + break; + case "expand": + el.hidden = card.expanded; + break; + case "preferences": + el.hidden = + getOptionsType(addon) !== "tab" && + (getOptionsType(addon) !== "inline" || card.expanded); + if (!el.hidden) { + isAddonOptionsUIAllowed(addon).then(allowed => { + el.hidden = !allowed; + }); + } + break; + } + } + + update(card, addon, updateInstall) { + for (let el of this.items) { + this.setElementState(el, card, addon, updateInstall); + } + + // Update the separators visibility based on the updated visibility + // of the actions in the panel-list. + this.updateSeparatorsVisibility(); + } + + get items() { + return this.querySelectorAll("panel-item"); + } + + get visibleItems() { + return Array.from(this.items).filter(item => !item.hidden); + } +} +customElements.define("addon-options", AddonOptions); + +class PluginOptions extends AddonOptions { + get template() { + return "plugin-options"; + } + + setElementState(el, card, addon) { + const userDisabledStates = { + "ask-to-activate": AddonManager.STATE_ASK_TO_ACTIVATE, + "always-activate": false, + "never-activate": true, + }; + const action = el.getAttribute("action"); + if (action in userDisabledStates) { + let userDisabled = userDisabledStates[action]; + el.checked = addon.userDisabled === userDisabled; + let resultProp = + action == "always-activate" && addon.isFlashPlugin + ? "hidden" + : "disabled"; + el[resultProp] = !(el.checked || hasPermission(addon, action)); + } else { + super.setElementState(el, card, addon); + } + } +} +customElements.define("plugin-options", PluginOptions); + +class FiveStarRating extends HTMLElement { + static get observedAttributes() { + return ["rating"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.append(importTemplate("five-star-rating")); + } + + set rating(v) { + this.setAttribute("rating", v); + } + + get rating() { + let v = parseFloat(this.getAttribute("rating"), 10); + if (v >= 0 && v <= 5) { + return v; + } + return 0; + } + + get ratingBuckets() { + // 0 <= x < 0.25 = empty + // 0.25 <= x < 0.75 = half + // 0.75 <= x <= 1 = full + // ... et cetera, until x <= 5. + let { rating } = this; + return [0, 1, 2, 3, 4].map(ratingStart => { + let distanceToFull = rating - ratingStart; + if (distanceToFull < 0.25) { + return "empty"; + } + if (distanceToFull < 0.75) { + return "half"; + } + return "full"; + }); + } + + connectedCallback() { + this.renderRating(); + } + + attributeChangedCallback() { + this.renderRating(); + } + + renderRating() { + let starElements = this.shadowRoot.querySelectorAll(".rating-star"); + for (let [i, part] of this.ratingBuckets.entries()) { + starElements[i].setAttribute("fill", part); + } + document.l10n.setAttributes(this, "five-star-rating", { + rating: this.rating, + }); + } +} +customElements.define("five-star-rating", FiveStarRating); + +class ContentSelectDropdown extends HTMLElement { + connectedCallback() { + if (this.children.length) { + return; + } + // This creates the menulist and menupopup elements needed for the inline + // browser to support <select> elements and context menus. + this.appendChild( + MozXULElement.parseXULToFragment(` + <menulist popuponly="true" id="ContentSelectDropdown" hidden="true"> + <menupopup rolluponmousewheel="true" activateontab="true" + position="after_start" level="parent"/> + </menulist> + `) + ); + } +} +customElements.define("content-select-dropdown", ContentSelectDropdown); + +class ProxyContextMenu extends HTMLElement { + openPopupAtScreen(...args) { + // prettier-ignore + const parentContextMenuPopup = + windowRoot.ownerGlobal.document.getElementById("contentAreaContextMenu"); + return parentContextMenuPopup.openPopupAtScreen(...args); + } +} +customElements.define("proxy-context-menu", ProxyContextMenu); + +class InlineOptionsBrowser extends HTMLElement { + constructor() { + super(); + // Force the options_ui remote browser to recompute window.mozInnerScreenX + // and window.mozInnerScreenY when the "addon details" page has been + // scrolled (See Bug 1390445 for rationale). + // Also force a repaint to fix an issue where the click location was + // getting out of sync (see bug 1548687). + this.updatePositionTask = new DeferredTask(() => { + if (this.browser && this.browser.isRemoteBrowser) { + // Select boxes can appear in the wrong spot after scrolling, this will + // clear that up. Bug 1390445. + this.browser.frameLoader.requestUpdatePosition(); + } + }, 100); + } + + connectedCallback() { + window.addEventListener("scroll", this, true); + top.browsingContext.embedderElement.addEventListener( + "FullZoomChange", + this + ); + top.browsingContext.embedderElement.addEventListener( + "TextZoomChange", + this + ); + } + + disconnectedCallback() { + window.removeEventListener("scroll", this, true); + top.browsingContext.embedderElement.removeEventListener( + "FullZoomChange", + this + ); + top.browsingContext.embedderElement.removeEventListener( + "TextZoomChange", + this + ); + } + + handleEvent(e) { + switch (e.type) { + case "scroll": + return this.updatePositionTask.arm(); + case "FullZoomChange": + case "TextZoomChange": + return this.maybeUpdateZoom(); + } + return undefined; + } + + maybeUpdateZoom() { + let bc = this.browser?.browsingContext; + let topBc = top.browsingContext; + if (!bc || !topBc) { + return; + } + // Use the same full-zoom as our top window. + bc.fullZoom = topBc.fullZoom; + bc.textZoom = topBc.textZoom; + } + + setAddon(addon) { + this.addon = addon; + } + + destroyBrowser() { + this.textContent = ""; + } + + ensureBrowserCreated() { + if (this.childElementCount === 0) { + this.render(); + } + } + + async render() { + let { addon } = this; + if (!addon) { + throw new Error("addon required to create inline options"); + } + + let browser = document.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("id", "addon-inline-options"); + browser.setAttribute("transparent", "true"); + browser.setAttribute("forcemessagemanager", "true"); + browser.setAttribute("selectmenulist", "ContentSelectDropdown"); + browser.setAttribute("autocompletepopup", "PopupAutoComplete"); + + // The outer about:addons document listens for key presses to focus + // the search box when / is pressed. But if we're focused inside an + // options page, don't let those keypresses steal focus. + browser.addEventListener("keypress", event => { + event.stopPropagation(); + }); + + let { optionsURL, optionsBrowserStyle } = addon; + if (addon.isWebExtension) { + let policy = ExtensionParent.WebExtensionPolicy.getByID(addon.id); + browser.setAttribute( + "initialBrowsingContextGroupId", + policy.browsingContextGroupId + ); + } + + let readyPromise; + let remoteSubframes = window.docShell.QueryInterface(Ci.nsILoadContext) + .useRemoteSubframes; + // For now originAttributes have no effect, which will change if the + // optionsURL becomes anything but moz-extension* or we start considering + // OA for extensions. + var oa = E10SUtils.predictOriginAttributes({ browser }); + let loadRemote = E10SUtils.canLoadURIInRemoteType( + optionsURL, + remoteSubframes, + E10SUtils.EXTENSION_REMOTE_TYPE, + oa + ); + if (loadRemote) { + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE); + + readyPromise = promiseEvent("XULFrameLoaderCreated", browser); + } else { + readyPromise = promiseEvent("load", browser, true); + } + + let stack = document.createXULElement("stack"); + stack.classList.add("inline-options-stack"); + stack.appendChild(browser); + this.appendChild(stack); + this.browser = browser; + + // Force bindings to apply synchronously. + browser.clientTop; + + await readyPromise; + + this.maybeUpdateZoom(); + + if (!browser.messageManager) { + // If the browser.messageManager is undefined, the browser element has + // been removed from the document in the meantime (e.g. due to a rapid + // sequence of addon reload), return null. + return; + } + + ExtensionParent.apiManager.emit("extension-browser-inserted", browser); + + await new Promise(resolve => { + let messageListener = { + receiveMessage({ name, data }) { + if (name === "Extension:BrowserResized") { + browser.style.height = `${data.height}px`; + } else if (name === "Extension:BrowserContentLoaded") { + resolve(); + } + }, + }; + + let mm = browser.messageManager; + + if (!mm) { + // If the browser.messageManager is undefined, the browser element has + // been removed from the document in the meantime (e.g. due to a rapid + // sequence of addon reload), return null. + resolve(); + return; + } + + mm.loadFrameScript( + "chrome://extensions/content/ext-browser-content.js", + false, + true + ); + mm.addMessageListener("Extension:BrowserContentLoaded", messageListener); + mm.addMessageListener("Extension:BrowserResized", messageListener); + + let browserOptions = { + fixedWidth: true, + isInline: true, + }; + + if (optionsBrowserStyle) { + browserOptions.stylesheets = extensionStylesheets; + } + + mm.sendAsyncMessage("Extension:InitBrowser", browserOptions); + + // prettier-ignore + browser.loadURI(optionsURL, { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + }); + } +} +customElements.define("inline-options-browser", InlineOptionsBrowser); + +class UpdateReleaseNotes extends HTMLElement { + connectedCallback() { + this.addEventListener("click", this); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + } + + handleEvent(e) { + // We used to strip links, but ParserUtils.parseFragment() leaves them in, + // so just make sure we open them using the null principal in a new tab. + if (e.type == "click" && e.target.localName == "a" && e.target.href) { + e.preventDefault(); + e.stopPropagation(); + windowRoot.ownerGlobal.openWebLinkIn(e.target.href, "tab"); + } + } + + async loadForUri(uri) { + // Can't load the release notes without a URL to load. + if (!uri || !uri.spec) { + this.setErrorMessage(); + this.dispatchEvent(new CustomEvent("release-notes-error")); + return; + } + + // Don't try to load for the same update a second time. + if (this.url == uri.spec) { + this.dispatchEvent(new CustomEvent("release-notes-cached")); + return; + } + + // Store the URL to skip the network if loaded again. + this.url = uri.spec; + + // Set the loading message before hitting the network. + this.setLoadingMessage(); + this.dispatchEvent(new CustomEvent("release-notes-loading")); + + try { + // loadReleaseNotes will fetch and sanitize the release notes. + let fragment = await loadReleaseNotes(uri); + this.textContent = ""; + this.appendChild(fragment); + this.dispatchEvent(new CustomEvent("release-notes-loaded")); + } catch (e) { + this.setErrorMessage(); + this.dispatchEvent(new CustomEvent("release-notes-error")); + } + } + + setMessage(id) { + this.textContent = ""; + let message = document.createElement("p"); + document.l10n.setAttributes(message, id); + this.appendChild(message); + } + + setLoadingMessage() { + this.setMessage("release-notes-loading"); + } + + setErrorMessage() { + this.setMessage("release-notes-error"); + } +} +customElements.define("update-release-notes", UpdateReleaseNotes); + +class AddonPermissionsList extends HTMLElement { + setAddon(addon) { + this.addon = addon; + this.render(); + } + + async render() { + let appName = brandBundle.GetStringFromName("brandShortName"); + let permissions = Extension.formatPermissionStrings( + { + permissions: this.addon.userPermissions, + optionalPermissions: this.addon.optionalPermissions, + appName, + }, + browserBundle + ); + let optionalEntries = [ + ...Object.entries(permissions.optionalPermissions), + ...Object.entries(permissions.optionalOrigins), + ]; + let perms = await ExtensionPermissions.get(this.addon.id); + + this.textContent = ""; + let frag = importTemplate("addon-permissions-list"); + + if (permissions.msgs.length) { + let section = frag.querySelector(".addon-permissions-required"); + section.hidden = false; + let list = section.querySelector(".addon-permissions-list"); + + for (let msg of permissions.msgs) { + let item = document.createElement("li"); + item.classList.add("permission-info", "permission-checked"); + item.appendChild(document.createTextNode(msg)); + list.appendChild(item); + } + } + if (optionalEntries.length) { + let section = frag.querySelector(".addon-permissions-optional"); + section.hidden = false; + let list = section.querySelector(".addon-permissions-list"); + + for (let id = 0; id < optionalEntries.length; id++) { + let [perm, msg] = optionalEntries[id]; + + let type = "permission"; + if (permissions.optionalOrigins[perm]) { + type = "origin"; + } + let item = document.createElement("li"); + item.classList.add("permission-info"); + + let label = document.createElement("label"); + label.textContent = msg; + + let toggle = document.createElement("input"); + toggle.id = `permission-${id}`; + + label.setAttribute("for", toggle.id); + item.appendChild(label); + + toggle.setAttribute("permission-type", type); + toggle.setAttribute("type", "checkbox"); + if (perms.permissions.includes(perm) || perms.origins.includes(perm)) { + toggle.checked = true; + item.classList.add("permission-checked"); + } + toggle.setAttribute("permission-key", perm); + toggle.setAttribute("action", "toggle-permission"); + toggle.classList.add("toggle-button"); + label.appendChild(toggle); + list.appendChild(item); + } + } + if (!permissions.msgs.length && !optionalEntries.length) { + let row = frag.querySelector(".addon-permissions-empty"); + row.hidden = false; + } + + this.appendChild(frag); + } +} +customElements.define("addon-permissions-list", AddonPermissionsList); + +class AddonDetails extends HTMLElement { + connectedCallback() { + if (!this.children.length) { + this.render(); + } + this.deck.addEventListener("view-changed", this); + } + + disconnectedCallback() { + this.inlineOptions.destroyBrowser(); + this.deck.removeEventListener("view-changed", this); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target == this.deck) { + switch (this.deck.selectedViewName) { + case "release-notes": + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: getTelemetryViewName(this), + action: "releaseNotes", + addon: this.addon, + }); + let releaseNotes = this.querySelector("update-release-notes"); + let uri = this.releaseNotesUri; + if (uri) { + releaseNotes.loadForUri(uri); + } + break; + case "preferences": + if (getOptionsType(this.addon) == "inline") { + this.inlineOptions.ensureBrowserCreated(); + } + break; + } + + // When a details view is rendered again, the default details view is + // unconditionally shown. So if any other tab is selected, do not save + // the current scroll offset, but start at the top of the page instead. + ScrollOffsets.canRestore = this.deck.selectedViewName === "details"; + } + } + + onInstalled() { + let policy = WebExtensionPolicy.getByID(this.addon.id); + let extension = policy && policy.extension; + if (extension && extension.startupReason === "ADDON_UPGRADE") { + // Ensure the options browser is recreated when a new version starts. + this.extensionShutdown(); + this.extensionStartup(); + } + } + + onDisabled(addon) { + this.extensionShutdown(); + } + + onEnabled(addon) { + this.extensionStartup(); + } + + extensionShutdown() { + this.inlineOptions.destroyBrowser(); + } + + extensionStartup() { + if (this.deck.selectedViewName === "preferences") { + this.inlineOptions.ensureBrowserCreated(); + } + } + + get releaseNotesUri() { + let { releaseNotesURI } = getUpdateInstall(this.addon) || this.addon; + return releaseNotesURI; + } + + setAddon(addon) { + this.addon = addon; + } + + update() { + let { addon } = this; + + // Hide tab buttons that won't have any content. + let getButtonByName = name => + this.tabGroup.querySelector(`[name="${name}"]`); + let permsBtn = getButtonByName("permissions"); + permsBtn.hidden = addon.type != "extension"; + let notesBtn = getButtonByName("release-notes"); + notesBtn.hidden = !this.releaseNotesUri; + let prefsBtn = getButtonByName("preferences"); + prefsBtn.hidden = getOptionsType(addon) !== "inline"; + if (prefsBtn.hidden) { + if (this.deck.selectedViewName === "preferences") { + this.deck.selectedViewName = "details"; + } + } else { + isAddonOptionsUIAllowed(addon).then(allowed => { + prefsBtn.hidden = !allowed; + }); + } + + // Hide the tab group if "details" is the only visible button. + let tabGroupButtons = this.tabGroup.querySelectorAll(".tab-button"); + this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => { + return button.name == "details" || button.hidden; + }); + + // Show the update check button if necessary. The button might not exist if + // the add-on doesn't support updates. + let updateButton = this.querySelector('[action="update-check"]'); + if (updateButton) { + updateButton.hidden = + this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon); + } + + // Set the value for auto updates. + let inputs = this.querySelectorAll(".addon-detail-row-updates input"); + for (let input of inputs) { + input.checked = input.value == addon.applyBackgroundUpdates; + } + } + + async render() { + let { addon } = this; + if (!addon) { + throw new Error("addon-details must be initialized by setAddon"); + } + + this.textContent = ""; + this.appendChild(importTemplate("addon-details")); + + this.deck = this.querySelector("named-deck"); + this.tabGroup = this.querySelector(".tab-group"); + + // Set the add-on for the permissions section. + this.permissionsList = this.querySelector("addon-permissions-list"); + this.permissionsList.setAddon(addon); + + // Set the add-on for the preferences section. + this.inlineOptions = this.querySelector("inline-options-browser"); + this.inlineOptions.setAddon(addon); + + // Full description. + let description = this.querySelector(".addon-detail-description"); + if (addon.getFullDescription) { + description.appendChild(addon.getFullDescription(document)); + } else if (addon.fullDescription) { + description.appendChild(nl2br(addon.fullDescription)); + } + + this.querySelector( + ".addon-detail-contribute" + ).hidden = !addon.contributionURL; + this.querySelector(".addon-detail-row-updates").hidden = !hasPermission( + addon, + "upgrade" + ); + + // By default, all private browsing rows are hidden. Possibly show one. + if (allowPrivateBrowsingByDefault || addon.type != "extension") { + // All add-addons of this type are allowed in private browsing mode, so + // do not show any UI. + } else if (addon.incognito == "not_allowed") { + let pbRowNotAllowed = this.querySelector( + ".addon-detail-row-private-browsing-disallowed" + ); + pbRowNotAllowed.hidden = false; + pbRowNotAllowed.nextElementSibling.hidden = false; + } else if (!hasPermission(addon, "change-privatebrowsing")) { + let pbRowRequired = this.querySelector( + ".addon-detail-row-private-browsing-required" + ); + pbRowRequired.hidden = false; + pbRowRequired.nextElementSibling.hidden = false; + } else { + let pbRow = this.querySelector(".addon-detail-row-private-browsing"); + pbRow.hidden = false; + pbRow.nextElementSibling.hidden = false; + let isAllowed = await isAllowedInPrivateBrowsing(addon); + pbRow.querySelector(`[value="${isAllowed ? 1 : 0}"]`).checked = true; + } + + // Author. + let creatorRow = this.querySelector(".addon-detail-row-author"); + if (addon.creator) { + let link = creatorRow.querySelector("a"); + link.hidden = !addon.creator.url; + if (link.hidden) { + creatorRow.appendChild(new Text(addon.creator.name)); + } else { + link.href = formatUTMParams( + "addons-manager-user-profile-link", + addon.creator.url + ); + link.target = "_blank"; + link.textContent = addon.creator.name; + } + } else { + creatorRow.hidden = true; + } + + // Version. Don't show a version for LWTs. + let version = this.querySelector(".addon-detail-row-version"); + if (addon.version && !/@personas\.mozilla\.org/.test(addon.id)) { + version.appendChild(new Text(addon.version)); + } else { + version.hidden = true; + } + + // Last updated. + let updateDate = this.querySelector(".addon-detail-row-lastUpdated"); + if (addon.updateDate) { + let lastUpdated = addon.updateDate.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + updateDate.appendChild(new Text(lastUpdated)); + } else { + updateDate.hidden = true; + } + + // Homepage. + let homepageRow = this.querySelector(".addon-detail-row-homepage"); + if (addon.homepageURL) { + let homepageURL = homepageRow.querySelector("a"); + homepageURL.href = addon.homepageURL; + homepageURL.textContent = addon.homepageURL; + } else { + homepageRow.hidden = true; + } + + // Rating. + let ratingRow = this.querySelector(".addon-detail-row-rating"); + if (addon.averageRating) { + ratingRow.querySelector("five-star-rating").rating = addon.averageRating; + let reviews = ratingRow.querySelector("a"); + reviews.href = formatUTMParams( + "addons-manager-reviews-link", + addon.reviewURL + ); + document.l10n.setAttributes(reviews, "addon-detail-reviews-link", { + numberOfReviews: addon.reviewCount, + }); + } else { + ratingRow.hidden = true; + } + + this.update(); + } + + showPrefs() { + if (getOptionsType(this.addon) == "inline") { + this.deck.selectedViewName = "preferences"; + this.inlineOptions.ensureBrowserCreated(); + } + } +} +customElements.define("addon-details", AddonDetails); + +/** + * A card component for managing an add-on. It should be initialized by setting + * the add-on with `setAddon()` before being connected to the document. + * + * let card = document.createElement("addon-card"); + * card.setAddon(addon); + * document.body.appendChild(card); + */ +class AddonCard extends HTMLElement { + connectedCallback() { + // If we've already rendered we can just update, otherwise render. + if (this.children.length) { + this.update(); + } else { + this.render(); + } + this.registerListeners(); + } + + disconnectedCallback() { + this.removeListeners(); + } + + get expanded() { + return this.hasAttribute("expanded"); + } + + set expanded(val) { + if (val) { + this.setAttribute("expanded", "true"); + } else { + this.removeAttribute("expanded"); + } + } + + get updateInstall() { + return this._updateInstall; + } + + set updateInstall(install) { + this._updateInstall = install; + if (this.children.length) { + this.update(); + } + } + + get reloading() { + return this.hasAttribute("reloading"); + } + + set reloading(val) { + this.toggleAttribute("reloading", val); + } + + /** + * Set the add-on for this card. The card will be populated based on the + * add-on when it is connected to the DOM. + * + * @param {AddonWrapper} addon The add-on to use. + */ + setAddon(addon) { + this.addon = addon; + let install = getUpdateInstall(addon); + if ( + install && + (isInState(install, "available") || isInState(install, "postponed")) + ) { + this.updateInstall = install; + } else { + this.updateInstall = null; + } + if (this.children.length) { + this.render(); + } + } + + async setAddonPermission(permission, type, action) { + let { addon } = this; + let origins = [], + permissions = []; + if (!["add", "remove"].includes(action)) { + throw new Error("invalid action for permission change"); + } + if (type == "permission") { + if ( + action == "add" && + !addon.optionalPermissions.permissions.includes(permission) + ) { + throw new Error("permission missing from manifest"); + } + permissions = [permission]; + } else if (type == "origin") { + if ( + action == "add" && + !addon.optionalPermissions.origins.includes(permission) + ) { + throw new Error("origin missing from manifest"); + } + origins = [permission]; + } else { + throw new Error("unknown permission type changed"); + } + let policy = WebExtensionPolicy.getByID(addon.id); + ExtensionPermissions[action]( + addon.id, + { origins, permissions }, + policy?.extension + ); + } + + async handleEvent(e) { + let { addon } = this; + let action = e.target.getAttribute("action"); + + if (e.type == "click") { + switch (action) { + case "toggle-permission": + let permission = e.target.getAttribute("permission-key"); + let type = e.target.getAttribute("permission-type"); + let fname = e.target.checked ? "add" : "remove"; + this.setAddonPermission(permission, type, fname); + break; + case "toggle-disabled": + this.recordActionEvent(addon.userDisabled ? "enable" : "disable"); + // Keep the checked state the same until the add-on's state changes. + e.target.checked = !addon.userDisabled; + if (addon.userDisabled) { + if (shouldShowPermissionsPrompt(addon)) { + await showPermissionsPrompt(addon); + } else { + await addon.enable(); + } + } else { + await addon.disable(); + } + break; + case "ask-to-activate": + if (hasPermission(addon, "ask-to-activate")) { + addon.userDisabled = AddonManager.STATE_ASK_TO_ACTIVATE; + } + break; + case "always-activate": + this.recordActionEvent("enable"); + addon.userDisabled = false; + break; + case "never-activate": + this.recordActionEvent("disable"); + addon.userDisabled = true; + break; + case "update-check": { + this.recordActionEvent("checkForUpdate"); + let { found } = await checkForUpdate(addon); + if (!found) { + this.sendEvent("no-update"); + } + break; + } + case "install-postponed": { + const { updateInstall } = this; + if (updateInstall && isInState(updateInstall, "postponed")) { + updateInstall.continuePostponedInstall(); + } + break; + } + case "install-update": + // Make sure that an update handler is attached to the install object + // before starting the update installation (otherwise the user would + // not be prompted for the new permissions requested if necessary), + // and also make sure that a prompt handler attached from a closed + // about:addons tab is replaced by the one attached by the currently + // active about:addons tab. + attachUpdateHandler(this.updateInstall); + this.updateInstall.install().then( + () => { + detachUpdateHandler(this.updateInstall); + // The card will update with the new add-on when it gets + // installed. + this.sendEvent("update-installed"); + }, + () => { + detachUpdateHandler(this.updateInstall); + // Update our state if the install is cancelled. + this.update(); + this.sendEvent("update-cancelled"); + } + ); + // Clear the install since it will be removed from the global list of + // available updates (whether it succeeds or fails). + this.updateInstall = null; + break; + case "contribute": + this.recordActionEvent("contribute"); + // prettier-ignore + windowRoot.ownerGlobal.openUILinkIn(addon.contributionURL, "tab", { + triggeringPrincipal: + Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + break; + case "preferences": + if (getOptionsType(addon) == "tab") { + this.recordActionEvent("preferences", "external"); + openOptionsInTab(addon.optionsURL); + } else if (getOptionsType(addon) == "inline") { + this.recordActionEvent("preferences", "inline"); + loadViewFn(`detail/${this.addon.id}/preferences`); + } + break; + case "remove": + { + this.panel.hide(); + let { + remove, + report, + } = windowRoot.ownerGlobal.BrowserAddonUI.promptRemoveExtension( + addon + ); + let value = remove ? "accepted" : "cancelled"; + this.recordActionEvent("uninstall", value); + if (remove) { + await addon.uninstall(true); + this.sendEvent("remove"); + if (report) { + openAbuseReport({ + addonId: addon.id, + reportEntryPoint: "uninstall", + }); + } + } else { + this.sendEvent("remove-cancelled"); + } + } + break; + case "expand": + loadViewFn(`detail/${this.addon.id}`); + break; + case "more-options": + // Open panel on click from the keyboard. + if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + this.panel.toggle(e); + } + break; + case "report": + this.panel.hide(); + openAbuseReport({ addonId: addon.id, reportEntryPoint: "menu" }); + break; + case "link": + if (e.target.getAttribute("url")) { + windowRoot.ownerGlobal.openWebLinkIn( + e.target.getAttribute("url"), + "tab" + ); + } + break; + default: + // Handle a click on the card itself. + if ( + !this.expanded && + (e.target === this.addonNameEl || !e.target.closest("a")) + ) { + e.preventDefault(); + loadViewFn(`detail/${this.addon.id}`); + } else if ( + e.target.localName == "a" && + e.target.getAttribute("data-telemetry-name") + ) { + let value = e.target.getAttribute("data-telemetry-name"); + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + addon, + value, + extra: { + view: getTelemetryViewName(this), + }, + }); + } + break; + } + } else if (e.type == "change") { + let { name } = e.target; + let telemetryValue = e.target.getAttribute("data-telemetry-value"); + if (name == "autoupdate") { + this.recordActionEvent("setAddonUpdate", telemetryValue); + addon.applyBackgroundUpdates = e.target.value; + } else if (name == "private-browsing") { + this.recordActionEvent("privateBrowsingAllowed", telemetryValue); + let policy = WebExtensionPolicy.getByID(addon.id); + let extension = policy && policy.extension; + + if (e.target.value == "1") { + await ExtensionPermissions.add( + addon.id, + PRIVATE_BROWSING_PERMS, + extension + ); + } else { + await ExtensionPermissions.remove( + addon.id, + PRIVATE_BROWSING_PERMS, + extension + ); + } + // Reload the extension if it is already enabled. This ensures any + // change on the private browsing permission is properly handled. + if (addon.isActive) { + this.reloading = true; + // Reloading will trigger an enable and update the card. + addon.reload(); + } else { + // Update the card if the add-on isn't active. + this.update(); + } + } + } else if (e.type == "mousedown") { + // Open panel on mousedown when the mouse is used. + if (action == "more-options" && e.button == 0) { + this.panel.toggle(e); + } + } else if (e.type === "shown" || e.type === "hidden") { + let panelOpen = e.type === "shown"; + // The card will be dimmed if it's disabled, but when the panel is open + // that should be reverted so the menu items can be easily read. + this.toggleAttribute("panelopen", panelOpen); + this.optionsButton.setAttribute("aria-expanded", panelOpen); + } + } + + get panel() { + return this.card.querySelector("panel-list"); + } + + get postponedMessageBar() { + return this.card.querySelector(".update-postponed-bar"); + } + + registerListeners() { + this.addEventListener("change", this); + this.addEventListener("click", this); + this.addEventListener("mousedown", this); + this.panel.addEventListener("shown", this); + this.panel.addEventListener("hidden", this); + } + + removeListeners() { + this.removeEventListener("change", this); + this.removeEventListener("click", this); + this.removeEventListener("mousedown", this); + this.panel.removeEventListener("shown", this); + this.panel.removeEventListener("hidden", this); + } + + /** + * Update the card's contents based on the previously set add-on. This should + * be called if there has been a change to the add-on. + */ + update() { + let { addon, card } = this; + + card.setAttribute("active", addon.isActive); + + // Set the icon or theme preview. + let iconEl = card.querySelector(".addon-icon"); + let preview = card.querySelector(".card-heading-image"); + if (addon.type == "theme") { + iconEl.hidden = true; + let screenshotUrl = getScreenshotUrlForAddon(addon); + if (screenshotUrl) { + preview.src = screenshotUrl; + } + preview.hidden = !screenshotUrl; + } else { + preview.hidden = true; + iconEl.hidden = false; + if (addon.type == "plugin") { + iconEl.src = PLUGIN_ICON_URL; + } else { + iconEl.src = + AddonManager.getPreferredIconURL(addon, 32, window) || + EXTENSION_ICON_URL; + } + } + + // Update the name. + let name = this.addonNameEl; + if (addon.isActive) { + name.textContent = addon.name; + name.removeAttribute("data-l10n-id"); + } else { + document.l10n.setAttributes(name, "addon-name-disabled", { + name: addon.name, + }); + } + name.title = `${addon.name} ${addon.version}`; + + let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]'); + if (toggleDisabledButton) { + let toggleDisabledAction = addon.userDisabled ? "enable" : "disable"; + toggleDisabledButton.hidden = !hasPermission(addon, toggleDisabledAction); + if (addon.type === "theme") { + document.l10n.setAttributes( + toggleDisabledButton, + `${toggleDisabledAction}-addon-button` + ); + } else if (addon.type === "extension") { + toggleDisabledButton.checked = !addon.userDisabled; + } + } + + // Set the items in the more options menu. + this.options.update(this, addon, this.updateInstall); + + // Badge the more options button if there's an update. + let moreOptionsButton = card.querySelector(".more-options-button"); + moreOptionsButton.classList.toggle( + "more-options-button-badged", + !!(this.updateInstall && isInState(this.updateInstall, "available")) + ); + + // Postponed update addon card message bar. + const hasPostponedInstall = + this.updateInstall && isInState(this.updateInstall, "postponed"); + this.postponedMessageBar.hidden = !hasPostponedInstall; + + // Hide the more options button if it's empty. + moreOptionsButton.hidden = this.options.visibleItems.length === 0; + + // Ensure all badges are initially hidden. + for (let node of card.querySelectorAll(".addon-badge")) { + node.hidden = true; + } + + // Set the private browsing badge visibility. + if ( + !allowPrivateBrowsingByDefault && + addon.type == "extension" && + addon.incognito != "not_allowed" + ) { + // Keep update synchronous, the badge can appear later. + isAllowedInPrivateBrowsing(addon).then(isAllowed => { + card.querySelector( + ".addon-badge-private-browsing-allowed" + ).hidden = !isAllowed; + }); + } + + // Show the recommended badges if needed. + // Plugins don't have recommendationStates, so ensure a default. + let states = addon.recommendationStates || []; + for (let badgeName of states) { + let badge = card.querySelector(`.addon-badge-${badgeName}`); + if (badge) { + badge.hidden = false; + } + } + + // Update description. + card.querySelector(".addon-description").textContent = addon.description; + + this.updateMessage(); + + // Update the details if they're shown. + if (this.details) { + this.details.update(); + } + + this.sendEvent("update"); + } + + async updateMessage() { + let { addon, card } = this; + let messageBar = card.querySelector(".addon-card-message"); + let link = messageBar.querySelector("button"); + + let { message, type = "", linkText, linkUrl } = await getAddonMessageInfo( + addon + ); + + if (message) { + messageBar.querySelector("span").textContent = message; + messageBar.setAttribute("type", type); + if (linkText) { + link.textContent = linkText; + link.setAttribute("url", linkUrl); + } + } + + messageBar.hidden = !message; + link.hidden = !linkText; + } + + showPrefs() { + this.details.showPrefs(); + } + + expand() { + if (!this.children.length) { + this.expanded = true; + } else { + throw new Error("expand() is only supported before render()"); + } + } + + render() { + this.textContent = ""; + + let { addon } = this; + if (!addon) { + throw new Error("addon-card must be initialized with setAddon()"); + } + + let headingId = ExtensionCommon.makeWidgetId(`${addon.name}-heading`); + this.setAttribute("aria-labelledby", headingId); + this.setAttribute("addon-id", addon.id); + + this.card = importTemplate("card").firstElementChild; + + // Remove the toggle-disabled button(s) based on type. + if (addon.type != "theme") { + this.card.querySelector(".theme-enable-button").remove(); + } + if (addon.type != "extension") { + this.card.querySelector(".extension-enable-button").remove(); + } + + let nameContainer = this.card.querySelector(".addon-name-container"); + let headingLevel = this.expanded ? "h1" : "h3"; + let nameHeading = document.createElement(headingLevel); + nameHeading.classList.add("addon-name"); + if (!this.expanded) { + let name = document.createElement("a"); + name.classList.add("addon-name-link"); + name.href = `addons://detail/${addon.id}`; + nameHeading.appendChild(name); + this.addonNameEl = name; + } else { + this.addonNameEl = nameHeading; + } + nameContainer.prepend(nameHeading); + + let panelType = addon.type == "plugin" ? "plugin-options" : "addon-options"; + this.options = document.createElement(panelType); + this.options.render(); + this.card.appendChild(this.options); + this.optionsButton = this.card.querySelector(".more-options-button"); + + // Set the contents. + this.update(); + + let doneRenderPromise = Promise.resolve(); + if (this.expanded) { + if (!this.details) { + this.details = document.createElement("addon-details"); + } + this.details.setAddon(this.addon); + doneRenderPromise = this.details.render(); + + // If we're re-rendering we still need to append the details since the + // entire card was emptied at the beginning of the render. + this.card.appendChild(this.details); + } + + this.appendChild(this.card); + + if (this.expanded) { + requestAnimationFrame(() => this.optionsButton.focus()); + } + + // Return the promise of details rendering to wait on in DetailView. + return doneRenderPromise; + } + + sendEvent(name, detail) { + this.dispatchEvent(new CustomEvent(name, { detail })); + } + + recordActionEvent(action, value) { + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: getTelemetryViewName(this), + action, + addon: this.addon, + value, + }); + } + + /** + * AddonManager listener events. + */ + + onNewInstall(install) { + this.updateInstall = install; + this.sendEvent("update-found"); + } + + onInstallEnded(install) { + this.setAddon(install.addon); + } + + onInstallPostponed(install) { + this.updateInstall = install; + this.sendEvent("update-postponed"); + } + + onDisabled(addon) { + if (!this.reloading) { + this.update(); + } + } + + onEnabled(addon) { + this.reloading = false; + this.update(); + } + + onInstalled(addon) { + // When a temporary addon is reloaded, onInstalled is triggered instead of + // onEnabled. + this.reloading = false; + this.update(); + } + + onUninstalling() { + // Dispatch a remove event, the DetailView is listening for this to get us + // back to the list view when the current add-on is removed. + this.sendEvent("remove"); + } + + onUpdateModeChanged() { + this.update(); + } + + onPropertyChanged(addon, changed) { + if (this.details && changed.includes("applyBackgroundUpdates")) { + this.details.update(); + } else if (addon.type == "plugin" && changed.includes("userDisabled")) { + this.update(); + } + } + + /* Extension Permission change listener */ + onChangePermissions(data) { + let perms = data.added || data.removed; + let fname = data.added ? "add" : "remove"; + for (let permission of perms.permissions.concat(perms.origins)) { + let target = document.querySelector(`[permission-key="${permission}"]`); + if (target) { + target.parentNode.parentNode.classList[fname]("permission-checked"); + target.checked = !data.removed; + } + } + } +} +customElements.define("addon-card", AddonCard); + +/** + * A child element of `<recommended-addon-list>`. It should be initialized + * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been + * installed, and call `setAddon(null)` upon uninstall. + * + * let discoAddon = new DiscoAddonWrapper({ ... }); + * let card = document.createElement("recommended-addon-card"); + * card.setDiscoAddon(discoAddon); + * document.body.appendChild(card); + * + * AddonManager.getAddonsByID(discoAddon.id) + * .then(addon => card.setAddon(addon)); + */ +class RecommendedAddonCard extends HTMLElement { + /** + * @param {DiscoAddonWrapper} addon + * The details of the add-on that should be rendered in the card. + */ + setDiscoAddon(addon) { + this.addonId = addon.id; + + // Save the information so we can install. + this.discoAddon = addon; + + let card = importTemplate("card").firstElementChild; + let heading = card.querySelector(".addon-name-container"); + heading.textContent = ""; + heading.append(importTemplate("addon-name-container-in-disco-card")); + + this.setCardContent(card, addon); + if (addon.type != "theme") { + card + .querySelector(".addon-description") + .append(importTemplate("addon-description-in-disco-card")); + this.setCardDescription(card, addon); + } + this.registerButtons(card, addon); + + this.textContent = ""; + this.append(card); + + // We initially assume that the add-on is not installed. + this.setAddon(null); + } + + /** + * Fills in all static parts of the card. + * + * @param {HTMLElement} card + * The primary content of this card. + * @param {DiscoAddonWrapper} addon + */ + setCardContent(card, addon) { + // Set the icon. + if (addon.type == "theme") { + card.querySelector(".addon-icon").hidden = true; + } else { + card.querySelector(".addon-icon").src = AddonManager.getPreferredIconURL( + addon, + 32, + window + ); + } + + // Set the theme preview. + let preview = card.querySelector(".card-heading-image"); + if (addon.type == "theme") { + let screenshotUrl = getScreenshotUrlForAddon(addon); + if (screenshotUrl) { + preview.src = screenshotUrl; + preview.hidden = false; + } + } else { + preview.hidden = true; + } + + // Set the name. + card.querySelector(".disco-addon-name").textContent = addon.name; + + // Set the author name and link to AMO. + if (addon.creator) { + let authorInfo = card.querySelector(".disco-addon-author"); + document.l10n.setAttributes(authorInfo, "created-by-author", { + author: addon.creator.name, + }); + // This is intentionally a link to the add-on listing instead of the + // author page, because the add-on listing provides more relevant info. + authorInfo.querySelector("a").href = formatUTMParams( + "discopane-entry-link", + addon.amoListingUrl + ); + authorInfo.hidden = false; + } + } + + setCardDescription(card, addon) { + // Set the description. Note that this is the editorial description, not + // the add-on's original description that would normally appear on a card. + card.querySelector(".disco-description-main").textContent = + addon.editorialDescription; + + let hasStats = false; + if (addon.averageRating) { + hasStats = true; + card.querySelector("five-star-rating").rating = addon.averageRating; + } else { + card.querySelector("five-star-rating").hidden = true; + } + + if (addon.dailyUsers) { + hasStats = true; + let userCountElem = card.querySelector(".disco-user-count"); + document.l10n.setAttributes(userCountElem, "user-count", { + dailyUsers: addon.dailyUsers, + }); + } + + card.querySelector(".disco-description-statistics").hidden = !hasStats; + } + + registerButtons(card, addon) { + let installButton = card.querySelector("[action='install-addon']"); + if (addon.type == "theme") { + document.l10n.setAttributes(installButton, "install-theme-button"); + } else { + document.l10n.setAttributes(installButton, "install-extension-button"); + } + + this.addEventListener("click", this); + } + + handleEvent(event) { + let action = event.target.getAttribute("action"); + switch (action) { + case "install-addon": + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: getTelemetryViewName(this), + action: "installFromRecommendation", + addon: this.discoAddon, + }); + this.installDiscoAddon(); + break; + case "manage-addon": + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: getTelemetryViewName(this), + action: "manage", + addon: this.discoAddon, + }); + loadViewFn(`detail/${this.addonId}`); + break; + default: + if (event.target.matches(".disco-addon-author a[href]")) { + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + // Note: This is not "author" nor "homepage", because the link text + // is the author name, but the link URL the add-on's listing URL. + value: "discohome", + extra: { + view: getTelemetryViewName(this), + }, + }); + } + } + } + + async installDiscoAddon() { + let addon = this.discoAddon; + let url = addon.sourceURI.spec; + let install = await AddonManager.getInstallForURL(url, { + name: addon.name, + telemetryInfo: { source: "disco" }, + }); + // We are hosted in a <browser> in about:addons, but we can just use the + // main tab's browser since all of it is using the system principal. + let browser = window.docShell.chromeEventHandler; + AddonManager.installAddonFromWebpage( + "application/x-xpinstall", + browser, + Services.scriptSecurityManager.getSystemPrincipal(), + install + ); + } + + /** + * @param {AddonWrapper|null} addon + * The add-on that has been installed; null if it has been removed. + */ + setAddon(addon) { + let card = this.firstElementChild; + card.querySelector("[action='install-addon']").hidden = !!addon; + card.querySelector("[action='manage-addon']").hidden = !addon; + + this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing. + } +} +customElements.define("recommended-addon-card", RecommendedAddonCard); + +/** + * A list view for add-ons of a certain type. It should be initialized with the + * type of add-on to render and have section data set before being connected to + * the document. + * + * let list = document.createElement("addon-list"); + * list.type = "plugin"; + * list.setSections([{ + * headingId: "plugin-section-heading", + * filterFn: addon => !addon.isSystem, + * }]); + * document.body.appendChild(list); + */ +class AddonList extends HTMLElement { + constructor() { + super(); + this.sections = []; + this.pendingUninstallAddons = new Set(); + this._addonsToUpdate = new Set(); + this._userFocusListenersAdded = false; + } + + async connectedCallback() { + // Register the listener and get the add-ons, these operations should + // happpen as close to each other as possible. + this.registerListener(); + // Don't render again if we were rendered prior to being inserted. + if (!this.children.length) { + // Render the initial view. + this.render(); + } + } + + disconnectedCallback() { + // Remove content and stop listening until this is connected again. + this.textContent = ""; + this.removeListener(); + + // Process any pending uninstall related to this list. + for (const addon of this.pendingUninstallAddons) { + if (isPending(addon, "uninstall")) { + addon.uninstall(); + } + } + this.pendingUninstallAddons.clear(); + } + + /** + * Configure the sections in the list. + * + * @param {object[]} sections + * The options for the section. Each entry in the array should have: + * headingId: The fluent id for the section's heading. + * filterFn: A function that determines if an add-on belongs in + * the section. + */ + setSections(sections) { + this.sections = sections.map(section => Object.assign({}, section)); + } + + /** + * Set the add-on type for this list. This will be used to filter the add-ons + * that are displayed. + * + * @param {string} val The type to filter on. + */ + set type(val) { + this.setAttribute("type", val); + } + + get type() { + return this.getAttribute("type"); + } + + getSection(index) { + return this.sections[index].node; + } + + getCards(section) { + return section.querySelectorAll("addon-card"); + } + + getCard(addon) { + return this.querySelector(`addon-card[addon-id="${addon.id}"]`); + } + + getPendingUninstallBar(addon) { + return this.querySelector(`message-bar[addon-id="${addon.id}"]`); + } + + sortByFn(aAddon, bAddon) { + return aAddon.name.localeCompare(bAddon.name); + } + + async getAddons() { + if (!this.type) { + throw new Error(`type must be set to find add-ons`); + } + + // Find everything matching our type, null will find all types. + let type = this.type == "all" ? null : [this.type]; + let addons = await AddonManager.getAddonsByTypes(type); + + // Put the add-ons into the sections, an add-on goes in the first section + // that it matches the filterFn for. It might not go in any section. + let sectionedAddons = this.sections.map(() => []); + for (let addon of addons) { + let index = this.sections.findIndex(({ filterFn }) => filterFn(addon)); + if (index != -1) { + sectionedAddons[index].push(addon); + } else if (isPending(addon, "uninstall")) { + // A second tab may be opened on "about:addons" (or Firefox may + // have crashed) while there are still "pending uninstall" add-ons. + // Ensure to list them in the pendingUninstall message-bar-stack + // when the AddonList is initially rendered. + this.pendingUninstallAddons.add(addon); + } + } + + // Sort the add-ons in each section. + for (let section of sectionedAddons) { + section.sort(this.sortByFn); + } + + return sectionedAddons; + } + + createPendingUninstallStack() { + const stack = document.createElement("message-bar-stack"); + stack.setAttribute("class", "pending-uninstall"); + stack.setAttribute("reverse", ""); + return stack; + } + + addPendingUninstallBar(addon) { + const stack = this.pendingUninstallStack; + const mb = document.createElement("message-bar"); + mb.setAttribute("addon-id", addon.id); + mb.setAttribute("type", "generic"); + + const addonName = document.createElement("span"); + addonName.setAttribute("data-l10n-name", "addon-name"); + const message = document.createElement("span"); + message.append(addonName); + const undo = document.createElement("button"); + undo.setAttribute("action", "undo"); + undo.addEventListener("click", () => { + AMTelemetry.recordActionEvent({ + object: "aboutAddons", + view: getTelemetryViewName(this), + action: "undo", + addon, + }); + addon.cancelUninstall(); + }); + + document.l10n.setAttributes(message, "pending-uninstall-description", { + addon: addon.name, + }); + document.l10n.setAttributes(undo, "pending-uninstall-undo-button"); + + mb.append(message, undo); + stack.append(mb); + } + + removePendingUninstallBar(addon) { + const messagebar = this.getPendingUninstallBar(addon); + if (messagebar) { + messagebar.remove(); + } + } + + createSectionHeading(headingIndex) { + let { headingId } = this.sections[headingIndex]; + let heading = document.createElement("h2"); + heading.classList.add("list-section-heading"); + document.l10n.setAttributes(heading, headingId); + return heading; + } + + updateSectionIfEmpty(section) { + // The header is added before any add-on cards, so if there's only one + // child then it's the header. In that case we should empty out the section. + if (section.children.length == 1) { + section.textContent = ""; + } + } + + insertCardInto(card, sectionIndex) { + let section = this.getSection(sectionIndex); + let sectionCards = this.getCards(section); + + // If this is the first card in the section, create the heading. + if (!sectionCards.length) { + section.appendChild(this.createSectionHeading(sectionIndex)); + } + + // Find where to insert the card. + let insertBefore = Array.from(sectionCards).find( + otherCard => this.sortByFn(card.addon, otherCard.addon) < 0 + ); + // This will append if insertBefore is null. + section.insertBefore(card, insertBefore || null); + } + + addAddon(addon) { + // Only insert add-ons of the right type. + if (addon.type != this.type && this.type != "all") { + this.sendEvent("skip-add", "type-mismatch"); + return; + } + + let insertSection = this._addonSectionIndex(addon); + + // Don't add the add-on if it doesn't go in a section. + if (insertSection == -1) { + return; + } + + // Create and insert the card. + let card = document.createElement("addon-card"); + card.setAddon(addon); + this.insertCardInto(card, insertSection); + this.sendEvent("add", { id: addon.id }); + } + + sendEvent(name, detail) { + this.dispatchEvent(new CustomEvent(name, { detail })); + } + + removeAddon(addon) { + let card = this.getCard(addon); + if (card) { + let section = card.parentNode; + card.remove(); + this.updateSectionIfEmpty(section); + this.sendEvent("remove", { id: addon.id }); + } + } + + updateAddon(addon) { + if (!this.getCard(addon)) { + // Try to add the add-on right away. + this.addAddon(addon); + } else if (this._addonSectionIndex(addon) == -1) { + // Try to remove the add-on right away. + this._updateAddon(addon); + } else if (this.isUserFocused) { + // Queue up a change for when the focus is cleared. + this.updateLater(addon); + } else { + // Not currently focused, make the change now. + this.withCardAnimation(() => this._updateAddon(addon)); + } + } + + updateLater(addon) { + this._addonsToUpdate.add(addon); + this._addUserFocusListeners(); + } + + _addUserFocusListeners() { + if (this._userFocusListenersAdded) { + return; + } + + this._userFocusListenersAdded = true; + this.addEventListener("mouseleave", this); + this.addEventListener("hidden", this, true); + this.addEventListener("focusout", this); + } + + _removeUserFocusListeners() { + if (!this._userFocusListenersAdded) { + return; + } + + this.removeEventListener("mouseleave", this); + this.removeEventListener("hidden", this, true); + this.removeEventListener("focusout", this); + this._userFocusListenersAdded = false; + } + + get hasMenuOpen() { + return !!this.querySelector("panel-list[open]"); + } + + get isUserFocused() { + return this.matches(":hover, :focus-within") || this.hasMenuOpen; + } + + update() { + if (this._addonsToUpdate.size) { + this.withCardAnimation(() => { + for (let addon of this._addonsToUpdate) { + this._updateAddon(addon); + } + this._addonsToUpdate = new Set(); + }); + } + } + + _getChildCoords() { + let results = new Map(); + for (let child of this.querySelectorAll("addon-card")) { + results.set(child, child.getBoundingClientRect()); + } + return results; + } + + withCardAnimation(changeFn) { + if (shouldSkipAnimations()) { + changeFn(); + return; + } + + let origChildCoords = this._getChildCoords(); + + changeFn(); + + let newChildCoords = this._getChildCoords(); + let cards = this.querySelectorAll("addon-card"); + let transitionCards = []; + for (let card of cards) { + let orig = origChildCoords.get(card); + let moved = newChildCoords.get(card); + let changeY = moved.y - (orig || moved).y; + let cardEl = card.firstElementChild; + + if (changeY != 0) { + cardEl.style.transform = `translateY(${changeY * -1}px)`; + transitionCards.push(card); + } + } + requestAnimationFrame(() => { + for (let card of transitionCards) { + card.firstElementChild.style.transition = "transform 125ms"; + } + + requestAnimationFrame(() => { + for (let card of transitionCards) { + let cardEl = card.firstElementChild; + cardEl.style.transform = ""; + cardEl.addEventListener("transitionend", function handler(e) { + if (e.target == cardEl && e.propertyName == "transform") { + cardEl.style.transition = ""; + cardEl.removeEventListener("transitionend", handler); + } + }); + } + }); + }); + } + + _addonSectionIndex(addon) { + return this.sections.findIndex(s => s.filterFn(addon)); + } + + _updateAddon(addon) { + let card = this.getCard(addon); + if (card) { + let sectionIndex = this._addonSectionIndex(addon); + if (sectionIndex != -1) { + // Move the card, if needed. This will allow an animation between + // page sections and provides clearer events for testing. + if (card.parentNode.getAttribute("section") != sectionIndex) { + let { activeElement } = document; + let refocus = card.contains(activeElement); + let oldSection = card.parentNode; + this.insertCardInto(card, sectionIndex); + this.updateSectionIfEmpty(oldSection); + if (refocus) { + activeElement.focus(); + } + this.sendEvent("move", { id: addon.id }); + } + } else { + this.removeAddon(addon); + } + } + } + + renderSection(addons, index) { + let section = document.createElement("section"); + section.setAttribute("section", index); + + // Render the heading and add-ons if there are any. + if (addons.length) { + section.appendChild(this.createSectionHeading(index)); + + for (let addon of addons) { + let card = document.createElement("addon-card"); + card.setAddon(addon); + card.render(); + section.appendChild(card); + } + } + + return section; + } + + async render() { + this.textContent = ""; + + let sectionedAddons = await this.getAddons(); + + let frag = document.createDocumentFragment(); + + // Render the pending uninstall message-bar-stack. + this.pendingUninstallStack = this.createPendingUninstallStack(); + for (let addon of this.pendingUninstallAddons) { + this.addPendingUninstallBar(addon); + } + frag.appendChild(this.pendingUninstallStack); + + // Render the sections. + for (let i = 0; i < sectionedAddons.length; i++) { + this.sections[i].node = this.renderSection(sectionedAddons[i], i); + frag.appendChild(this.sections[i].node); + } + + // Make sure fluent has set all the strings before we render. This will + // avoid the height changing as strings go from 0 height to having text. + await document.l10n.translateFragment(frag); + this.appendChild(frag); + } + + registerListener() { + AddonManagerListenerHandler.addListener(this); + } + + removeListener() { + AddonManagerListenerHandler.removeListener(this); + } + + handleEvent(e) { + if (!this.isUserFocused || (e.type == "mouseleave" && !this.hasMenuOpen)) { + this._removeUserFocusListeners(); + this.update(); + } + } + + /** + * AddonManager listener events. + */ + + onOperationCancelled(addon) { + if ( + this.pendingUninstallAddons.has(addon) && + !isPending(addon, "uninstall") + ) { + this.pendingUninstallAddons.delete(addon); + this.removePendingUninstallBar(addon); + } + this.updateAddon(addon); + } + + onEnabled(addon) { + this.updateAddon(addon); + } + + onDisabled(addon) { + this.updateAddon(addon); + } + + onUninstalling(addon) { + if ( + isPending(addon, "uninstall") && + (this.type === "all" || addon.type === this.type) + ) { + this.pendingUninstallAddons.add(addon); + this.addPendingUninstallBar(addon); + this.updateAddon(addon); + } + } + + onInstalled(addon) { + if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) { + return; + } + this.addAddon(addon); + } + + onUninstalled(addon) { + this.pendingUninstallAddons.delete(addon); + this.removePendingUninstallBar(addon); + this.removeAddon(addon); + } +} +customElements.define("addon-list", AddonList); + +class RecommendedAddonList extends HTMLElement { + connectedCallback() { + if (this.isConnected) { + this.loadCardsIfNeeded(); + this.updateCardsWithAddonManager(); + } + AddonManagerListenerHandler.addListener(this); + } + + disconnectedCallback() { + AddonManagerListenerHandler.removeListener(this); + } + + get type() { + return this.getAttribute("type"); + } + + /** + * Set the add-on type for this list. This will be used to filter the add-ons + * that are displayed. + * + * Must be set prior to the first render. + * + * @param {string} val The type to filter on. + */ + set type(val) { + this.setAttribute("type", val); + } + + get hideInstalled() { + return this.hasAttribute("hide-installed"); + } + + /** + * Set whether installed add-ons should be hidden from the list. If false, + * installed add-ons will be shown with a "Manage" button, otherwise they + * will be hidden. + * + * Must be set prior to the first render. + * + * @param {boolean} val Whether to show installed add-ons. + */ + set hideInstalled(val) { + this.toggleAttribute("hide-installed", val); + } + + getCardById(addonId) { + for (let card of this.children) { + if (card.addonId === addonId) { + return card; + } + } + return null; + } + + setAddonForCard(card, addon) { + card.setAddon(addon); + + let wasHidden = card.hidden; + card.hidden = this.hideInstalled && addon; + + if (wasHidden != card.hidden) { + let eventName = card.hidden ? "card-hidden" : "card-shown"; + this.dispatchEvent(new CustomEvent(eventName, { detail: { card } })); + } + } + + /** + * Whether the client ID should be preferred. This is disabled for themes + * since they don't use the telemetry data and don't show the TAAR notice. + */ + get preferClientId() { + return !this.type || this.type == "extension"; + } + + async updateCardsWithAddonManager() { + let cards = Array.from(this.children); + let addonIds = cards.map(card => card.addonId); + let addons = await AddonManager.getAddonsByIDs(addonIds); + for (let [i, card] of cards.entries()) { + let addon = addons[i]; + this.setAddonForCard(card, addon); + if (addon) { + // Already installed, move card to end. + this.append(card); + } + } + } + + async loadCardsIfNeeded() { + // Use promise as guard. Also used by tests to detect when load completes. + if (!this.cardsReady) { + this.cardsReady = this._loadCards(); + } + return this.cardsReady; + } + + async _loadCards() { + let recommendedAddons; + try { + recommendedAddons = await DiscoveryAPI.getResults(this.preferClientId); + } catch (e) { + return; + } + + let frag = document.createDocumentFragment(); + for (let addon of recommendedAddons) { + if (this.type && addon.type != this.type) { + continue; + } + let card = document.createElement("recommended-addon-card"); + card.setDiscoAddon(addon); + frag.append(card); + } + this.append(frag); + await this.updateCardsWithAddonManager(); + } + + /** + * AddonManager listener events. + */ + + onInstalled(addon) { + let card = this.getCardById(addon.id); + if (card) { + this.setAddonForCard(card, addon); + } + } + + onUninstalled(addon) { + let card = this.getCardById(addon.id); + if (card) { + this.setAddonForCard(card, null); + } + } +} +customElements.define("recommended-addon-list", RecommendedAddonList); + +class TaarMessageBar extends HTMLElement { + connectedCallback() { + this.hidden = + Services.prefs.getBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, false) || + !DiscoveryAPI.clientIdDiscoveryEnabled; + if (this.childElementCount == 0 && !this.hidden) { + this.appendChild(importTemplate("taar-notice")); + this.addEventListener("click", this); + this.messageBar = this.querySelector("message-bar"); + this.messageBar.addEventListener("message-bar:user-dismissed", this); + } + } + + handleEvent(e) { + if ( + e.type == "click" && + e.target.getAttribute("action") == "notice-learn-more" + ) { + // The element is a button but opens a URL, so record as link. + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value: "disconotice", + extra: { + view: getTelemetryViewName(this), + }, + }); + windowRoot.ownerGlobal.openTrustedLinkIn( + SUPPORT_URL + "personalized-addons", + "tab" + ); + } else if (e.type == "message-bar:user-dismissed") { + Services.prefs.setBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, true); + } + } +} +customElements.define("taar-notice", TaarMessageBar); + +class RecommendedFooter extends HTMLElement { + connectedCallback() { + if (this.childElementCount == 0) { + this.appendChild(importTemplate("recommended-footer")); + this.querySelector( + ".privacy-policy-link" + ).href = Services.prefs.getStringPref(PREF_PRIVACY_POLICY_URL); + this.addEventListener("click", this); + } + } + + handleEvent(event) { + let action = event.target.getAttribute("action"); + switch (action) { + case "open-amo": + openAmoInTab(this); + break; + } + } +} +customElements.define("recommended-footer", RecommendedFooter, { + extends: "footer", +}); + +class RecommendedThemesFooter extends HTMLElement { + connectedCallback() { + if (this.childElementCount == 0) { + this.appendChild(importTemplate("recommended-themes-footer")); + let themeRecommendationRow = this.querySelector(".theme-recommendation"); + let themeRecommendationUrl = Services.prefs.getStringPref( + PREF_THEME_RECOMMENDATION_URL + ); + if (themeRecommendationUrl) { + themeRecommendationRow.querySelector("a").href = themeRecommendationUrl; + } + themeRecommendationRow.hidden = !themeRecommendationUrl; + this.addEventListener("click", this); + } + } + + handleEvent(event) { + let action = event.target.getAttribute("action"); + switch (action) { + case "open-amo": + openAmoInTab(this); + break; + } + } +} +customElements.define("recommended-themes-footer", RecommendedThemesFooter, { + extends: "footer", +}); + +/** + * This element will handle showing recommendations with a + * <recommended-addon-list> and a <footer>. The footer will be hidden until + * the <recommended-addon-list> is done making its request so the footer + * doesn't move around. + * + * Subclass this element to use it and define a `template` property to pull + * the template from. Expected template: + * + * <h1>My extra content can go here.</h1> + * <p>It can be anything but a footer or recommended-addon-list.</p> + * <recommended-addon-list></recommended-addon-list> + * <footer>My custom footer</footer> + */ +class RecommendedSection extends HTMLElement { + connectedCallback() { + if (this.childElementCount == 0) { + this.render(); + } + } + + get list() { + return this.querySelector("recommended-addon-list"); + } + + get footer() { + return this.querySelector("footer"); + } + + render() { + this.appendChild(importTemplate(this.template)); + + // Hide footer until the cards are loaded, to prevent the content from + // suddenly shifting when the user attempts to interact with it. + let { footer } = this; + footer.hidden = true; + this.list.loadCardsIfNeeded().finally(() => { + footer.hidden = false; + }); + } +} + +class RecommendedExtensionsSection extends RecommendedSection { + get template() { + return "recommended-extensions-section"; + } +} +customElements.define( + "recommended-extensions-section", + RecommendedExtensionsSection +); + +class RecommendedThemesSection extends RecommendedSection { + get template() { + return "recommended-themes-section"; + } +} +customElements.define("recommended-themes-section", RecommendedThemesSection); + +class DiscoveryPane extends RecommendedSection { + get template() { + return "discopane"; + } +} +customElements.define("discovery-pane", DiscoveryPane); + +class ListView { + constructor({ param, root }) { + this.type = param; + this.root = root; + } + + async render() { + if (!(this.type in AddonManager.addonTypes)) { + replaceWithDefaultViewFn(); + return; + } + + let frag = document.createDocumentFragment(); + + let list = document.createElement("addon-list"); + list.type = this.type; + list.setSections([ + { + headingId: this.type + "-enabled-heading", + filterFn: addon => + !addon.hidden && addon.isActive && !isPending(addon, "uninstall"), + }, + { + headingId: this.type + "-disabled-heading", + filterFn: addon => + !addon.hidden && !addon.isActive && !isPending(addon, "uninstall"), + }, + ]); + frag.appendChild(list); + + // Show recommendations for themes and extensions. + if ( + LIST_RECOMMENDATIONS_ENABLED && + (this.type == "extension" || this.type == "theme") + ) { + let elementName = + this.type == "extension" + ? "recommended-extensions-section" + : "recommended-themes-section"; + let recommendations = document.createElement(elementName); + // Start loading the recommendations. This can finish after the view load + // event is sent. + recommendations.render(); + frag.appendChild(recommendations); + } + + await list.render(); + + this.root.textContent = ""; + this.root.appendChild(frag); + } +} + +class DetailView { + constructor({ param, root }) { + let [id, selectedTab] = param.split("/"); + this.id = id; + this.selectedTab = selectedTab; + this.root = root; + } + + async render() { + let addon = await AddonManager.getAddonByID(this.id); + + if (!addon) { + replaceWithDefaultViewFn(); + return; + } + + let card = document.createElement("addon-card"); + + // Ensure the category for this add-on type is selected. + categoriesBox.selectType(addon.type); + + // Go back to the list view when the add-on is removed. + card.addEventListener("remove", () => loadViewFn(`list/${addon.type}`)); + + card.setAddon(addon); + card.expand(); + await card.render(); + if ( + this.selectedTab === "preferences" && + (await isAddonOptionsUIAllowed(addon)) + ) { + card.showPrefs(); + } + + this.root.textContent = ""; + this.root.appendChild(card); + } +} + +class UpdatesView { + constructor({ param, root }) { + this.root = root; + this.param = param; + } + + async render() { + let list = document.createElement("addon-list"); + list.type = "all"; + if (this.param == "available") { + list.setSections([ + { + headingId: "available-updates-heading", + filterFn: addon => { + // Filter the addons visible in the updates view using the same + // criteria that is being used to compute the counter on the + // available updates category button badge. + const install = getUpdateInstall(addon); + return install && isManualUpdate(install) && !install.installed; + }, + }, + ]); + } else if (this.param == "recent") { + list.sortByFn = (a, b) => { + if (a.updateDate > b.updateDate) { + return -1; + } + if (a.updateDate < b.updateDate) { + return 1; + } + return 0; + }; + let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN; + list.setSections([ + { + headingId: "recent-updates-heading", + filterFn: addon => + !addon.hidden && addon.updateDate && addon.updateDate > updateLimit, + }, + ]); + } else { + throw new Error(`Unknown updates view ${this.param}`); + } + + await list.render(); + this.root.textContent = ""; + this.root.appendChild(list); + } +} + +class DiscoveryView { + render() { + let discopane = document.createElement("discovery-pane"); + discopane.render(); + return discopane; + } +} + +// Generic view management. +let mainEl = null; +let addonPageHeader = null; +let categoriesBox = null; + +/** + * The name of the view for an element, used for telemetry. + * + * @param {Element} el The element to find the view from. A parent of the + * element must define a current-view property. + * @returns {string} The current view name. + */ +function getTelemetryViewName(el) { + let root = + el.closest("[current-view]") || document.querySelector("[current-view]"); + return root.getAttribute("current-view"); +} + +/** + * @param {Element} el The button element. + */ +function openAmoInTab(el) { + // The element is a button but opens a URL, so record as link. + AMTelemetry.recordLinkEvent({ + object: "aboutAddons", + value: "discomore", + extra: { + view: getTelemetryViewName(el), + }, + }); + let amoUrl = Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ); + amoUrl = formatUTMParams("find-more-link-bottom", amoUrl); + windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab"); +} + +/** + * Helper for saving and restoring the scroll offsets when a previously loaded + * view is accessed again. + */ +var ScrollOffsets = { + _key: null, + _offsets: new Map(), + canRestore: true, + + setView(historyEntryId) { + this._key = historyEntryId; + this.canRestore = true; + }, + + getPosition() { + if (!this.canRestore) { + return { top: 0, left: 0 }; + } + let { scrollTop: top, scrollLeft: left } = document.documentElement; + return { top, left }; + }, + + save() { + if (this._key) { + this._offsets.set(this._key, this.getPosition()); + } + }, + + restore() { + let { top = 0, left = 0 } = this._offsets.get(this._key) || {}; + window.scrollTo({ top, left, behavior: "auto" }); + }, +}; + +/** + * Called from extensions.js once, when about:addons is loading. + */ +function initialize(opts) { + mainEl = document.getElementById("main"); + addonPageHeader = document.getElementById("page-header"); + categoriesBox = document.querySelector("categories-box"); + + loadViewFn = opts.loadViewFn; + replaceWithDefaultViewFn = opts.replaceWithDefaultViewFn; + + if (opts.shouldLoadInitialView) { + opts.loadInitialViewFn(categoriesBox.initialViewId); + } + categoriesBox.initialize(); + + AddonManagerListenerHandler.startup(); + + window.addEventListener( + "unload", + () => { + // Clear out the document so the disconnectedCallback will trigger + // properly and all of the custom elements can cleanup. + document.body.textContent = ""; + AddonManagerListenerHandler.shutdown(); + }, + { once: true } + ); +} + +/** + * Called from extensions.js to load a view. The view's render method should + * resolve once the view has been updated to conform with other about:addons + * views. + */ +async function show(type, param, { historyEntryId }) { + let container = document.createElement("div"); + container.setAttribute("current-view", type); + addonPageHeader.setViewInfo({ type, param }); + categoriesBox.select(`addons://${type}/${param}`); + if (type == "list") { + await new ListView({ param, root: container }).render(); + } else if (type == "detail") { + await new DetailView({ + param, + root: container, + }).render(); + } else if (type == "discover") { + let discoverView = new DiscoveryView(); + let elem = discoverView.render(); + await document.l10n.translateFragment(elem); + container.append(elem); + } else if (type == "updates") { + await new UpdatesView({ param, root: container }).render(); + } else if (type == "shortcuts") { + // Force the extension category to be selected, in the case of a reload, + // restart, or if the view was opened from another category's page. + categoriesBox.selectType("extension"); + let view = document.createElement("addon-shortcuts"); + await view.render(); + await document.l10n.translateFragment(view); + container.appendChild(view); + } else { + console.warn(`No view for ${type} ${param}, switching to default`); + replaceWithDefaultViewFn(); + } + + ScrollOffsets.save(); + ScrollOffsets.setView(historyEntryId); + mainEl.textContent = ""; + mainEl.appendChild(container); + + // Most content has been rendered at this point. The only exception are + // recommendations in the discovery pane and extension/theme list, because + // they rely on remote data. If loaded before, then these may be rendered + // within one tick, so wait a frame before restoring scroll offsets. + return new Promise(resolve => { + window.requestAnimationFrame(() => { + ScrollOffsets.restore(); + resolve(); + }); + }); +} + +function hide() { + ScrollOffsets.save(); + ScrollOffsets.setView(null); + mainEl.textContent = ""; +} diff --git a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js new file mode 100644 index 0000000000..73d23942cf --- /dev/null +++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js @@ -0,0 +1,289 @@ +/* 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.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "WEBEXT_PERMISSION_PROMPTS", + "extensions.webextPermissionPrompts", + false +); + +ChromeUtils.defineModuleGetter( + this, + "Extension", + "resource://gre/modules/Extension.jsm" +); + +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) { + if (!WEBEXT_PERMISSION_PROMPTS) { + return; + } + + 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 (!WEBEXT_PERMISSION_PROMPTS || !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..16f84d456c --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-frame.html @@ -0,0 +1,144 @@ +<!-- 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> + <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> + </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> + </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..f55c1b747a --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-panel.css @@ -0,0 +1,252 @@ +/* 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; + + --radio-image-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E %3Ccircle cx='8' cy='8' r='4' fill='%23fff'/%3E %3C/svg%3E"); + --radio-size: 16px; + + --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; + + --input-radio-border: var(--in-content-box-border-color); + --input-radio-background: var(--grey-90-a10); + --input-radio-background-hover: var(--grey-90-a20); + --input-radio-background-active: var(--grey-90-a30); + --input-radio-background-selected: var(--blue-60); + --input-radio-background-selected-hover: var(--blue-70); + --input-radio-background-selected-active: var(--blue-80); + --input-radio-focus-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} + +@media (prefers-color-scheme: dark) { + :root { + --input-radio-background: #202023; + --input-radio-background-hover: #303033; + --input-radio-background-active: #404044; + } +} + +/* 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, fill-opacity; + color: inherit !important; + fill: currentColor; + fill-opacity: 0; + 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(--in-content-deemphasized-text); + 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 { + width: 100%; + line-height: var(--line-height); + font-size: var(--subtitle-font-size); + font-weight: var(--note-font-weight); +} + +ul.abuse-report-reasons > li > label { + display: grid; + grid-template-columns: var(--list-radio-column-size) auto; + grid-template-rows: 50% auto; +} + +ul.abuse-report-reasons > li > label > [type=radio] { + grid-column: 1; +} + +ul.abuse-report-reasons > li > label > span { + grid-column: 2; +} + +ul.abuse-report-reasons > li > label > span:nth-child(2) { + padding-top: 2px; +} + +.abuse-report-contents [type=radio] { + appearance: none; + height: var(--radio-size); + width: var(--radio-size); + border-radius: 100%; + border: 1px solid var(--input-radio-border); + background-color: var(--input-radio-background); + margin-inline-start: 4px; + margin-inline-end: 4px; +} + +.abuse-report-contents [type=radio]:focus { + border: none; + box-shadow: var(--input-radio-focus-shadow); +} + +.abuse-report-contents label:hover [type=radio]:not(:active), +.abuse-report-contents [type=radio]:hover { + background-color: var(--input-radio-background-hover); +} + +.abuse-report-contents [type=radio]:active { + background-color: var(--input-radio-background-active); +} + +.abuse-report-contents [type=radio]:checked { + background-image: var(--radio-image-url); + background-color: var(--input-radio-background-selected); + background-position: center center; +} + +.abuse-report-contents label:hover [type=radio]:checked:not(:active), +.abuse-report-contents [type=radio]:checked:hover { + background-color: var(--input-radio-background-selected-hover); +} + +.abuse-report-contents [type=radio]:checked:active { + background-color: var(--input-radio-background-selected-active); +} + +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..9ea15955da --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-report-panel.js @@ -0,0 +1,865 @@ +/* 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.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); + +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 hideOnThemeType = addonType => addonType === "theme"; + +// 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: hideOnThemeType, + }, + spam: { + isExampleHidden: showOnAnyType, + isReasonHidden: showOnAnyType, + }, + settings: { + hasSuggestions: true, + isExampleHidden: hideOnAnyType, + isReasonHidden: hideOnThemeType, + }, + deceptive: { + isExampleHidden: showOnAnyType, + isReasonHidden: showOnAnyType, + }, + broken: { + hasAddonTypeL10nId: true, + hasAddonTypeSuggestionTemplate: true, + hasSuggestions: true, + isExampleHidden: hideOnThemeType, + isReasonHidden: showOnAnyType, + requiresSupportURL: true, + }, + policy: { + hasSuggestions: true, + isExampleHidden: hideOnAnyType, + isReasonHidden: showOnAnyType, + }, + unwanted: { + isExampleHidden: showOnAnyType, + isReasonHidden: hideOnThemeType, + }, + 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-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.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, + _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 (!["extension", "theme"].includes(this.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 && this._report.addon; + } + + get addonId() { + return this.addon && this.addon.id; + } + + get addonName() { + return this.addon && this.addon.name; + } + + get addonType() { + return this.addon && this.addon.type; + } + + get addonCreator() { + return this.addon && this.addon.creator; + } + + get homepageURL() { + const { addon } = this; + return (addon && 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 && this.addonCreator.name) || ""; + } + + get authorURL() { + return (this.addonCreator && this.addonCreator.url) || ""; + } + + get iconURL() { + return this.addon && this.addon.iconURL; + } + + get supportURL() { + return (this.addon && this.addon.supportURL) || this.homepageURL || ""; + } + + 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..5816ab7218 --- /dev/null +++ b/toolkit/mozapps/extensions/content/abuse-reports.js @@ -0,0 +1,310 @@ +/* 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.import( + "resource://gre/modules/AbuseReporter.jsm" +); + +// 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) { + 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); + + document.l10n.setAttributes( + messageEl, + getMessageL10n(barInfo.addonTypeSuffix ? `${id}-${addonType}` : 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}-${addonType}` + : 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/blocklist.js b/toolkit/mozapps/extensions/content/blocklist.js new file mode 100644 index 0000000000..a852d3c627 --- /dev/null +++ b/toolkit/mozapps/extensions/content/blocklist.js @@ -0,0 +1,119 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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"; + +/* exported init, finish */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var gArgs; + +function init() { + var hasHardBlocks = false; + var hasSoftBlocks = false; + gArgs = window.arguments[0].wrappedJSObject; + + document.addEventListener("dialogaccept", function() { + finish(true); + }); + document.addEventListener("dialogcancel", function() { + finish(false); + }); + + // NOTE: We use strings from the "updates.properties" bundleset to change the + // text on the "Cancel" button to "Restart Later". (bug 523784) + let bundle = Services.strings.createBundle( + "chrome://mozapps/locale/update/updates.properties" + ); + let cancelButton = document + .getElementById("BlocklistDialog") + .getButton("cancel"); + cancelButton.setAttribute( + "label", + bundle.GetStringFromName("restartLaterButton") + ); + cancelButton.setAttribute( + "accesskey", + bundle.GetStringFromName("restartLaterButton.accesskey") + ); + + var richlist = document.getElementById("addonList"); + var list = gArgs.list; + list.sort((a, b) => String(a.name).localeCompare(b.name)); + for (let listItem of list) { + let item = document.createXULElement("richlistitem"); + + const icon = document.createXULElement("image"); + icon.src = listItem.icon; + + const container = document.createXULElement("vbox"); + container.flex = 1; + + const nameVersion = document.createXULElement("hbox"); + nameVersion.className = "addon-name-version"; + + const name = document.createXULElement("label"); + name.className = "addonName"; + name.value = listItem.name; + name.crop = "end"; + const version = document.createXULElement("label"); + version.value = listItem.version; + + nameVersion.append(name, version); + + const fragment = document.createXULElement("hbox"); + fragment.setAttribute("pack", "end"); + + if (listItem.blocked) { + const label = document.createXULElement("label"); + label.className = "blockedLabel"; + label.setAttribute("data-l10n-id", "blocklist-blocked"); + fragment.appendChild(label); + hasHardBlocks = true; + } else { + const checkbox = document.createXULElement("checkbox"); + checkbox.className = "disableCheckbox"; + checkbox.setAttribute("data-l10n-id", "blocklist-checkbox"); + checkbox.checked = true; + fragment.appendChild(checkbox); + hasSoftBlocks = true; + } + + container.append(nameVersion, fragment); + item.append(icon, container); + richlist.appendChild(item); + } + + if (hasHardBlocks && hasSoftBlocks) { + document.getElementById("bothMessage").hidden = false; + } else if (hasHardBlocks) { + document.getElementById("hardBlockMessage").hidden = false; + } else { + document.getElementById("softBlockMessage").hidden = false; + } + + var link = document.getElementById("moreInfo"); + if (list.length == 1 && list[0].url) { + link.setAttribute("href", list[0].url); + } else { + var url = Services.urlFormatter.formatURLPref( + "extensions.blocklist.detailsURL" + ); + link.setAttribute("href", url); + } +} + +function finish(shouldRestartNow) { + gArgs.restart = shouldRestartNow; + var list = gArgs.list; + var items = document.getElementById("addonList").childNodes; + for (let i = 0; i < list.length; i++) { + if (!list[i].blocked) { + list[i].disable = items[i].querySelector(".disableCheckbox").checked; + } + } +} diff --git a/toolkit/mozapps/extensions/content/blocklist.xhtml b/toolkit/mozapps/extensions/content/blocklist.xhtml new file mode 100644 index 0000000000..68d0004044 --- /dev/null +++ b/toolkit/mozapps/extensions/content/blocklist.xhtml @@ -0,0 +1,51 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://mozapps/skin/extensions/blocklist.css"?> + +<!DOCTYPE window> + +<window windowtype="Addons:Blocklist" + data-l10n-id="blocklist-window" + data-l10n-attrs="title,style" + align="stretch" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="init();" + > + +<dialog id="BlocklistDialog" + buttons="accept,cancel" + buttonidaccept="blocklist-accept"> + +<linkset> + <html:link rel="localization" href="branding/brand.ftl"/> + <html:link rel="localization" href="toolkit/extensions/blocklist.ftl"/> +</linkset> + + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://mozapps/content/extensions/blocklist.js"/> + + <hbox align="stretch" flex="1"> + <vbox pack="start"> + <image class="error-icon"/> + </vbox> + <vbox flex="1"> + <label data-l10n-id="blocklist-label-summary" /> + <separator class="thin"/> + <richlistbox id="addonList" flex="1" style="-moz-user-focus: none;"/> + <separator class="thin"/> + <description id="bothMessage" class="bold" hidden="true" data-l10n-id="blocklist-soft-and-hard"/> + <description id="hardBlockMessage" class="bold" hidden="true" data-l10n-id="blocklist-hard-blocked"/> + <description id="softBlockMessage" class="bold" hidden="true" data-l10n-id="blocklist-soft-blocked"/> + <hbox pack="start"> + <label id="moreInfo" data-l10n-attrs="blocklist-more-information" is="text-link"/> + </hbox> + </vbox> + </hbox> +</dialog> +</window> diff --git a/toolkit/mozapps/extensions/content/default-theme.svg b/toolkit/mozapps/extensions/content/default-theme.svg new file mode 100644 index 0000000000..b8b6ef29b2 --- /dev/null +++ b/toolkit/mozapps/extensions/content/default-theme.svg @@ -0,0 +1,17 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="664" height="90" fill="#0C0C0D"> + <path fill="#202340" d="M0 0h664v90H0z"/> + <path fill="#F9F9FA" d="M28 35.717V.5L156 0v35.217h508V90H0V35.217z"/> + <path fill="#0A84FF" d="M28 0h128v5H28z"/> + <rect width="76" height="5" x="54" y="18" fill="#3D3D3D" rx="2.5"/> + <rect width="76" height="5" x="182" y="18" fill="#F9F9FA" rx="2.5"/> + <rect width="533" height="29" x="82.5" y="47.5" fill="#FFF" stroke="#ADADB3" stroke-opacity=".2" rx="4"/> + <rect width="430" height="5" x="96" y="61" rx="2.5"/> + <circle cx="27" cy="63" r="7"/> + <circle cx="55" cy="63" r="7"/> + <rect width="18" height="2.667" x="630" y="54" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="60.667" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="67" rx="1.333"/> +</svg> 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/extensions.js b/toolkit/mozapps/extensions/content/extensions.js new file mode 100644 index 0000000000..10e28e6980 --- /dev/null +++ b/toolkit/mozapps/extensions/content/extensions.js @@ -0,0 +1,300 @@ +/* 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 ../../../content/customElements.js */ +/* import-globals-from aboutaddonsCommon.js */ +/* exported loadView */ + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AMTelemetry", + "resource://gre/modules/AddonManager.jsm" +); + +document.addEventListener("load", initialize, true); +window.addEventListener("unload", shutdown); + +var gPendingInitializations = 1; +Object.defineProperty(this, "gIsInitializing", { + get: () => gPendingInitializations > 0, +}); + +function initialize(event) { + document.removeEventListener("load", initialize, true); + + // Support focusing the search bar from the XUL document. + document.addEventListener("keypress", e => { + getHtmlBrowser() + .contentDocument.querySelector("search-addons") + .handleEvent(e); + }); + + gViewController.initialize(); + Services.obs.addObserver(sendEMPong, "EM-ping"); + Services.obs.notifyObservers(window, "EM-loaded"); + + // If the initial view has already been selected (by a call to loadView from + // the above notifications) then bail out now + if (gViewController.initialViewSelected) { + return; + } + + // If there is a history state to restore then use that + if (history.state) { + gViewController.updateState(history.state); + } +} + +function notifyInitialized() { + if (!gIsInitializing) { + return; + } + + gPendingInitializations--; + if (!gIsInitializing) { + var event = document.createEvent("Events"); + event.initEvent("Initialized", true, true); + document.dispatchEvent(event); + } +} + +function shutdown() { + Services.obs.removeObserver(sendEMPong, "EM-ping"); +} + +function sendEMPong(aSubject, aTopic, aData) { + Services.obs.notifyObservers(window, "EM-pong"); +} + +async function recordViewTelemetry(param) { + let type; + let addon; + + if ( + param in AddonManager.addonTypes || + ["recent", "available"].includes(param) + ) { + type = param; + } else if (param) { + let id = param.replace("/preferences", ""); + addon = await AddonManager.getAddonByID(id); + } + + let { currentViewId } = gViewController; + let viewType = gViewController.parseViewId(currentViewId)?.type; + AMTelemetry.recordViewEvent({ + view: viewType || "other", + addon, + type, + }); +} + +// Used by external callers to load a specific view into the manager +function loadView(aViewId) { + if (!gViewController.initialViewSelected) { + // The caller opened the window and immediately loaded the view so it + // should be the initial history entry + + gViewController.loadInitialView(aViewId); + } else { + gViewController.loadView(aViewId); + } +} + +var gViewController = { + defaultViewId: "addons://discover/", + currentViewId: "", + 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), + initialViewSelected: false, + + initialize() { + if (!isDiscoverEnabled()) { + this.defaultViewId = "addons://list/extension"; + } + + gCategories.initialize(); + + window.addEventListener("popstate", e => { + this.updateState(e.state); + }); + }, + + updateState(state) { + this.loadViewInternal(state.view, state.previousView, state); + }, + + parseViewId(aViewId) { + var matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/; + var [, viewType, viewParam] = aViewId.match(matchRegex) || []; + return { type: viewType, param: decodeURIComponent(viewParam) }; + }, + + loadView(aViewId) { + if (aViewId == this.currentViewId) { + return; + } + + var state = { + view: aViewId, + previousView: this.currentViewId, + historyEntryId: ++this.nextHistoryEntryId, + }; + history.pushState(state, ""); + this.loadViewInternal(aViewId, this.currentViewId, state); + }, + + // Replaces the existing view with a new one, rewriting the current history + // entry to match. + replaceView(aViewId) { + if (aViewId == this.currentViewId) { + return; + } + + var state = { + view: aViewId, + previousView: null, + historyEntryId: ++this.nextHistoryEntryId, + }; + history.replaceState(state, ""); + this.loadViewInternal(aViewId, null, state); + }, + + loadInitialView(aViewId) { + var state = { + view: aViewId, + previousView: null, + historyEntryId: ++this.nextHistoryEntryId, + }; + history.replaceState(state, ""); + + this.loadViewInternal(aViewId, null, state); + notifyInitialized(); + }, + + loadViewInternal(aViewId, aPreviousView, aState) { + const view = this.parseViewId(aViewId); + const viewTypes = ["shortcuts", "list", "detail", "updates", "discover"]; + + if (!view.type || !viewTypes.includes(view.type)) { + throw Components.Exception("Invalid view: " + view.type); + } + + if (aViewId != aPreviousView) { + promiseHtmlBrowserLoaded() + .then(browser => browser.contentWindow.hide()) + .catch(err => Cu.reportError(err)); + } + + this.currentViewId = aViewId; + this.isLoading = true; + + recordViewTelemetry(view.param); + + if (aViewId != aPreviousView) { + promiseHtmlBrowserLoaded() + .then(browser => + browser.contentWindow.show(view.type, view.param, aState) + ) + .then(() => { + this.isLoading = false; + + var event = document.createEvent("Events"); + event.initEvent("ViewChanged", true, true); + document.dispatchEvent(event); + }); + } + + this.initialViewSelected = true; + }, +}; + +var gCategories = { + initialize() { + gPendingInitializations++; + promiseHtmlBrowserLoaded().then(async browser => { + await browser.contentWindow.customElements.whenDefined("categories-box"); + let categoriesBox = browser.contentDocument.getElementById("categories"); + await categoriesBox.promiseInitialized; + notifyInitialized(); + }); + }, +}; + +const htmlViewOpts = { + loadViewFn(view) { + let viewId = view.startsWith("addons://") ? view : `addons://${view}`; + gViewController.loadView(viewId); + }, + loadInitialViewFn(viewId) { + gViewController.loadInitialView(viewId); + }, + replaceWithDefaultViewFn() { + gViewController.replaceView(gViewController.defaultViewId); + }, + get shouldLoadInitialView() { + // Let the HTML document load the view if `loadView` hasn't been called + // externally and we don't have history to refresh from. + return !gViewController.currentViewId && !window.history.state; + }, +}; + +// View wrappers for the HTML version of about:addons. These delegate to an +// HTML browser that renders the actual views. +let htmlBrowser; +let _htmlBrowserLoaded; +function getHtmlBrowser() { + if (!htmlBrowser) { + gPendingInitializations++; + htmlBrowser = document.getElementById("html-view-browser"); + htmlBrowser.loadURI( + "chrome://mozapps/content/extensions/aboutaddons.html", + { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + _htmlBrowserLoaded = new Promise(resolve => + htmlBrowser.addEventListener("load", function loadListener() { + if (htmlBrowser.contentWindow.location.href != "about:blank") { + htmlBrowser.removeEventListener("load", loadListener); + resolve(); + } + }) + ).then(() => { + htmlBrowser.contentWindow.initialize(htmlViewOpts); + notifyInitialized(); + }); + } + return htmlBrowser; +} + +async function promiseHtmlBrowserLoaded() { + // Call getHtmlBrowser() first to ensure _htmlBrowserLoaded has been defined. + let browser = getHtmlBrowser(); + await _htmlBrowserLoaded; + return browser; +} + +// Helper method exported into the about:addons global, used to open the +// abuse report panel from outside of the about:addons page +// (e.g. triggered from the browserAction context menu). +window.openAbuseReport = ({ addonId, reportEntryPoint }) => { + promiseHtmlBrowserLoaded().then(browser => { + browser.contentWindow.openAbuseReport({ + addonId, + reportEntryPoint, + }); + }); +}; diff --git a/toolkit/mozapps/extensions/content/extensions.xhtml b/toolkit/mozapps/extensions/content/extensions.xhtml new file mode 100644 index 0000000000..09c7d80b81 --- /dev/null +++ b/toolkit/mozapps/extensions/content/extensions.xhtml @@ -0,0 +1,23 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xhtml="http://www.w3.org/1999/xhtml" + csp="default-src chrome:; frame-src chrome:; object-src 'none'" + id="addons-page" data-l10n-id="addons-window" + role="application" windowtype="Addons:Manager"> + + <xhtml:link rel="shortcut icon" + href="chrome://mozapps/skin/extensions/extension.svg"/> + <linkset> + <xhtml:link rel="localization" href="branding/brand.ftl"/> + <xhtml:link rel="localization" href="toolkit/about/aboutAddons.ftl"/> + </linkset> + + <script src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"/> + <script src="chrome://mozapps/content/extensions/extensions.js"/> + + <browser id="html-view-browser" type="content" flex="1" disablehistory="true"/> +</window> diff --git a/toolkit/mozapps/extensions/content/firefox-alpenglow.svg b/toolkit/mozapps/extensions/content/firefox-alpenglow.svg new file mode 100644 index 0000000000..9293421e94 --- /dev/null +++ b/toolkit/mozapps/extensions/content/firefox-alpenglow.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="664" height="90"><defs><linearGradient id="b" x1="324.11" y1="53.56" x2="324.11" y2="52.83" gradientTransform="matrix(72 0 0 -128 -23004 6821)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff6bba"/><stop offset="1" stop-color="#ffc999"/></linearGradient><linearGradient id="c" x1="579.97" y1="46.46" x2="524.59" y2="66.59" gradientUnits="userSpaceOnUse"><stop offset=".27" stop-color="#fe82a4"/><stop offset="1" stop-color="#fe7fa3" stop-opacity="0"/></linearGradient><linearGradient id="a" x1="602.6" y1="42.67" x2="543.8" y2="95.35" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fc4ca0"/><stop offset="1" stop-color="#ffa769"/></linearGradient><linearGradient id="d" x1="627.55" y1="5.24" x2="616.47" y2="92.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7542e5"/><stop offset=".15" stop-color="#8b4ee5"/><stop offset=".62" stop-color="#ff4aa2"/><stop offset="1" stop-color="#ff778e"/></linearGradient><linearGradient id="e" x1="644.75" y1="11.14" x2="640.27" y2="43.06" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#20123a"/><stop offset="1" stop-color="#20123a" stop-opacity="0"/></linearGradient><linearGradient id="f" x1="322.39" y1="52.97" x2="322.66" y2="52.88" gradientTransform="matrix(160 0 0 -128 -51516 6821)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fc56a1"/><stop offset="1" stop-color="#fc56a1" stop-opacity="0"/></linearGradient><linearGradient id="g" x1="42.61" y1="47.79" x2="98.34" y2="78.93" xlink:href="#a"/><linearGradient id="h" x1="12.93" y1="23.88" x2="60.52" y2="67.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4e27a4"/><stop offset=".34" stop-color="#8b4ee5"/><stop offset=".73" stop-color="#ff4ad9"/><stop offset="1" stop-color="#ff778e"/></linearGradient><linearGradient id="i" x1="-9.97" y1="1.03" x2="65.82" y2="107.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#20123a"/><stop offset=".51" stop-color="#5f2eca"/><stop offset="1" stop-color="#7542e5" stop-opacity="0"/></linearGradient></defs><path d="M0 0h664v90H0z" fill-rule="evenodd" fill="url(#b)"/><path fill="url(#c)" d="M504 0h160v90H504z"/><path d="M664 90V0H536c4.4 35.42 18.65 66.86 39.21 90z" fill-rule="evenodd" fill="url(#a)"/><path d="M664 90V0h-83c1.56 34.75 13.2 66.18 31.37 90z" fill-rule="evenodd" fill="url(#d)"/><path d="M650.22 90H664V0h-48a51.79 51.79 0 0 0 2.65 9.31z" fill="#7542e5" fill-rule="evenodd"/><path d="M649 0h15v28a130.53 130.53 0 0 0-15-28z" fill="#20123a" fill-rule="evenodd"/><path d="M650.22 90H664V0h-48a51.79 51.79 0 0 0 2.65 9.31z" fill-rule="evenodd" fill="url(#e)"/><path d="M0 0h160v90H0z" fill="url(#f)"/><path d="M128 90v-2.61C128 45 104.13 9.44 72 0H38C23 4.41 9.8 14.49 0 28.31V90z" fill="url(#g)"/><path d="M106.29 90a110.93 110.93 0 0 0 .71-12.46C107 44.49 92.5 15.6 70.9 0H0v90z" fill="url(#h)"/><path d="M98.35 90a54 54 0 0 0-9.09-23.29L42.2 0H0v89.27l.52.73z" fill="url(#i)"/><path d="M46.47 90A122.45 122.45 0 0 0 33 67.85l-2.18-3C16.62 46.34 8.42 23.51 6.16 0H0v90z" fill="#20123a"/><path d="M28 35.72V.5L156 0v35.22h508V90H0V35.22z" fill="#fff" fill-opacity=".8"/><path d="M28 0h128v5H28z" fill="#bb8bff"/><path d="M56.5 18h71a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5h-71a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5zm128 0h71a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5h-71a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5z" fill="#2b1b4c"/><rect x="82.5" y="48.5" width="533" height="29" rx="4" fill="#fff"/><path d="M98.5 61h425a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5h-425a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5z" fill="#2b1b4c"/><circle cx="27" cy="63" r="7" fill="#6e46d5"/><circle cx="55" cy="63" r="7" fill="#6e46d5"/><rect x="630" y="54" width="18" height="2.67" rx="1.33" fill="#6e46d5"/><rect x="630" y="60.67" width="18" height="2.67" rx="1.33" fill="#6e46d5"/><rect x="630" y="67" width="18" height="2.67" rx="1.33" fill="#6e46d5"/></svg>
\ No newline at end of file diff --git a/toolkit/mozapps/extensions/content/firefox-compact-dark.svg b/toolkit/mozapps/extensions/content/firefox-compact-dark.svg new file mode 100644 index 0000000000..86ee5c3fde --- /dev/null +++ b/toolkit/mozapps/extensions/content/firefox-compact-dark.svg @@ -0,0 +1,17 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="664" height="90" fill="#F9F9FA"> + <path fill="#0C0C0D" d="M0 0h664v90H0z"/> + <path fill="#323234" d="M28 35.217V0l128 .489v35.218l508-.49V90H0V35.217z"/> + <path fill="#0A84FF" d="M28 0h128v5H28z"/> + <rect width="76" height="5" x="54" y="18" rx="2.5"/> + <rect width="76" height="5" x="182" y="18" rx="2.5"/> + <rect width="533" height="29" x="82.5" y="47.5" fill="#515153" stroke="#ADADB3" stroke-opacity=".2" rx="4"/> + <rect width="430" height="5" x="96" y="61" rx="2.5"/> + <circle cx="27" cy="63" r="7"/> + <circle cx="55" cy="63" r="7"/> + <rect width="18" height="2.667" x="630" y="54" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="60.667" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="67.333" rx="1.333"/> +</svg> diff --git a/toolkit/mozapps/extensions/content/firefox-compact-light.svg b/toolkit/mozapps/extensions/content/firefox-compact-light.svg new file mode 100644 index 0000000000..3e13c787d4 --- /dev/null +++ b/toolkit/mozapps/extensions/content/firefox-compact-light.svg @@ -0,0 +1,17 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="664" height="90" fill="#0C0C0D"> + <path fill="#E1E1E5" d="M0 0h664v90H0z"/> + <path fill="#F9F9FA" d="M28 35.217V0h128v35.217h508V90H0V35.217z"/> + <path fill="#0A84FF" d="M28 0h128v5H28z"/> + <rect width="76" height="5" x="54" y="18" fill="#3D3D3D" rx="2.5"/> + <rect width="76" height="5" x="182" y="18" fill="#3D3D3D" rx="2.5"/> + <rect width="533" height="29" x="82.5" y="47.5" fill="#FFF" stroke="#ADADB3" stroke-opacity=".2" rx="4"/> + <rect width="430" height="5" x="96" y="61" rx="2.5"/> + <circle cx="27" cy="63" r="7"/> + <circle cx="55" cy="63" r="7"/> + <rect width="18" height="2.667" x="630" y="54" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="60.667" rx="1.333"/> + <rect width="18" height="2.667" x="630" y="67.333" rx="1.333"/> +</svg> diff --git a/toolkit/mozapps/extensions/content/message-bar.css b/toolkit/mozapps/extensions/content/message-bar.css new file mode 100644 index 0000000000..4ef93c11a2 --- /dev/null +++ b/toolkit/mozapps/extensions/content/message-bar.css @@ -0,0 +1,144 @@ +/* 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 { + --info-icon-url: url("chrome://global/skin/icons/info.svg"); + --warn-icon-url: url("chrome://global/skin/icons/warning.svg"); + --check-icon-url: url("chrome://global/skin/icons/check.svg"); + --error-icon-url: url("chrome://global/skin/icons/error.svg"); + --close-icon-url: url("chrome://global/skin/icons/close.svg"); + --icon-size: 16px; + --close-icon-size: 32px; +} + +/* MessageBar colors by message type */ +/* Colors from: https://design.firefox.com/photon/components/message-bars.html#type-specific-style */ + +:host { + /* Colors used by default, and for [type=generic] message bars.*/ + background-color: var(--in-content-box-info-background); + color: var(--in-content-text-color); + + --message-bar-icon-url: var(--info-icon-url); + /* The default values of --in-content-button* are sufficient, even for dark themes */ +} + +:host([type=warning]) { + background-color: var(--yellow-50); + color: var(--yellow-90); + + --message-bar-icon-url: var(--warn-icon-url); + --in-content-button-background: var(--yellow-60); + --in-content-button-background-hover: var(--yellow-70); + --in-content-button-background-active: var(--yellow-80); +} + +:host([type=success]) { + background-color: var(--green-50); + color: var(--green-90); + + --message-bar-icon-url: var(--check-icon-url); + --in-content-button-background: var(--green-60); + --in-content-button-background-hover: var(--green-70); + --in-content-button-background-active: var(--green-80); +} + +:host([type=error]) { + background-color: var(--red-60); + color: #ffffff; + + --message-bar-icon-url: var(--error-icon-url); + --in-content-button-background: var(--red-70); + --in-content-button-background-hover: var(--red-80); + --in-content-button-background-active: var(--red-90); +} + +:host { + border-radius: 4px; +} + +/* Make the host to behave as a block by default, but allow hidden to hide it. */ +:host(:not([hidden])) { + display: block; +} + +::slotted(button) { + /* Enforce micro-button width. */ + min-width: -moz-fit-content !important; +} + +/* MessageBar Grid Layout */ + +.container { + background: inherit; + color: inherit; + + padding: 8px; + + min-height: 32px; + border-radius: 4px; + + display: flex; + /* Ensure that the message bar shadow dom elements are vertically aligned. */ + align-items: center; +} + +:host([align="center"]) .container { + justify-content: center; +} + +.content { + margin: 0 4px; + display: flex; + /* Ensure that the message bar content is vertically aligned. */ + align-items: center; + /* Ensure that the message bar content is wrapped. */ + word-break: break-word; +} + +/* MessageBar icon style */ + +.icon { + padding: 4px; + width: var(--icon-size); + height: var(--icon-size); + flex-shrink: 0; +} + +.icon::after { + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + content: var(--message-bar-icon-url); +} + +/* Use a spacer to position the close button at the end, but also support + * centering if required. */ +.spacer { + flex-grow: 1; +} + +/* Close icon styles */ + +:host(:not([dismissable])) .close { + display: none; +} + +.close { + background-image: var(--close-icon-url); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0; + min-width: auto; + min-height: auto; + width: var(--close-icon-size); + height: var(--close-icon-size); + margin: 0; + margin-inline-end: 4px; + padding: 0; + flex-shrink: 0; +} diff --git a/toolkit/mozapps/extensions/content/message-bar.js b/toolkit/mozapps/extensions/content/message-bar.js new file mode 100644 index 0000000000..0ab5a3c563 --- /dev/null +++ b/toolkit/mozapps/extensions/content/message-bar.js @@ -0,0 +1,148 @@ +/* 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"; + +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; + } +} + +class MessageBarElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + const content = this.constructor.template.content.cloneNode(true); + shadowRoot.append(content); + this.closeButton.addEventListener( + "click", + () => { + this.dispatchEvent(new CustomEvent("message-bar:user-dismissed")); + this.remove(); + }, + { + once: true, + } + ); + } + + disconnectedCallback() { + this.dispatchEvent(new CustomEvent("message-bar:close")); + } + + get closeButton() { + return this.shadowRoot.querySelector("button.close"); + } + + static get template() { + const template = document.createElement("template"); + + const style = document.createElement("style"); + style.textContent = ` + @import "chrome://global/skin/in-content/common.css"; + @import "chrome://mozapps/content/extensions/message-bar.css"; + `; + template.content.append(style); + + // A container for the entire message bar content, + // most of the css rules needed to provide the + // expected message bar layout is applied on this + // element. + const container = document.createElement("div"); + container.setAttribute("class", "container"); + template.content.append(container); + + const icon = document.createElement("span"); + icon.setAttribute("class", "icon"); + container.append(icon); + + const barcontent = document.createElement("span"); + barcontent.setAttribute("class", "content"); + barcontent.append(document.createElement("slot")); + container.append(barcontent); + + const spacer = document.createElement("span"); + spacer.classList.add("spacer"); + container.append(spacer); + + const closeIcon = document.createElement("button"); + closeIcon.setAttribute("class", "close"); + container.append(closeIcon); + + Object.defineProperty(this, "template", { + value: template, + }); + + return template; + } +} + +customElements.define("message-bar", MessageBarElement); +customElements.define("message-bar-stack", MessageBarStackElement); diff --git a/toolkit/mozapps/extensions/content/named-deck.js b/toolkit/mozapps/extensions/content/named-deck.js new file mode 100644 index 0000000000..a5fdd59013 --- /dev/null +++ b/toolkit/mozapps/extensions/content/named-deck.js @@ -0,0 +1,380 @@ +/* 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"; + +/** + * This element is for use with the <named-deck> element. Set the target + * <named-deck>'s ID in the "deck" attribute and the button's selected state + * will reflect the deck's state. When the button is clicked, it will set the + * view in the <named-deck> to the button's "name" attribute. + * + * The "tab" role will be added unless a different role is provided. Wrapping + * a set of these buttons in a <button-group> element will add the key handling + * for a tablist. + * + * NOTE: This does not observe changes to the "deck" or "name" attributes, so + * changing them likely won't work properly. + * + * <button is="named-deck-button" deck="pet-deck" name="dogs">Dogs</button> + * <named-deck id="pet-deck"> + * <p name="cats">I like cats.</p> + * <p name="dogs">I like dogs.</p> + * </named-deck> + * + * let btn = document.querySelector('button[name="dogs"]'); + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; + * btn.selected == false; // Selected was pulled from the related deck. + * btn.click(); + * deck.selectedViewName == "dogs"; + * btn.selected == true; // Selected updated when view changed. + */ +class NamedDeckButton extends HTMLButtonElement { + connectedCallback() { + this.id = `${this.deckId}-button-${this.name}`; + if (!this.hasAttribute("role")) { + this.setAttribute("role", "tab"); + } + this.setSelectedFromDeck(); + this.addEventListener("click", this); + document.addEventListener("view-changed", this, { capture: true }); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + document.removeEventListener("view-changed", this, { capture: true }); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "selected") { + this.selected = newVal; + } + } + + get deckId() { + return this.getAttribute("deck"); + } + + set deckId(val) { + this.setAttribute("deck", val); + } + + get deck() { + return document.getElementById(this.deckId); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target.id == this.deckId) { + this.setSelectedFromDeck(); + } else if (e.type == "click") { + let { deck } = this; + if (deck) { + deck.selectedViewName = this.name; + } + } + } + + get name() { + return this.getAttribute("name"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + set selected(val) { + if (this.selected != val) { + this.toggleAttribute("selected", val); + } + this.setAttribute("aria-selected", !!val); + } + + setSelectedFromDeck() { + let { deck } = this; + this.selected = deck && deck.selectedViewName == this.name; + if (this.selected) { + this.dispatchEvent( + new CustomEvent("button-group:selected", { bubbles: true }) + ); + } + } +} +customElements.define("named-deck-button", NamedDeckButton, { + extends: "button", +}); + +class ButtonGroup extends HTMLElement { + static get observedAttributes() { + return ["orientation"]; + } + + connectedCallback() { + this.setAttribute("role", "tablist"); + + if (!this.observer) { + this.observer = new MutationObserver(changes => { + for (let change of changes) { + this.setChildAttributes(change.addedNodes); + for (let node of change.removedNodes) { + if (this.activeChild == node) { + // Ensure there's still an active child. + this.activeChild = this.firstElementChild; + } + } + } + }); + } + this.observer.observe(this, { childList: true }); + + // Set the role and tabindex for the current children. + this.setChildAttributes(this.children); + + // Try assigning the active child again, this will run through the checks + // to ensure it's still valid. + this.activeChild = this._activeChild; + + this.addEventListener("button-group:selected", this); + this.addEventListener("keydown", this); + this.addEventListener("mousedown", this); + document.addEventListener("keypress", this); + } + + disconnectedCallback() { + this.observer.disconnect(); + this.removeEventListener("button-group:selected", this); + this.removeEventListener("keydown", this); + this.removeEventListener("mousedown", this); + document.removeEventListener("keypress", this); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "orientation") { + if (this.isVertical) { + this.setAttribute("aria-orientation", this.orientation); + } else { + this.removeAttribute("aria-orientation"); + } + } + } + + setChildAttributes(nodes) { + for (let node of nodes) { + if (node.nodeType == Node.ELEMENT_NODE && node != this.activeChild) { + node.setAttribute("tabindex", "-1"); + } + } + } + + // The activeChild is the child that can be focused with tab. + get activeChild() { + return this._activeChild; + } + + set activeChild(node) { + let prevActiveChild = this._activeChild; + let newActiveChild; + + if (node && this.contains(node)) { + newActiveChild = node; + } else { + newActiveChild = this.firstElementChild; + } + + this._activeChild = newActiveChild; + + if (newActiveChild) { + newActiveChild.setAttribute("tabindex", "0"); + } + + if (prevActiveChild && prevActiveChild != newActiveChild) { + prevActiveChild.setAttribute("tabindex", "-1"); + } + } + + get isVertical() { + return this.orientation == "vertical"; + } + + get orientation() { + return this.getAttribute("orientation") == "vertical" + ? "vertical" + : "horizontal"; + } + + set orientation(val) { + if (val == "vertical") { + this.setAttribute("orientation", val); + } else { + this.removeAttribute("orientation"); + } + } + + _navigationKeys() { + if (this.isVertical) { + return { + previousKey: "ArrowUp", + nextKey: "ArrowDown", + }; + } + if (document.dir == "rtl") { + return { + previousKey: "ArrowRight", + nextKey: "ArrowLeft", + }; + } + return { + previousKey: "ArrowLeft", + nextKey: "ArrowRight", + }; + } + + handleEvent(e) { + let { previousKey, nextKey } = this._navigationKeys(); + if (e.type == "keydown" && (e.key == previousKey || e.key == nextKey)) { + this.setAttribute("last-input-type", "keyboard"); + e.preventDefault(); + let oldFocus = this.activeChild; + this.walker.currentNode = oldFocus; + let newFocus; + if (e.key == previousKey) { + newFocus = this.walker.previousNode(); + } else { + newFocus = this.walker.nextNode(); + } + if (newFocus) { + this.activeChild = newFocus; + this.dispatchEvent(new CustomEvent("button-group:key-selected")); + } + } else if (e.type == "button-group:selected") { + this.activeChild = e.target; + } else if (e.type == "mousedown") { + this.setAttribute("last-input-type", "mouse"); + } else if (e.type == "keypress" && e.key == "Tab") { + this.setAttribute("last-input-type", "keyboard"); + } + } + + get walker() { + if (!this._walker) { + this._walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT, { + acceptNode: node => { + if (node.hidden || node.disabled) { + return NodeFilter.FILTER_REJECT; + } + node.focus(); + return document.activeElement == node + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + } + return this._walker; + } +} +customElements.define("button-group", ButtonGroup); + +/** + * A deck that is indexed by the "name" attribute of its children. The + * <named-deck-button> element is a companion element that can update its state + * and change the view of a <named-deck>. + * + * When the deck is connected it will set the first child as the selected view + * if a view is not already selected. + * + * The deck is implemented using a named slot. Setting a slot directly on a + * child element of the deck is not supported. + * + * You can get or set the selected view by name with the `selectedViewName` + * property or by setting the "selected-view" attribute. + * + * <named-deck> + * <section name="cats">Some info about cats.</section> + * <section name="dogs">Some dog stuff.</section> + * </named-deck> + * + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * deck.selectedViewName = "dogs"; + * deck.selectedViewName == "dogs"; // Dog stuff is shown. + * deck.setAttribute("selected-view", "cats"); + * deck.selectedViewName == "cats"; // Cat info is shown. + */ +class NamedDeck extends HTMLElement { + static get observedAttributes() { + return ["selected-view"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // Create a slot for the visible content. + let selectedSlot = document.createElement("slot"); + selectedSlot.setAttribute("name", "selected"); + this.shadowRoot.appendChild(selectedSlot); + + this.observer = new MutationObserver(() => { + this._setSelectedViewAttributes(); + }); + } + + connectedCallback() { + if (this.selectedViewName) { + // Make sure the selected view is shown. + this._setSelectedViewAttributes(); + } else { + // If there's no selected view, default to the first. + let firstView = this.firstElementChild; + if (firstView) { + // This will trigger showing the first view. + this.selectedViewName = firstView.getAttribute("name"); + } + } + this.observer.observe(this, { childList: true }); + } + + disconnectedCallback() { + this.observer.disconnect(); + } + + attributeChangedCallback(attr, oldVal, newVal) { + if (attr == "selected-view" && oldVal != newVal) { + // Update the slot attribute on the views. + this._setSelectedViewAttributes(); + + // Notify that the selected view changed. + this.dispatchEvent(new CustomEvent("view-changed")); + } + } + + get selectedViewName() { + return this.getAttribute("selected-view"); + } + + set selectedViewName(name) { + this.setAttribute("selected-view", name); + } + + /** + * Set the slot attribute on all of the views to ensure only the selected view + * is shown. + */ + _setSelectedViewAttributes() { + let { selectedViewName } = this; + for (let view of this.children) { + let name = view.getAttribute("name"); + view.setAttribute("aria-labelledby", `${this.id}-button-${name}`); + view.setAttribute("role", "tabpanel"); + + if (name === selectedViewName) { + view.slot = "selected"; + } else { + view.slot = ""; + } + } + } +} +customElements.define("named-deck", NamedDeck); diff --git a/toolkit/mozapps/extensions/content/panel-item.css b/toolkit/mozapps/extensions/content/panel-item.css new file mode 100644 index 0000000000..2a381019fe --- /dev/null +++ b/toolkit/mozapps/extensions/content/panel-item.css @@ -0,0 +1,68 @@ +/* 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 { + display: flex; + align-items: center; +} + +::slotted(a) { + margin-inline-end: 12px; +} + +:host([checked]) { + --icon: url("chrome://global/skin/icons/check.svg"); + -moz-context-properties: fill; + fill: currentColor; +} + +:host([checked]) button { + background-size: 1em; +} + +button { + background-color: transparent; + color: inherit; + background-image: var(--icon); + background-position: 16px center; + background-repeat: no-repeat; + background-size: 16px; + border: none; + position: relative; + display: block; + font-size: inherit; + padding: 4px 40px; + padding-inline-end: 12px; + text-align: start; + width: 100%; +} + +button:dir(rtl) { + background-position-x: right 16px; +} + +:host([badged]) button::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--blue-50); + position: absolute; + top: 4px; + inset-inline-start: 28px; +} + +button:focus, +button:enabled:hover { + background-color: var(--in-content-button-background); +} + +button:enabled:hover:active { + background-color: var(--in-content-button-background-hover); +} + +button:disabled { + opacity: 0.4; +} diff --git a/toolkit/mozapps/extensions/content/panel-list.css b/toolkit/mozapps/extensions/content/panel-list.css new file mode 100644 index 0000000000..f72301b9f1 --- /dev/null +++ b/toolkit/mozapps/extensions/content/panel-list.css @@ -0,0 +1,58 @@ +/* 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([showing]) { + visibility: hidden; +} + +:host { + position: absolute; + background: var(--in-content-box-background); + border: 1px solid var(--in-content-box-border-color); + border-radius: var(--panel-border-radius); + padding: 6px 0; + margin-bottom: 16px; + box-shadow: var(--shadow-30); + min-width: 12em; + z-index: var(--z-index-popup, 10); + white-space: nowrap; + cursor: default; +} + +.list { + margin: 0; + padding: 0; +} + +.arrow { + width: 18px; + height: 9px; + -moz-context-properties: fill, stroke; + stroke: var(--in-content-box-border-color); + fill: var(--in-content-box-background); + background: url("chrome://global/skin/arrow/panelarrow-vertical.svg"); + position: absolute; +} + +:host([valign="bottom"]) > .arrow.bottom, +:host([valign="top"]) > .arrow.top { + display: none; +} + +.arrow.top { + top: -9px; +} + +.arrow.bottom { + bottom: -9px; + transform: scaleY(-1); +} + +:host([align="left"]) > .arrow { + left: 16px; +} + +:host([align="right"]) > .arrow { + right: 16px; +} 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..a5991efac9 --- /dev/null +++ b/toolkit/mozapps/extensions/content/shortcuts.css @@ -0,0 +1,139 @@ +/* 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; + -moz-outline-radius: 3px; + 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..00dcaa63c7 --- /dev/null +++ b/toolkit/mozapps/extensions/content/shortcuts.js @@ -0,0 +1,680 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.jsm", +}); + +{ + 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. + }; + const SHORTCUT_KEY_SEPARATOR = "|"; + + let templatesLoaded = false; + let shortcutKeyMap = new Map(); + 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_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("+"); + } + + function buildDuplicateShortcutsMap(addons) { + return Promise.all( + addons.map(async addon => { + let extension = extensionForAddonId(addon.id); + if (extension && extension.shortcuts) { + let commands = await extension.shortcuts.allCommands(); + for (let command of commands) { + recordShortcut(command.shortcut, addon.name, command.name); + } + } + }) + ); + } + + function recordShortcut(shortcut, addonName, commandName) { + if (!shortcut) { + return; + } + let addons = shortcutKeyMap.get(shortcut); + let addonString = `${addonName}${SHORTCUT_KEY_SEPARATOR}${commandName}`; + if (addons) { + addons.add(addonString); + } else { + shortcutKeyMap.set(shortcut, new Set([addonString])); + } + } + + function removeShortcut(shortcut, addonName, commandName) { + let addons = shortcutKeyMap.get(shortcut); + let addonString = `${addonName}${SHORTCUT_KEY_SEPARATOR}${commandName}`; + if (addons) { + addons.delete(addonString); + if (addons.size === 0) { + shortcutKeyMap.delete(shortcut); + } + } + } + + function getAddonName(shortcut) { + let addons = shortcutKeyMap.get(shortcut); + // Get the first addon name with given shortcut. + let name = addons.values().next().value; + return name.split(SHORTCUT_KEY_SEPARATOR)[0]; + } + + 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); +} |