diff options
Diffstat (limited to 'comm/mail/components/extensions/parent/ext-menus.js')
-rw-r--r-- | comm/mail/components/extensions/parent/ext-menus.js | 1544 |
1 files changed, 1544 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-menus.js b/comm/mail/components/extensions/parent/ext-menus.js new file mode 100644 index 0000000000..0db7ddf809 --- /dev/null +++ b/comm/mail/components/extensions/parent/ext-menus.js @@ -0,0 +1,1544 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineModuleGetter( + this, + "MailServices", + "resource:///modules/MailServices.jsm" +); + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { SelectionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/SelectionUtils.sys.mjs" +); + +var { DefaultMap, ExtensionError } = ExtensionUtils; + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { IconDetails, StartupCache } = ExtensionParent; + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); +var { makeWidgetId } = ExtensionCommon; + +const ACTION_MENU_TOP_LEVEL_LIMIT = 6; + +// Map[Extension -> Map[ID -> MenuItem]] +// Note: we want to enumerate all the menu items so +// this cannot be a weak map. +var gMenuMap = new Map(); + +// Map[Extension -> Map[ID -> MenuCreateProperties]] +// The map object for each extension is a reference to the same +// object in StartupCache.menus. This provides a non-async +// getter for that object. +var gStartupCache = new Map(); + +// Map[Extension -> MenuItem] +var gRootItems = new Map(); + +// Map[Extension -> ID[]] +// Menu IDs that were eligible for being shown in the current menu. +var gShownMenuItems = new DefaultMap(() => []); + +// Map[Extension -> Set[Contexts]] +// A DefaultMap (keyed by extension) which keeps track of the +// contexts with a subscribed onShown event listener. +var gOnShownSubscribers = new DefaultMap(() => new Set()); + +// If id is not specified for an item we use an integer. +var gNextMenuItemID = 0; + +// Used to assign unique names to radio groups. +var gNextRadioGroupID = 0; + +// The max length of a menu item's label. +var gMaxLabelLength = 64; + +var gMenuBuilder = { + // When a new menu is opened, this function is called and + // we populate the |xulMenu| with all the items from extensions + // to be displayed. We always clear all the items again when + // popuphidden fires. + build(contextData) { + contextData = this.maybeOverrideContextData(contextData); + let xulMenu = contextData.menu; + xulMenu.addEventListener("popuphidden", this); + this.xulMenu = xulMenu; + for (let [, root] of gRootItems) { + this.createAndInsertTopLevelElements(root, contextData, null); + } + this.afterBuildingMenu(contextData); + + if ( + contextData.webExtContextData && + !contextData.webExtContextData.showDefaults + ) { + // Wait until nsContextMenu.js has toggled the visibility of the default + // menu items before hiding the default items. + Promise.resolve().then(() => this.hideDefaultMenuItems()); + } + }, + + maybeOverrideContextData(contextData) { + let { webExtContextData } = contextData; + if (!webExtContextData || !webExtContextData.overrideContext) { + return contextData; + } + let contextDataBase = { + menu: contextData.menu, + // eslint-disable-next-line no-use-before-define + originalViewType: getContextViewType(contextData), + originalViewUrl: contextData.inFrame + ? contextData.frameUrl + : contextData.pageUrl, + webExtContextData, + }; + if (webExtContextData.overrideContext === "tab") { + // TODO: Handle invalid tabs more gracefully (instead of throwing). + let tab = tabTracker.getTab(webExtContextData.tabId); + return { + ...contextDataBase, + tab, + pageUrl: tab.linkedBrowser?.currentURI?.spec, + onTab: true, + }; + } + throw new ExtensionError( + `Unexpected overrideContext: ${webExtContextData.overrideContext}` + ); + }, + + createAndInsertTopLevelElements(root, contextData, nextSibling) { + const newWebExtensionGroupSeparator = () => { + let element = + this.xulMenu.ownerDocument.createXULElement("menuseparator"); + element.classList.add("webextension-group-separator"); + return element; + }; + + let rootElements; + if ( + contextData.onAction || + contextData.onBrowserAction || + contextData.onComposeAction || + contextData.onMessageDisplayAction + ) { + if (contextData.extension.id !== root.extension.id) { + return; + } + rootElements = this.buildTopLevelElements( + root, + contextData, + ACTION_MENU_TOP_LEVEL_LIMIT, + false + ); + + // Action menu items are prepended to the menu, followed by a separator. + nextSibling = nextSibling || this.xulMenu.firstElementChild; + if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) { + rootElements.push(newWebExtensionGroupSeparator()); + } + } else if ( + contextData.inActionMenu || + contextData.inBrowserActionMenu || + contextData.inComposeActionMenu || + contextData.inMessageDisplayActionMenu + ) { + if (contextData.extension.id !== root.extension.id) { + return; + } + rootElements = this.buildTopLevelElements( + root, + contextData, + Infinity, + false + ); + } else if (contextData.webExtContextData) { + let { extensionId, showDefaults, overrideContext } = + contextData.webExtContextData; + if (extensionId === root.extension.id) { + rootElements = this.buildTopLevelElements( + root, + contextData, + Infinity, + false + ); + // The extension menu should be rendered at the top, but after the navigation buttons. + nextSibling = + nextSibling || this.xulMenu.querySelector(":scope > :first-child"); + if ( + rootElements.length && + showDefaults && + !this.itemsToCleanUp.has(nextSibling) + ) { + rootElements.push(newWebExtensionGroupSeparator()); + } + } else if (!showDefaults && !overrideContext) { + // When the default menu items should be hidden, menu items from other + // extensions should be hidden too. + return; + } + // Fall through to show default extension menu items. + } + + if (!rootElements) { + rootElements = this.buildTopLevelElements(root, contextData, 1, true); + if ( + rootElements.length && + !this.itemsToCleanUp.has(this.xulMenu.lastElementChild) && + this.xulMenu.firstChild + ) { + // All extension menu items are appended at the end. + // Prepend separator if this is the first extension menu item. + rootElements.unshift(newWebExtensionGroupSeparator()); + } + } + + if (!rootElements.length) { + return; + } + + if (nextSibling) { + nextSibling.before(...rootElements); + } else { + this.xulMenu.append(...rootElements); + } + for (let item of rootElements) { + this.itemsToCleanUp.add(item); + } + }, + + buildElementWithChildren(item, contextData) { + const element = this.buildSingleElement(item, contextData); + const children = this.buildChildren(item, contextData); + if (children.length) { + element.firstElementChild.append(...children); + } + return element; + }, + + buildChildren(item, contextData) { + let groupName; + let children = []; + for (let child of item.children) { + if (child.type == "radio" && !child.groupName) { + if (!groupName) { + groupName = `webext-radio-group-${gNextRadioGroupID++}`; + } + child.groupName = groupName; + } else { + groupName = null; + } + + if (child.enabledForContext(contextData)) { + children.push(this.buildElementWithChildren(child, contextData)); + } + } + return children; + }, + + buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) { + let children = this.buildChildren(root, contextData); + + // TODO: Fix bug 1492969 and remove this whole if block. + if ( + children.length === 1 && + maxCount === 1 && + forceManifestIcons && + AppConstants.platform === "linux" && + children[0].getAttribute("type") === "checkbox" + ) { + // Keep single checkbox items in the submenu on Linux since + // the extension icon overlaps the checkbox otherwise. + maxCount = 0; + } + + if (children.length > maxCount) { + // Move excess items into submenu. + let rootElement = this.buildSingleElement(root, contextData); + rootElement.setAttribute("ext-type", "top-level-menu"); + rootElement.firstElementChild.append(...children.splice(maxCount - 1)); + children.push(rootElement); + } + + if (forceManifestIcons) { + for (let rootElement of children) { + // Display the extension icon on the root element. + if ( + root.extension.manifest.icons && + rootElement.getAttribute("type") !== "checkbox" + ) { + this.setMenuItemIcon( + rootElement, + root.extension, + contextData, + root.extension.manifest.icons + ); + } else { + this.removeMenuItemIcon(rootElement); + } + } + } + return children; + }, + + removeSeparatorIfNoTopLevelItems() { + // Extension menu items always have have a non-empty ID. + let isNonExtensionSeparator = item => + item.nodeName === "menuseparator" && !item.id; + + // itemsToCleanUp contains all top-level menu items. A separator should + // only be kept if it is next to an extension menu item. + let isExtensionMenuItemSibling = item => + item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item); + + for (let item of this.itemsToCleanUp) { + if (isNonExtensionSeparator(item)) { + if ( + !isExtensionMenuItemSibling(item.previousElementSibling) && + !isExtensionMenuItemSibling(item.nextElementSibling) + ) { + item.remove(); + this.itemsToCleanUp.delete(item); + } + } + } + }, + + buildSingleElement(item, contextData) { + let doc = contextData.menu.ownerDocument; + let element; + if (item.children.length) { + element = this.createMenuElement(doc, item); + } else if (item.type == "separator") { + element = doc.createXULElement("menuseparator"); + } else { + element = doc.createXULElement("menuitem"); + } + + return this.customizeElement(element, item, contextData); + }, + + createMenuElement(doc, item) { + let element = doc.createXULElement("menu"); + // Menu elements need to have a menupopup child for its menu items. + let menupopup = doc.createXULElement("menupopup"); + element.appendChild(menupopup); + return element; + }, + + customizeElement(element, item, contextData) { + let label = item.title; + if (label) { + let accessKey; + label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => { + if (nextChar === "&") { + return "&"; + } + if (accessKey === undefined) { + if (nextChar === "%" && label.charAt(i + 2) === "s") { + accessKey = ""; + } else { + accessKey = nextChar; + } + } + return nextChar; + }); + element.setAttribute("accesskey", accessKey || ""); + + if (contextData.isTextSelected && label.includes("%s")) { + let selection = contextData.selectionText.trim(); + // The rendering engine will truncate the title if it's longer than 64 characters. + // But if it makes sense let's try truncate selection text only, to handle cases like + // 'look up "%s" in MyDictionary' more elegantly. + + let codePointsToRemove = 0; + + let selectionArray = Array.from(selection); + + let completeLabelLength = label.length - 2 + selectionArray.length; + if (completeLabelLength > gMaxLabelLength) { + codePointsToRemove = completeLabelLength - gMaxLabelLength; + } + + if (codePointsToRemove) { + let ellipsis = "\u2026"; + try { + ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + codePointsToRemove += 1; + selection = + selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis; + } + + label = label.replace(/%s/g, selection); + } + + element.setAttribute("label", label); + } + + element.setAttribute("id", item.elementId); + + if ("icons" in item) { + if (item.icons) { + this.setMenuItemIcon(element, item.extension, contextData, item.icons); + } else { + this.removeMenuItemIcon(element); + } + } + + if (item.type == "checkbox") { + element.setAttribute("type", "checkbox"); + if (item.checked) { + element.setAttribute("checked", "true"); + } + } else if (item.type == "radio") { + element.setAttribute("type", "radio"); + element.setAttribute("name", item.groupName); + if (item.checked) { + element.setAttribute("checked", "true"); + } + } + + if (!item.enabled) { + element.setAttribute("disabled", "true"); + } + + let button; + + element.addEventListener( + "command", + async event => { + if (event.target !== event.currentTarget) { + return; + } + const wasChecked = item.checked; + if (item.type == "checkbox") { + item.checked = !item.checked; + } else if (item.type == "radio") { + // Deselect all radio items in the current radio group. + for (let child of item.parent.children) { + if (child.type == "radio" && child.groupName == item.groupName) { + child.checked = false; + } + } + // Select the clicked radio item. + item.checked = true; + } + + let { webExtContextData } = contextData; + if ( + contextData.tab && + // If the menu context was overridden by the extension, do not grant + // activeTab since the extension also controls the tabId. + (!webExtContextData || + webExtContextData.extensionId !== item.extension.id) + ) { + item.tabManager.addActiveTabPermission(contextData.tab); + } + + let info = await item.getClickInfo(contextData, wasChecked); + info.modifiers = clickModifiersFromEvent(event); + + info.button = button; + let _execute_action = + item.extension.manifestVersion < 3 + ? "_execute_browser_action" + : "_execute_action"; + + // Allow menus to open various actions supported in webext prior + // to notifying onclicked. + let actionFor = { + [_execute_action]: global.browserActionFor, + _execute_compose_action: global.composeActionFor, + _execute_message_display_action: global.messageDisplayActionFor, + }[item.command]; + if (actionFor) { + let win = event.target.ownerGlobal; + actionFor(item.extension).triggerAction(win); + return; + } + + item.extension.emit( + "webext-menu-menuitem-click", + info, + contextData.tab + ); + }, + { once: true } + ); + + // eslint-disable-next-line mozilla/balanced-listeners + element.addEventListener("click", event => { + if ( + event.target !== event.currentTarget || + // Ignore menu items that are usually not clickeable, + // such as separators and parents of submenus and disabled items. + element.localName !== "menuitem" || + element.disabled + ) { + return; + } + + button = event.button; + if (event.button) { + element.doCommand(); + contextData.menu.hidePopup(); + } + }); + + // Don't publish the ID of the root because the root element is + // auto-generated. + if (item.parent) { + gShownMenuItems.get(item.extension).push(item.id); + } + + return element; + }, + + setMenuItemIcon(element, extension, contextData, icons) { + let parentWindow = contextData.menu.ownerGlobal; + + let { icon } = IconDetails.getPreferredIcon( + icons, + extension, + 16 * parentWindow.devicePixelRatio + ); + + // The extension icons in the manifest are not pre-resolved, since + // they're sometimes used by the add-on manager when the extension is + // not enabled, and its URLs are not resolvable. + let resolvedURL = extension.baseURI.resolve(icon); + + if (element.localName == "menu") { + element.setAttribute("class", "menu-iconic"); + } else if (element.localName == "menuitem") { + element.setAttribute("class", "menuitem-iconic"); + } + + element.setAttribute("image", resolvedURL); + }, + + // Undo changes from setMenuItemIcon. + removeMenuItemIcon(element) { + element.removeAttribute("class"); + element.removeAttribute("image"); + }, + + rebuildMenu(extension) { + let { contextData } = this; + if (!contextData) { + // This happens if the menu is not visible. + return; + } + + // Find the group of existing top-level items (usually 0 or 1 items) + // and remember its position for when the new items are inserted. + let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`; + let nextSibling = null; + for (let item of this.itemsToCleanUp) { + if (item.id && item.id.startsWith(elementIdPrefix)) { + nextSibling = item.nextSibling; + item.remove(); + this.itemsToCleanUp.delete(item); + } + } + + let root = gRootItems.get(extension); + if (root) { + this.createAndInsertTopLevelElements(root, contextData, nextSibling); + } + this.removeSeparatorIfNoTopLevelItems(); + }, + + // This should be called once, after constructing the top-level menus, if any. + afterBuildingMenu(contextData) { + function dispatchOnShownEvent(extension) { + // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the + // extension to be stored in the map even if there are currently no + // shown menu items. This ensures that the onHidden event can be fired + // when the menu is closed. + let menuIds = gShownMenuItems.get(extension); + extension.emit("webext-menu-shown", menuIds, contextData); + } + + if ( + contextData.onAction || + contextData.onBrowserAction || + contextData.onComposeAction || + contextData.onMessageDisplayAction + ) { + dispatchOnShownEvent(contextData.extension); + } else { + for (const extension of gOnShownSubscribers.keys()) { + dispatchOnShownEvent(extension); + } + } + + this.contextData = contextData; + }, + + hideDefaultMenuItems() { + for (let item of this.xulMenu.children) { + if (!this.itemsToCleanUp.has(item)) { + item.hidden = true; + } + } + }, + + handleEvent(event) { + if (this.xulMenu != event.target || event.type != "popuphidden") { + return; + } + + delete this.xulMenu; + delete this.contextData; + + let target = event.target; + target.removeEventListener("popuphidden", this); + for (let item of this.itemsToCleanUp) { + item.remove(); + } + this.itemsToCleanUp.clear(); + for (let extension of gShownMenuItems.keys()) { + extension.emit("webext-menu-hidden"); + } + gShownMenuItems.clear(); + }, + + itemsToCleanUp: new Set(), +}; + +// Called from different action popups. +global.actionContextMenu = function (contextData) { + contextData.originalViewType = "tab"; + gMenuBuilder.build(contextData); +}; + +const contextsMap = { + onAudio: "audio", + onEditable: "editable", + inFrame: "frame", + onImage: "image", + onLink: "link", + onPassword: "password", + isTextSelected: "selection", + onVideo: "video", + + onAction: "action", + onBrowserAction: "browser_action", + onComposeAction: "compose_action", + onMessageDisplayAction: "message_display_action", + inActionMenu: "action_menu", + inBrowserActionMenu: "browser_action_menu", + inComposeActionMenu: "compose_action_menu", + inMessageDisplayActionMenu: "message_display_action_menu", + + onComposeBody: "compose_body", + onTab: "tab", + inToolsMenu: "tools_menu", + selectedMessages: "message_list", + selectedFolder: "folder_pane", + selectedComposeAttachments: "compose_attachments", + selectedMessageAttachments: "message_attachments", + allMessageAttachments: "all_message_attachments", +}; + +const chromeElementsMap = { + msgSubject: "composeSubject", + toAddrInput: "composeTo", + ccAddrInput: "composeCc", + bccAddrInput: "composeBcc", + replyAddrInput: "composeReplyTo", + newsgroupsAddrInput: "composeNewsgroupTo", + followupAddrInput: "composeFollowupTo", +}; + +const getMenuContexts = contextData => { + let contexts = new Set(); + + for (const [key, value] of Object.entries(contextsMap)) { + if (contextData[key]) { + contexts.add(value); + } + } + + if (contexts.size === 0) { + contexts.add("page"); + } + + // New non-content contexts supported in Thunderbird are not part of "all". + if (!contextData.onTab && !contextData.inToolsMenu) { + contexts.add("all"); + } + + return contexts; +}; + +function getContextViewType(contextData) { + if ("originalViewType" in contextData) { + return contextData.originalViewType; + } + if ( + contextData.webExtBrowserType === "popup" || + contextData.webExtBrowserType === "sidebar" + ) { + return contextData.webExtBrowserType; + } + if (contextData.tab && contextData.menu.id === "browserContext") { + return "tab"; + } + return undefined; +} + +async function addMenuEventInfo( + info, + contextData, + extension, + includeSensitiveData +) { + info.viewType = getContextViewType(contextData); + if (contextData.onVideo) { + info.mediaType = "video"; + } else if (contextData.onAudio) { + info.mediaType = "audio"; + } else if (contextData.onImage) { + info.mediaType = "image"; + } + if (contextData.frameId !== undefined) { + info.frameId = contextData.frameId; + } + info.editable = contextData.onEditable || false; + if (includeSensitiveData) { + if (contextData.timeStamp) { + // Convert to integer, in case the DOMHighResTimeStamp has a fractional part. + info.targetElementId = Math.floor(contextData.timeStamp); + } + if (contextData.onLink) { + info.linkText = contextData.linkText; + info.linkUrl = contextData.linkUrl; + } + if (contextData.onAudio || contextData.onImage || contextData.onVideo) { + info.srcUrl = contextData.srcUrl; + } + info.pageUrl = contextData.pageUrl; + if (contextData.inFrame) { + info.frameUrl = contextData.frameUrl; + } + if (contextData.isTextSelected) { + info.selectionText = contextData.selectionText; + } + } + // If the context was overridden, then frameUrl should be the URL of the + // document in which the menu was opened (instead of undefined, even if that + // document is not in a frame). + if (contextData.originalViewUrl) { + info.frameUrl = contextData.originalViewUrl; + } + + if (contextData.fieldId) { + info.fieldId = contextData.fieldId; + } + + if (contextData.selectedMessages && extension.hasPermission("messagesRead")) { + info.selectedMessages = await messageListTracker.startList( + contextData.selectedMessages, + extension + ); + } + if (extension.hasPermission("accountsRead")) { + for (let folderType of ["displayedFolder", "selectedFolder"]) { + if (contextData[folderType]) { + let folder = convertFolder(contextData[folderType]); + // If the context menu click in the folder pane occurred on a root folder + // representing an account, do not include a selectedFolder object, but + // the corresponding selectedAccount object. + if (folderType == "selectedFolder" && folder.path == "/") { + info.selectedAccount = convertAccount( + MailServices.accounts.getAccount(folder.accountId) + ); + } else { + info[folderType] = traverseSubfolders( + contextData[folderType], + folder.accountId + ); + } + } + } + } + if ( + (contextData.selectedMessageAttachments || + contextData.allMessageAttachments) && + extension.hasPermission("messagesRead") + ) { + let attachments = + contextData.selectedMessageAttachments || + contextData.allMessageAttachments; + info.attachments = attachments.map(attachment => { + return { + contentType: attachment.contentType, + name: attachment.name, + size: attachment.size, + partName: attachment.partID, + }; + }); + } + if ( + contextData.selectedComposeAttachments && + extension.hasPermission("compose") + ) { + if (!("composeAttachmentTracker" in global)) { + extensions.loadModule("compose"); + } + + info.attachments = contextData.selectedComposeAttachments.map(a => + global.composeAttachmentTracker.convert(a, contextData.menu.ownerGlobal) + ); + } +} + +class MenuItem { + constructor(extension, createProperties, isRoot = false) { + this.extension = extension; + this.children = []; + this.parent = null; + this.tabManager = extension.tabManager; + + this.setDefaults(); + this.setProps(createProperties); + + if (!this.hasOwnProperty("_id")) { + this.id = gNextMenuItemID++; + } + // If the item is not the root and has no parent + // it must be a child of the root. + if (!isRoot && !this.parent) { + this.root.addChild(this); + } + } + + static mergeProps(obj, properties) { + for (let propName in properties) { + if (properties[propName] === null) { + // Omitted optional argument. + continue; + } + obj[propName] = properties[propName]; + } + + if ("icons" in properties) { + if (properties.icons === null) { + obj.icons = null; + } else if (typeof properties.icons == "string") { + obj.icons = { 16: properties.icons }; + } + } + } + + setProps(createProperties) { + MenuItem.mergeProps(this, createProperties); + + if (createProperties.documentUrlPatterns != null) { + this.documentUrlMatchPattern = new MatchPatternSet( + this.documentUrlPatterns, + { + restrictSchemes: this.extension.restrictSchemes, + } + ); + } + + if (createProperties.targetUrlPatterns != null) { + this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns, { + // restrictSchemes default to false when matching links instead of pages + // (see Bug 1280370 for a rationale). + restrictSchemes: false, + }); + } + + // If a child MenuItem does not specify any contexts, then it should + // inherit the contexts specified from its parent. + if (createProperties.parentId && !createProperties.contexts) { + this.contexts = this.parent.contexts; + } + } + + setDefaults() { + this.setProps({ + type: "normal", + checked: false, + contexts: ["all"], + enabled: true, + visible: true, + }); + } + + set id(id) { + if (this.hasOwnProperty("_id")) { + throw new ExtensionError("ID of a MenuItem cannot be changed"); + } + let isIdUsed = gMenuMap.get(this.extension).has(id); + if (isIdUsed) { + throw new ExtensionError(`ID already exists: ${id}`); + } + this._id = id; + } + + get id() { + return this._id; + } + + get elementId() { + let id = this.id; + // If the ID is an integer, it is auto-generated and globally unique. + // If the ID is a string, it is only unique within one extension and the + // ID needs to be concatenated with the extension ID. + if (typeof id !== "number") { + // To avoid collisions with numeric IDs, add a prefix to string IDs. + id = `_${id}`; + } + return `${makeWidgetId(this.extension.id)}-menuitem-${id}`; + } + + ensureValidParentId(parentId) { + if (parentId === undefined) { + return; + } + let menuMap = gMenuMap.get(this.extension); + if (!menuMap.has(parentId)) { + throw new ExtensionError( + `Could not find any MenuItem with id: ${parentId}` + ); + } + for (let item = menuMap.get(parentId); item; item = item.parent) { + if (item === this) { + throw new ExtensionError( + "MenuItem cannot be an ancestor (or self) of its new parent." + ); + } + } + } + + /** + * When updating menu properties we need to ensure parents exist + * in the cache map before children. That allows the menus to be + * created in the correct sequence on startup. This reparents the + * tree starting from this instance of MenuItem. + */ + reparentInCache() { + let { id, extension } = this; + let cachedMap = gStartupCache.get(extension); + let createProperties = cachedMap.get(id); + cachedMap.delete(id); + cachedMap.set(id, createProperties); + + for (let child of this.children) { + child.reparentInCache(); + } + } + + set parentId(parentId) { + this.ensureValidParentId(parentId); + + if (this.parent) { + this.parent.detachChild(this); + } + + if (parentId === undefined) { + this.root.addChild(this); + } else { + let menuMap = gMenuMap.get(this.extension); + menuMap.get(parentId).addChild(this); + } + } + + get parentId() { + return this.parent ? this.parent.id : undefined; + } + + addChild(child) { + if (child.parent) { + throw new ExtensionError("Child MenuItem already has a parent."); + } + this.children.push(child); + child.parent = this; + } + + detachChild(child) { + let idx = this.children.indexOf(child); + if (idx < 0) { + throw new ExtensionError( + "Child MenuItem not found, it cannot be removed." + ); + } + this.children.splice(idx, 1); + child.parent = null; + } + + get root() { + let extension = this.extension; + if (!gRootItems.has(extension)) { + let root = new MenuItem( + extension, + { title: extension.name }, + /* isRoot = */ true + ); + gRootItems.set(extension, root); + } + + return gRootItems.get(extension); + } + + remove() { + if (this.parent) { + this.parent.detachChild(this); + } + let children = this.children.slice(0); + for (let child of children) { + child.remove(); + } + + let menuMap = gMenuMap.get(this.extension); + menuMap.delete(this.id); + // Menu items are saved if !extension.persistentBackground. + if (gStartupCache.get(this.extension)?.delete(this.id)) { + StartupCache.save(); + } + if (this.root == this) { + gRootItems.delete(this.extension); + } + } + + async getClickInfo(contextData, wasChecked) { + let info = { + menuItemId: this.id, + }; + if (this.parent) { + info.parentMenuItemId = this.parentId; + } + + await addMenuEventInfo(info, contextData, this.extension, true); + + if (this.type === "checkbox" || this.type === "radio") { + info.checked = this.checked; + info.wasChecked = wasChecked; + } + + return info; + } + + enabledForContext(contextData) { + if (!this.visible) { + return false; + } + let contexts = getMenuContexts(contextData); + if (!this.contexts.some(n => contexts.has(n))) { + return false; + } + + if ( + this.viewTypes && + !this.viewTypes.includes(getContextViewType(contextData)) + ) { + return false; + } + + let docPattern = this.documentUrlMatchPattern; + // When viewTypes is specified, the menu item is expected to be restricted + // to documents. So let documentUrlPatterns always apply to the URL of the + // document in which the menu was opened. When maybeOverrideContextData + // changes the context, contextData.pageUrl does not reflect that URL any + // more, so use contextData.originalViewUrl instead. + if (docPattern && this.viewTypes && contextData.originalViewUrl) { + if ( + !docPattern.matches(Services.io.newURI(contextData.originalViewUrl)) + ) { + return false; + } + docPattern = null; // Null it so that it won't be used with pageURI below. + } + + let pageURI = contextData[contextData.inFrame ? "frameUrl" : "pageUrl"]; + if (pageURI) { + pageURI = Services.io.newURI(pageURI); + if (docPattern && !docPattern.matches(pageURI)) { + return false; + } + } + + let targetPattern = this.targetUrlMatchPattern; + if (targetPattern) { + let targetUrls = []; + if (contextData.onImage || contextData.onAudio || contextData.onVideo) { + // TODO: Double check if srcUrl is always set when we need it. + targetUrls.push(contextData.srcUrl); + } + if (contextData.onLink) { + targetUrls.push(contextData.linkUrl); + } + if ( + !targetUrls.some(targetUrl => + targetPattern.matches(Services.io.newURI(targetUrl)) + ) + ) { + return false; + } + } + + return true; + } +} + +// While any extensions are active, this Tracker registers to observe/listen +// for menu events from both Tools and context menus, both content and chrome. +const menuTracker = { + menuIds: [ + "tabContextMenu", + "folderPaneContext", + "msgComposeAttachmentItemContext", + "taskPopup", + ], + + register() { + Services.obs.addObserver(this, "on-build-contextmenu"); + for (const window of windowTracker.browserWindows()) { + this.onWindowOpen(window); + } + windowTracker.addOpenListener(this.onWindowOpen); + }, + + unregister() { + Services.obs.removeObserver(this, "on-build-contextmenu"); + for (const window of windowTracker.browserWindows()) { + this.cleanupWindow(window); + } + windowTracker.removeOpenListener(this.onWindowOpen); + }, + + observe(subject, topic, data) { + subject = subject.wrappedJSObject; + gMenuBuilder.build(subject); + }, + + onWindowOpen(window) { + // Register the event listener on the window, as some menus we are + // interested in are dynamically created: + // https://hg.mozilla.org/mozilla-central/file/83a21ab93aff939d348468e69249a3a33ccfca88/toolkit/content/editMenuOverlay.js#l96 + window.addEventListener("popupshowing", menuTracker); + }, + + cleanupWindow(window) { + window.removeEventListener("popupshowing", this); + }, + + handleEvent(event) { + const menu = event.target; + const trigger = menu.triggerNode; + const win = menu.ownerGlobal; + switch (menu.id) { + case "taskPopup": { + let info = { menu, inToolsMenu: true }; + if ( + win.document.location.href == + "chrome://messenger/content/messenger.xhtml" + ) { + info.tab = tabTracker.activeTab; + // Calendar and Task view do not have a browser/URL. + info.pageUrl = info.tab.linkedBrowser?.currentURI?.spec; + } else { + info.tab = win; + } + gMenuBuilder.build(info); + break; + } + case "tabContextMenu": { + let triggerTab = trigger.closest("tab"); + const tab = triggerTab || tabTracker.activeTab; + const pageUrl = tab.linkedBrowser?.currentURI?.spec; + gMenuBuilder.build({ menu, tab, pageUrl, onTab: true }); + break; + } + case "folderPaneContext": { + const tab = tabTracker.activeTab; + const pageUrl = tab.linkedBrowser?.currentURI?.spec; + gMenuBuilder.build({ + menu, + tab, + pageUrl, + selectedFolder: win.folderPaneContextMenu.activeFolder, + }); + break; + } + case "attachmentListContext": { + let attachmentList = + menu.ownerGlobal.document.getElementById("attachmentList"); + let allMessageAttachments = [...attachmentList.children].map( + item => item.attachment + ); + gMenuBuilder.build({ + menu, + tab: menu.ownerGlobal, + allMessageAttachments, + }); + break; + } + case "attachmentItemContext": { + let attachmentList = + menu.ownerGlobal.document.getElementById("attachmentList"); + let attachmentInfo = + menu.ownerGlobal.document.getElementById("attachmentInfo"); + + // If we opened the context menu from the attachment info area (the paperclip, + // "1 attachment" label, filename, or file size, just grab the first (and + // only) attachment as our "selected" attachments. + let selectedMessageAttachments; + if ( + menu.triggerNode == attachmentInfo || + menu.triggerNode.parentNode == attachmentInfo + ) { + selectedMessageAttachments = [ + attachmentList.getItemAtIndex(0).attachment, + ]; + } else { + selectedMessageAttachments = [...attachmentList.selectedItems].map( + item => item.attachment + ); + } + + gMenuBuilder.build({ + menu, + tab: menu.ownerGlobal, + selectedMessageAttachments, + }); + break; + } + case "msgComposeAttachmentItemContext": { + let bucket = menu.ownerDocument.getElementById("attachmentBucket"); + let selectedComposeAttachments = []; + for (let item of bucket.itemChildren) { + if (item.selected) { + selectedComposeAttachments.push(item.attachment); + } + } + gMenuBuilder.build({ + menu, + tab: menu.ownerGlobal, + selectedComposeAttachments, + }); + break; + } + default: + // Fall back to the triggerNode. Make sure we are not re-triggered by a + // sub-menu. + if (menu.parentNode.localName == "menu") { + return; + } + if (Object.keys(chromeElementsMap).includes(trigger?.id)) { + let selectionInfo = SelectionUtils.getSelectionDetails(win); + let isContentSelected = !selectionInfo.docSelectionIsCollapsed; + let textSelected = selectionInfo.text; + let isTextSelected = !!textSelected.length; + gMenuBuilder.build({ + menu, + tab: win, + pageUrl: win.browser.currentURI.spec, + onEditable: true, + isContentSelected, + isTextSelected, + onTextInput: true, + originalViewType: "tab", + fieldId: chromeElementsMap[trigger.id], + selectionText: isTextSelected ? selectionInfo.fullText : undefined, + }); + } + break; + } + }, +}; + +this.menus = class extends ExtensionAPIPersistent { + constructor(extension) { + super(extension); + + if (!gMenuMap.size) { + menuTracker.register(); + } + gMenuMap.set(extension, new Map()); + } + + restoreFromCache() { + let { extension } = this; + // ensure extension has not shutdown + if (!this.extension) { + return; + } + for (let createProperties of gStartupCache.get(extension).values()) { + // The order of menu creation is significant, see reparentInCache. + let menuItem = new MenuItem(extension, createProperties); + gMenuMap.get(extension).set(menuItem.id, menuItem); + } + // Used for testing + extension.emit("webext-menus-created", gMenuMap.get(extension)); + } + + async onStartup() { + let { extension } = this; + if (extension.persistentBackground) { + return; + } + // Using the map retains insertion order. + let cachedMenus = await StartupCache.menus.get(extension.id, () => { + return new Map(); + }); + gStartupCache.set(extension, cachedMenus); + if (!cachedMenus.size) { + return; + } + + this.restoreFromCache(); + } + + onShutdown() { + let { extension } = this; + + if (gMenuMap.has(extension)) { + gMenuMap.delete(extension); + gRootItems.delete(extension); + gShownMenuItems.delete(extension); + gStartupCache.delete(extension); + gOnShownSubscribers.delete(extension); + if (!gMenuMap.size) { + menuTracker.unregister(); + } + } + } + + PERSISTENT_EVENTS = { + onShown({ fire }) { + let { extension } = this; + let listener = async (event, menuIds, contextData) => { + let info = { + menuIds, + contexts: Array.from(getMenuContexts(contextData)), + }; + + let nativeTab = contextData.tab; + + // The menus.onShown event is fired before the user has consciously + // interacted with an extension, so we require permissions before + // exposing sensitive contextual data. + let contextUrl = contextData.inFrame + ? contextData.frameUrl + : contextData.pageUrl; + + let ownerDocumentUrl = contextData.menu.ownerDocument.location.href; + + let contextScheme; + if (contextUrl) { + contextScheme = Services.io.newURI(contextUrl).scheme; + } + + let includeSensitiveData = + (nativeTab && + extension.tabManager.hasActiveTabPermission(nativeTab)) || + (contextUrl && extension.allowedOrigins.matches(contextUrl)) || + (MESSAGE_PROTOCOLS.includes(contextScheme) && + extension.hasPermission("messagesRead")) || + (ownerDocumentUrl == + "chrome://messenger/content/messengercompose/messengercompose.xhtml" && + extension.hasPermission("compose")); + + await addMenuEventInfo( + info, + contextData, + extension, + includeSensitiveData + ); + + let tab = nativeTab && extension.tabManager.convert(nativeTab); + fire.sync(info, tab); + }; + gOnShownSubscribers.get(extension).add(listener); + extension.on("webext-menu-shown", listener); + return { + unregister() { + const listeners = gOnShownSubscribers.get(extension); + listeners.delete(listener); + if (listeners.size === 0) { + gOnShownSubscribers.delete(extension); + } + extension.off("webext-menu-shown", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onHidden({ fire }) { + let { extension } = this; + let listener = () => { + fire.sync(); + }; + extension.on("webext-menu-hidden", listener); + return { + unregister() { + extension.off("webext-menu-hidden", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onClicked({ context, fire }) { + let { extension } = this; + let listener = async (event, info, nativeTab) => { + let { linkedBrowser } = nativeTab || tabTracker.activeTab; + let tab = nativeTab && extension.tabManager.convert(nativeTab); + if (fire.wakeup) { + // force the wakeup, thus the call to convert to get the context. + await fire.wakeup(); + // If while waiting the tab disappeared we bail out. + if ( + !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser) + ) { + console.error( + `menus.onClicked: target tab closed during background startup.` + ); + return; + } + } + context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab)); + }; + + extension.on("webext-menu-menuitem-click", listener); + return { + unregister() { + extension.off("webext-menu-menuitem-click", listener); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + + return { + menus: { + refresh() { + gMenuBuilder.rebuildMenu(extension); + }, + + onShown: new EventManager({ + context, + module: "menus", + event: "onShown", + extensionApi: this, + }).api(), + onHidden: new EventManager({ + context, + module: "menus", + event: "onHidden", + extensionApi: this, + }).api(), + onClicked: new EventManager({ + context, + module: "menus", + event: "onClicked", + extensionApi: this, + }).api(), + + create(createProperties) { + // event pages require id + if (!extension.persistentBackground) { + if (!createProperties.id) { + throw new ExtensionError( + "menus.create requires an id for non-persistent background scripts." + ); + } + if (gMenuMap.get(extension).has(createProperties.id)) { + throw new ExtensionError( + `The menu id ${createProperties.id} already exists in menus.create.` + ); + } + } + + // Note that the id is required by the schema. If the addon did not set + // it, the implementation of menus.create in the child will add it for + // extensions with persistent backgrounds, but not otherwise. + let menuItem = new MenuItem(extension, createProperties); + gMenuMap.get(extension).set(menuItem.id, menuItem); + if (!extension.persistentBackground) { + // Only cache properties that are necessary. + let cached = {}; + MenuItem.mergeProps(cached, createProperties); + gStartupCache.get(extension).set(menuItem.id, cached); + StartupCache.save(); + } + }, + + update(id, updateProperties) { + let menuItem = gMenuMap.get(extension).get(id); + if (!menuItem) { + return; + } + menuItem.setProps(updateProperties); + + // Update the startup cache for non-persistent extensions. + if (extension.persistentBackground) { + return; + } + + let cached = gStartupCache.get(extension).get(id); + let reparent = + updateProperties.parentId != null && + cached.parentId != updateProperties.parentId; + MenuItem.mergeProps(cached, updateProperties); + if (reparent) { + // The order of menu creation is significant, see reparentInCache. + menuItem.reparentInCache(); + } + StartupCache.save(); + }, + + remove(id) { + let menuItem = gMenuMap.get(extension).get(id); + if (menuItem) { + menuItem.remove(); + } + }, + + removeAll() { + let root = gRootItems.get(extension); + if (root) { + root.remove(); + } + // Should be empty, just extra assurance. + if (!extension.persistentBackground) { + let cached = gStartupCache.get(extension); + if (cached.size) { + cached.clear(); + StartupCache.save(); + } + } + }, + }, + }; + } +}; |