Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import {
AddonManagerListenerHandler,
isPending,
shouldSkipAnimations,
} from "../aboutaddons-utils.mjs";
const { AddonManager } = ChromeUtils.importESModule(
"resource://gre/modules/AddonManager.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
recordListViewTelemetry: "chrome://global/content/ml/Utils.sys.mjs",
});
/**
* 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);
*/
export class AddonList extends HTMLElement {
constructor() {
super();
this.sections = [];
this.pendingUninstallAddons = new Set();
this._addonsToUpdate = new Set();
this._userFocusListenersAdded = false;
this._listeningForInstallUpdates = 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.
*
* Warning: if filterFn uses criteria that are not tied to add-on events,
* make sure to add an implementation that calls updateAddon(addon) as
* needed. Not doing so can result in missing or out-of-date add-on cards!
*
* @param {object[]} sections
* The options for the section. Each entry in the array should have:
* headingId: The fluent id for the section's heading.
* filterFn: A function that determines if an add-on belongs in
* the section.
*/
setSections(sections) {
this.sections = sections.map(section => Object.assign({}, section));
}
/**
* Set the add-on type for this list. This will be used to filter the add-ons
* that are displayed.
*
* @param {string} val The type to filter on.
*/
set type(val) {
this.setAttribute("type", val);
}
get type() {
return this.getAttribute("type");
}
getSection(index) {
return this.sections[index].node;
}
getCards(section) {
return section.querySelectorAll("addon-card");
}
getCard(addon) {
return this.querySelector(`addon-card[addon-id="${addon.id}"]`);
}
getPendingUninstallBar(addon) {
return this.querySelector(`moz-message-bar[addon-id="${addon.id}"]`);
}
sortByFn(aAddon, bAddon) {
return aAddon.name.localeCompare(bAddon.name);
}
async getAddons() {
if (!this.type) {
throw new Error(`type must be set to find add-ons`);
}
// Find everything matching our type, null will find all types.
let type = this.type == "all" ? null : [this.type];
let addons = await AddonManager.getAddonsByTypes(type);
if (type == "theme") {
await lazy.BuiltInThemes.ensureBuiltInThemes();
}
if (type == "mlmodel") {
lazy.recordListViewTelemetry(addons.length);
}
// Put the add-ons into the sections, an add-on goes in the first section
// that it matches the filterFn for. It might not go in any section.
let sectionedAddons = this.sections.map(() => []);
for (let addon of addons) {
let index = this.sections.findIndex(({ filterFn }) => filterFn(addon));
if (index != -1) {
sectionedAddons[index].push(addon);
} else if (isPending(addon, "uninstall")) {
// A second tab may be opened on "about:addons" (or Firefox may
// have crashed) while there are still "pending uninstall" add-ons.
// Ensure to list them in the pendingUninstall message-bar-stack
// when the AddonList is initially rendered.
this.pendingUninstallAddons.add(addon);
}
}
// Sort the add-ons in each section.
for (let [index, section] of sectionedAddons.entries()) {
let sortByFn = this.sections[index].sortByFn || this.sortByFn;
section.sort(sortByFn);
}
return sectionedAddons;
}
createPendingUninstallStack() {
const stack = document.createElement("message-bar-stack");
stack.setAttribute("class", "pending-uninstall");
stack.setAttribute("reverse", "");
return stack;
}
addPendingUninstallBar(addon) {
const stack = this.pendingUninstallStack;
const mb = document.createElement("moz-message-bar");
mb.setAttribute("addon-id", addon.id);
mb.setAttribute("type", "info");
const undo = document.createElement("button");
undo.setAttribute("action", "undo");
undo.addEventListener("click", () => {
addon.cancelUninstall();
});
undo.setAttribute("slot", "actions");
document.l10n.setAttributes(mb, "pending-uninstall-description2", {
addon: addon.name,
});
mb.setAttribute("data-l10n-attrs", "message");
document.l10n.setAttributes(undo, "pending-uninstall-undo-button");
mb.appendChild(undo);
stack.append(mb);
}
removePendingUninstallBar(addon) {
const messagebar = this.getPendingUninstallBar(addon);
if (messagebar) {
messagebar.remove();
}
}
createSectionHeading(headingIndex) {
let { headingId, subheadingId } = this.sections[headingIndex];
let frag = document.createDocumentFragment();
let heading = document.createElement("h2");
heading.classList.add("list-section-heading");
document.l10n.setAttributes(heading, headingId);
frag.append(heading);
if (subheadingId) {
heading.className = "header-name";
let subheading = document.createElement("h3");
subheading.classList.add("list-section-subheading");
document.l10n.setAttributes(subheading, subheadingId);
frag.append(subheading);
}
return frag;
}
createEmptyListMessage() {
let emptyMessage = "list-empty-get-extensions-message";
let linkPref = "extensions.getAddons.link.url";
if (this.sections && this.sections.length) {
if (this.sections[0].headingId == "locale-enabled-heading") {
emptyMessage = "list-empty-get-language-packs-message";
linkPref = "browser.dictionaries.download.url";
} else if (this.sections[0].headingId == "dictionary-enabled-heading") {
emptyMessage = "list-empty-get-dictionaries-message";
linkPref = "browser.dictionaries.download.url";
}
}
let messageContainer = document.createElement("p");
messageContainer.id = "empty-addons-message";
let a = document.createElement("a");
a.href = Services.urlFormatter.formatURLPref(linkPref);
a.setAttribute("target", "_blank");
a.setAttribute("data-l10n-name", "get-extensions");
document.l10n.setAttributes(messageContainer, emptyMessage, {
domain: a.hostname,
});
messageContainer.appendChild(a);
return messageContainer;
}
updateSectionIfEmpty(section) {
// Clear the entire list if there are no `addon-card` childrens.
if (!section.querySelectorAll("addon-card").length) {
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) {
if (this._listeningForInstallUpdates) {
// For stability of the UI, do not remove the card from the updates view
// when it is already there, even when an update is installed.
return;
}
let card = this.getCard(addon);
if (card) {
let sectionIndex = this._addonSectionIndex(addon);
if (sectionIndex != -1) {
// Move the card, if needed. This will allow an animation between
// page sections and provides clearer events for testing.
if (card.parentNode.getAttribute("section") != sectionIndex) {
let { activeElement } = document;
let refocus = card.contains(activeElement);
let oldSection = card.parentNode;
this.insertCardInto(card, sectionIndex);
this.updateSectionIfEmpty(oldSection);
if (refocus) {
activeElement.focus();
}
this.sendEvent("move", { id: addon.id });
}
} else {
this.removeAddon(addon);
}
}
}
renderSection(addons, index) {
const { sectionClass } = this.sections[index];
let section = document.createElement("section");
section.setAttribute("section", index);
if (sectionClass) {
section.setAttribute("class", sectionClass);
}
// Render the heading and add-ons if there are any, except for mlmodel list
// view which only shows installed models.
if (this.type != "mlmodel" && 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);
if (this.type == "mlmodel") {
frag.appendChild(document.createElement("mlmodel-list-intro"));
}
// 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);
}
// Add the "empty list message" elements (but omit it in the list view
// related to the "mlmodel" type).
if (this.type != "mlmodel") {
// Render the placeholder that is shown when all sections are empty.
// This call is after rendering the sections, because its visibility
// is controlled through the general sibling combinator relative to
// the sections (section ~).
let message = this.createEmptyListMessage();
frag.appendChild(message);
}
// Make sure fluent has set all the strings before we render. This will
// avoid the height changing as strings go from 0 height to having text.
await document.l10n.translateFragment(frag);
this.appendChild(frag);
}
registerListener() {
AddonManagerListenerHandler.addListener(this);
}
removeListener() {
AddonManagerListenerHandler.removeListener(this);
}
handleEvent(e) {
if (!this.isUserFocused || (e.type == "mouseleave" && !this.hasMenuOpen)) {
this._removeUserFocusListeners();
this.update();
}
}
/**
* AddonManager listener events.
*/
onOperationCancelled(addon) {
if (
this.pendingUninstallAddons.has(addon) &&
!isPending(addon, "uninstall")
) {
this.pendingUninstallAddons.delete(addon);
this.removePendingUninstallBar(addon);
}
this.updateAddon(addon);
}
onEnabled(addon) {
this.updateAddon(addon);
}
onDisabled(addon) {
this.updateAddon(addon);
}
onUninstalling(addon) {
if (
isPending(addon, "uninstall") &&
(this.type === "all" || addon.type === this.type)
) {
this.pendingUninstallAddons.add(addon);
this.addPendingUninstallBar(addon);
this.updateAddon(addon);
}
}
onInstalled(addon) {
this.updateAddon(addon);
}
onUninstalled(addon) {
this.pendingUninstallAddons.delete(addon);
this.removePendingUninstallBar(addon);
this.removeAddon(addon);
}
onNewInstall(install) {
if (this._listeningForInstallUpdates) {
this._updateOnNewInstall(install);
}
}
onInstallPostponed(install) {
if (this._listeningForInstallUpdates) {
this._updateOnNewInstall(install);
}
}
listenForUpdates() {
this._listeningForInstallUpdates = true;
}
async _updateOnNewInstall(install) {
if (!install.existingAddon) {
// Not from an update check.
return;
}
// To make sure that we use the real, live add-on state, look it up again.
const addon = await AddonManager.getAddonByID(install.existingAddon.id);
if (addon) {
this.updateAddon(addon);
}
}
}
customElements.define("addon-list", AddonList);