/* # 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 EventListenerManager from '/extlib/EventListenerManager.js'; import { log as internalLogger, mapAndFilter, configs, sanitizeForHTMLText } from '/common/common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Constants from '/common/constants.js'; import * as ContextualIdentities from '/common/contextual-identities.js'; import * as SidebarConnection from '/common/sidebar-connection.js'; import * as Sync from '/common/sync.js'; import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; import * as TabsStore from '/common/tabs-store.js'; import * as TreeBehavior from '/common/tree-behavior.js'; import * as TSTAPI from '/common/tst-api.js'; import { Tab, TreeItem } from '/common/TreeItem.js'; import * as Commands from './commands.js'; import * as NativeTabGroups from './native-tab-groups.js'; import * as TabsOpen from './tabs-open.js'; function log(...args) { internalLogger('background/tab-context-menu', ...args); } export const onTSTItemClick = new EventListenerManager(); export const onTSTTabContextMenuShown = new EventListenerManager(); export const onTSTTabContextMenuHidden = new EventListenerManager(); export const onTopLevelItemAdded = new EventListenerManager(); const EXTERNAL_TOP_LEVEL_ITEM_MATCHER = /^external-top-level-item:([^:]+):(.+)$/; function getExternalTopLevelItemId(ownerId, itemId) { return `external-top-level-item:${ownerId}:${itemId}`; } const SAFE_MENU_PROPERTIES = [ 'checked', 'enabled', 'icons', 'parentId', 'title', 'type', 'visible' ]; const mItemsById = { 'context_newTab': { title: browser.i18n.getMessage('tabContextMenu_newTab_label'), titleTab: browser.i18n.getMessage('tabContextMenu_newTabNext_label'), }, 'context_newGroup': { title: browser.i18n.getMessage('tabContextMenu_newGroup_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_newGroup_label_multiselected') }, 'context_addToGroup': { title: browser.i18n.getMessage('tabContextMenu_addToGroup_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_addToGroup_label_multiselected') }, 'context_addToGroup_newGroup': { parentId: 'context_addToGroup', title: browser.i18n.getMessage('tabContextMenu_addToGroup_newGroup_label'), }, 'context_addToGroup_separator:afterNewGroup': { parentId: 'context_addToGroup', type: 'separator', }, 'context_removeFromGroup': { title: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label_multiselected') }, 'context_separator:afterNewTab': { type: 'separator' }, 'context_reloadTab': { title: browser.i18n.getMessage('tabContextMenu_reload_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_reload_label_multiselected') }, 'context_topLevel_reloadTree': { title: browser.i18n.getMessage('context_reloadTree_label'), titleMultiselected: browser.i18n.getMessage('context_reloadTree_label_multiselected') }, 'context_topLevel_reloadDescendants': { title: browser.i18n.getMessage('context_reloadDescendants_label'), titleMultiselected: browser.i18n.getMessage('context_reloadDescendants_label_multiselected') }, // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. 'context_unblockAutoplay': { title: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label_multiselected') }, // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. 'context_topLevel_unblockAutoplayTree': { title: browser.i18n.getMessage('context_unblockAutoplayTree_label'), titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayTree_label_multiselected'), }, // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. 'context_topLevel_unblockAutoplayDescendants': { title: browser.i18n.getMessage('context_unblockAutoplayDescendants_label'), titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayDescendants_label_multiselected'), }, 'context_toggleMuteTab': { titleMute: browser.i18n.getMessage('tabContextMenu_mute_label'), titleUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label'), titleMultiselectedMute: browser.i18n.getMessage('tabContextMenu_mute_label_multiselected'), titleMultiselectedUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label_multiselected') }, 'context_topLevel_toggleMuteTree': { titleMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_mute'), titleMultiselectedMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_mute'), titleUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_unmute'), titleMultiselectedUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_unmute') }, 'context_topLevel_toggleMuteDescendants': { titleMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_mute'), titleMultiselectedMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_mute'), titleUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_unmute'), titleMultiselectedUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_unmute') }, 'context_pinTab': { title: browser.i18n.getMessage('tabContextMenu_pin_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_pin_label_multiselected') }, 'context_unpinTab': { title: browser.i18n.getMessage('tabContextMenu_unpin_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_unpin_label_multiselected') }, 'context_topLevel_toggleSticky': { titleStick: browser.i18n.getMessage('context_toggleSticky_label_stick'), titleMultiselectedStick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_stick'), titleUnstick: browser.i18n.getMessage('context_toggleSticky_label_unstick'), titleMultiselectedUnstick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_unstick') }, 'context_unloadTab': { title: browser.i18n.getMessage('tabContextMenu_unload_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_unload_label_multiselected'), }, 'context_duplicateTab': { title: browser.i18n.getMessage('tabContextMenu_duplicate_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_duplicate_label_multiselected') }, 'context_separator:afterDuplicate': { type: 'separator' }, 'context_bookmarkTab': { title: browser.i18n.getMessage('tabContextMenu_bookmark_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmark_label_multiselected') }, 'context_topLevel_bookmarkTree': { title: browser.i18n.getMessage('context_bookmarkTree_label'), titleMultiselected: browser.i18n.getMessage('context_bookmarkTree_label_multiselected') }, 'context_moveTab': { title: browser.i18n.getMessage('tabContextMenu_moveTab_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_moveTab_label_multiselected') }, 'context_moveTabToStart': { parentId: 'context_moveTab', title: browser.i18n.getMessage('tabContextMenu_moveTabToStart_label') }, 'context_moveTabToEnd': { parentId: 'context_moveTab', title: browser.i18n.getMessage('tabContextMenu_moveTabToEnd_label') }, 'context_openTabInWindow': { parentId: 'context_moveTab', title: browser.i18n.getMessage('tabContextMenu_tearOff_label') }, 'context_shareTabURL': { title: browser.i18n.getMessage('tabContextMenu_shareTabURL_label'), }, 'context_sendTabsToDevice': { title: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label_multiselected') }, 'context_topLevel_sendTreeToDevice': { title: browser.i18n.getMessage('context_sendTreeToDevice_label'), titleMultiselected: browser.i18n.getMessage('context_sendTreeToDevice_label_multiselected') }, 'context_reopenInContainer': { title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_label') }, 'context_selectAllTabs': { title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label') }, 'context_separator:afterSelectAllTabs': { type: 'separator' }, 'context_topLevel_collapseTree': { title: browser.i18n.getMessage('context_collapseTree_label'), titleMultiselected: browser.i18n.getMessage('context_collapseTree_label_multiselected') }, 'context_topLevel_collapseTreeRecursively': { title: browser.i18n.getMessage('context_collapseTreeRecursively_label'), titleMultiselected: browser.i18n.getMessage('context_collapseTreeRecursively_label_multiselected') }, 'context_topLevel_collapseAll': { title: browser.i18n.getMessage('context_collapseAll_label') }, 'context_topLevel_expandTree': { title: browser.i18n.getMessage('context_expandTree_label'), titleMultiselected: browser.i18n.getMessage('context_expandTree_label_multiselected') }, 'context_topLevel_expandTreeRecursively': { title: browser.i18n.getMessage('context_expandTreeRecursively_label'), titleMultiselected: browser.i18n.getMessage('context_expandTreeRecursively_label_multiselected') }, 'context_topLevel_expandAll': { title: browser.i18n.getMessage('context_expandAll_label') }, 'context_separator:afterCollapseExpand': { type: 'separator' }, 'context_closeTab': { title: browser.i18n.getMessage('tabContextMenu_close_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_close_label_multiselected') }, 'context_closeDuplicatedTabs': { title: browser.i18n.getMessage('tabContextMenu_closeDuplicatedTabs_label') }, 'context_closeMultipleTabs': { title: browser.i18n.getMessage('tabContextMenu_closeMultipleTabs_label') }, 'context_closeTabsToTheStart': { parentId: 'context_closeMultipleTabs', title: browser.i18n.getMessage('tabContextMenu_closeTabsToTop_label') }, 'context_closeTabsToTheEnd': { parentId: 'context_closeMultipleTabs', title: browser.i18n.getMessage('tabContextMenu_closeTabsToBottom_label') }, 'context_closeOtherTabs': { parentId: 'context_closeMultipleTabs', title: browser.i18n.getMessage('tabContextMenu_closeOther_label') }, 'context_topLevel_closeTree': { title: browser.i18n.getMessage('context_closeTree_label'), titleMultiselected: browser.i18n.getMessage('context_closeTree_label_multiselected') }, 'context_topLevel_closeDescendants': { title: browser.i18n.getMessage('context_closeDescendants_label'), titleMultiselected: browser.i18n.getMessage('context_closeDescendants_label_multiselected') }, 'context_topLevel_closeOthers': { title: browser.i18n.getMessage('context_closeOthers_label'), titleMultiselected: browser.i18n.getMessage('context_closeOthers_label_multiselected') }, 'context_undoCloseTab': { title: browser.i18n.getMessage('tabContextMenu_undoClose_label'), titleRegular: browser.i18n.getMessage('tabContextMenu_undoClose_label'), titleMultipleTabsRestorable: browser.i18n.getMessage('tabContextMenu_undoClose_label_multiple') }, 'context_separator:afterClose': { type: 'separator' }, 'noContextTab:context_reloadTab': { title: browser.i18n.getMessage('tabContextMenu_reloadSelected_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_reloadSelected_label_multiselected'), }, 'noContextTab:context_bookmarkSelected': { title: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label'), titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label_multiselected'), }, 'noContextTab:context_selectAllTabs': { title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label') }, 'noContextTab:context_undoCloseTab': { title: browser.i18n.getMessage('tabContextMenu_undoClose_label') }, 'lastSeparatorBeforeExtraItems': { type: 'separator', fakeMenu: true } }; const mExtraItems = new Map(); const SIDEBAR_URL_PATTERN = [`${Constants.kSHORTHAND_URIS.tabbar}*`]; let mInitialized = false; browser.runtime.onMessage.addListener(onMessage); browser.menus.onShown.addListener(onShown); browser.menus.onHidden.addListener(onHidden); browser.menus.onClicked.addListener(onClick); TSTAPI.onMessageExternal.addListener(onMessageExternal); function getItemPlacementSignature(item) { if (item.placementSignature) return item.placementSignature; return item.placementSignature = JSON.stringify({ parentId: item.parentId }); } export async function init() { mInitialized = true; window.addEventListener('unload', () => { browser.runtime.onMessage.removeListener(onMessage); TSTAPI.onMessageExternal.removeListener(onMessageExternal); }, { once: true }); const itemIds = Object.keys(mItemsById); for (const id of itemIds) { const item = mItemsById[id]; item.id = id; item.lastTitle = item.title; item.lastVisible = false; item.lastEnabled = true; if (item.type == 'separator') { let beforeSeparator = true; item.precedingItems = []; item.followingItems = []; for (const id of itemIds) { const possibleSibling = mItemsById[id]; if (getItemPlacementSignature(item) != getItemPlacementSignature(possibleSibling)) { if (beforeSeparator) continue; else break; } if (id == item.id) { beforeSeparator = false; continue; } if (beforeSeparator) { if (possibleSibling.type == 'separator') { item.previousSeparator = possibleSibling; item.precedingItems = []; } else { item.precedingItems.push(id); } } else { if (possibleSibling.type == 'separator') break; else item.followingItems.push(id); } } } const info = { id, title: item.title, type: item.type || 'normal', contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], visible: false, // hide all by default documentUrlPatterns: SIDEBAR_URL_PATTERN }; if (item.parentId) info.parentId = item.parentId; if (!item.fakeMenu) browser.menus.create(info); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: info }, browser.runtime); } onTSTItemClick.addListener(onClick); await ContextualIdentities.init(); updateContextualIdentities(); ContextualIdentities.onUpdated.addListener(() => { updateContextualIdentities(); }); } // Workaround for https://github.com/piroor/treestyletab/issues/3423 // Firefox does not provide any API to access to the sharing service of the platform. // We need to provide it as experiments API or something way. // This module is designed to work with a service which has features: // * async listServices(tab) // - Returns an array of sharing services on macOS. // - Retruned array should have 0 or more items like: // { name: "service name", // title: "title for a menu item", // image: "icon image (maybe data: URI)" } // * share(tab, shareName = null) // - Returns nothing. // - Shares the specified tab with the specified service. // The second argument is optional because it is required only on macOS. // * openPreferences() // - Returns nothing. // - Opens preferences of sharing services on macOS. let mSharingService = null; export function registerSharingService(service) { mSharingService = service; } let mMultipleTabsRestorable = false; Tab.onChangeMultipleTabsRestorability.addListener(multipleTabsRestorable => { mMultipleTabsRestorable = multipleTabsRestorable; }); const mNativeTabGroupItems = new Set(); function updateNativeTabGroups(contextTab) { if (!contextTab || !configs.tabGroupsEnabled) { return; } for (const item of mNativeTabGroupItems) { const id = item.id; if (id in mItemsById) delete mItemsById[id]; browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_REMOVE, params: id }, browser.runtime); } mNativeTabGroupItems.clear(); updateItem('context_addToGroup_newGroup', { visible: true, }); updateItem('context_addToGroup_separator:afterNewGroup', { visible: true, }); const defaultTitle = browser.i18n.getMessage('tabContextMenu_addToGroup_unnamed_label'); const darkSuffix = window.matchMedia('(prefers-color-scheme: dark)').matches ? '-invert' : ''; const groups = getEffectiveTabGroups(contextTab.windowId); for (const group of groups) { if (contextTab.groupId == group.id) { continue; } const id = `context_addToGroup:group:${group.id}`; const item = { id, parentId: 'context_addToGroup', title: group.title || defaultTitle, icons: { 16: `/resources/icons/tab-group-chicklet.svg#${group.color}${darkSuffix}` }, contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN, }; browser.menus.create(item); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: item }, browser.runtime); mNativeTabGroupItems.add(item); mItemsById[item.id] = item; item.lastVisible = true; item.lastEnabled = true; } } function getEffectiveTabGroups(windowId) { return TreeItem.sort( [...TabsStore.windows.get(windowId).tabGroups.values()] .filter(group => !!group.$TST.firstMember) ); } const mContextualIdentityItems = new Set(); function updateContextualIdentities() { for (const item of mContextualIdentityItems) { const id = item.id; if (id in mItemsById) delete mItemsById[id]; browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_REMOVE, params: id }, browser.runtime); } mContextualIdentityItems.clear(); const defaultItem = { parentId: 'context_reopenInContainer', id: 'context_reopenInContainer:firefox-default', title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_noContainer_label'), contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN }; browser.menus.create(defaultItem); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: defaultItem }, browser.runtime); mContextualIdentityItems.add(defaultItem); const defaultSeparator = { parentId: 'context_reopenInContainer', id: 'context_reopenInContainer_separator', type: 'separator', contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN }; browser.menus.create(defaultSeparator); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: defaultSeparator }, browser.runtime); mContextualIdentityItems.add(defaultSeparator); ContextualIdentities.forEach(identity => { const id = `context_reopenInContainer:${identity.cookieStoreId}`; const item = { parentId: 'context_reopenInContainer', id: id, title: identity.name.replace(/^([a-z0-9])/i, '&$1'), contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN }; if (identity.iconUrl) item.icons = { 16: identity.iconUrl }; browser.menus.create(item); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: item }, browser.runtime); mContextualIdentityItems.add(item); mItemsById[item.id] = item; item.lastVisible = true; item.lastEnabled = true; }); } const mLastDevicesSignature = new Map(); const mSendToDeviceItems = new Map(); export async function updateSendToDeviceItems(parentId, { manage } = {}) { const devices = await Sync.getOtherDevices(); const signature = JSON.stringify(devices.map(device => ({ id: device.id, name: device.name }))); if (signature == mLastDevicesSignature.get(parentId)) return false; mLastDevicesSignature.set(parentId, signature); const items = mSendToDeviceItems.get(parentId) || new Set(); for (const item of items) { const id = item.id; browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_REMOVE, params: id }, browser.runtime); } items.clear(); const baseParams = { parentId, contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN }; if (devices.length > 0) { for (const device of devices) { const item = { ...baseParams, type: 'normal', id: `${parentId}:device:${device.id}`, title: device.name }; if (device.icon) item.icons = { '16': `/resources/icons/${sanitizeForHTMLText(device.icon)}.svg` }; browser.menus.create(item); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: item }, browser.runtime); items.add(item); } const separator = { ...baseParams, type: 'separator', id: `${parentId}:separator` }; browser.menus.create(separator); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: separator }, browser.runtime); items.add(separator); const sendToAllItem = { ...baseParams, type: 'normal', id: `${parentId}:all`, title: browser.i18n.getMessage('tabContextMenu_sendTabsToAllDevices_label') }; browser.menus.create(sendToAllItem); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: sendToAllItem }, browser.runtime); items.add(sendToAllItem); } if (manage) { const manageItem = { ...baseParams, type: 'normal', id: `${parentId}:manage`, title: browser.i18n.getMessage('tabContextMenu_manageSyncDevices_label') }; browser.menus.create(manageItem); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: manageItem }, browser.runtime); items.add(manageItem); } mSendToDeviceItems.set(parentId, items); return true; } const mLastSharingServicesSignature = new Map(); const mShareItems = new Map(); async function updateSharingServiceItems(parentId, contextTab) { if (!mSharingService || !contextTab) return false; const services = await mSharingService.listServices(contextTab); const signature = JSON.stringify(services); if (signature == mLastSharingServicesSignature.get(parentId)) return false; mLastSharingServicesSignature.set(parentId, signature); const items = mShareItems.get(parentId) || new Set(); for (const item of items) { const id = item.id; browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_REMOVE, params: id }, browser.runtime); } items.clear(); const baseParams = { parentId, contexts: ['tab'], viewTypes: ['sidebar', 'tab', 'popup'], documentUrlPatterns: SIDEBAR_URL_PATTERN }; if (services.length > 0) { for (const service of services) { const item = { ...baseParams, type: 'normal', id: `${parentId}:service:${service.name}`, title: service.title, }; if (service.image) item.icons = { '16': service.image, }; browser.menus.create(item); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: item, }, browser.runtime); items.add(item); } const separator = { ...baseParams, type: 'separator', id: `${parentId}:separator`, }; browser.menus.create(separator); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: separator, }, browser.runtime); items.add(separator); const moreItem = { ...baseParams, type: 'normal', id: `${parentId}:more`, title: browser.i18n.getMessage('tabContextMenu_shareTabURL_more_label'), icons: { '16': '/resources/icons/more-horiz-16.svg', }, }; browser.menus.create(moreItem); onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_CREATE, params: moreItem, }, browser.runtime); items.add(moreItem); } mShareItems.set(parentId, items); return true; } function updateItem(id, state = {}) { let modified = false; const item = mItemsById[id]; if (!item) { return false; } const updateInfo = { visible: 'visible' in state ? !!state.visible : true, enabled: 'enabled' in state ? !!state.enabled : true }; if ('checked' in state) updateInfo.checked = state.checked; const title = String( (state.tab && state.titleTab) || state.title || (state.multiselected && item.titleMultiselected) || item.title ).replace(/%S/g, state.count || 0); if (title) { updateInfo.title = title; modified = title != item.lastTitle; item.lastTitle = updateInfo.title; } if (!modified) modified = updateInfo.visible != item.lastVisible || updateInfo.enabled != item.lastEnabled; item.lastVisible = updateInfo.visible; item.lastEnabled = updateInfo.enabled; if (!item.fakeMenu) { browser.menus.update(id, updateInfo).catch(ApiTabs.createErrorSuppressor()); } onMessageExternal({ type: TSTAPI.kCONTEXT_MENU_UPDATE, params: [id, updateInfo] }, browser.runtime); return modified; } function updateSeparator(id, options = {}) { const item = mItemsById[id]; const visible = ( (options.hasVisiblePreceding || hasVisiblePrecedingItem(item)) && (options.hasVisibleFollowing || item.followingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible)) ); return updateItem(id, { visible }); } function hasVisiblePrecedingItem(separator) { return ( separator.precedingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible) || (separator.previousSeparator && !separator.previousSeparator.lastVisible && hasVisiblePrecedingItem(separator.previousSeparator)) ); } let mOverriddenContext = null; let mLastContextTabId = null; async function onShown(info, contextTab) { if (!mInitialized) return; const contextTabId = contextTab?.id; mLastContextTabId = contextTabId; try { contextTab = Tab.get(contextTabId); const windowId = contextTab ? contextTab.windowId : (await browser.windows.getLastFocused({}).catch(ApiTabs.createErrorHandler())).id; if (mLastContextTabId != contextTabId) return; // Skip further operations if the menu was already reopened on a different context tab. const previousTab = contextTab?.$TST.previousTab; const previousSiblingTab = contextTab?.$TST.previousSiblingTab; const nextTab = contextTab?.$TST.nextTab; const nextSiblingTab = contextTab?.$TST.nextSiblingTab; const hasDuplicatedTabs = Tab.hasDuplicatedTabs(windowId); const hasMultipleTabs = Tab.hasMultipleTabs(windowId); const hasMultipleNormalTabs = Tab.hasMultipleTabs(windowId, { normal: true }); const multiselected = contextTab?.$TST.multiselected; const contextTabs = multiselected ? Tab.getSelectedTabs(windowId) : contextTab ? [contextTab] : []; const hasChild = contextTab && contextTabs.some(tab => tab.$TST.hasChild); const { hasUnmutedTab, hasUnmutedDescendant } = Commands.getUnmutedState(contextTabs); const { hasAutoplayBlockedTab, hasAutoplayBlockedDescendant } = Commands.getAutoplayBlockedState(contextTabs); const hasChoosableNativeTabGroup = contextTab && getEffectiveTabGroups(windowId).filter(group => group.id != contextTab.groupId).length > 0; if (mOverriddenContext) return onOverriddenMenuShown(info, contextTab, windowId); let modifiedItemsCount = cleanupOverriddenMenu(); // ESLint reports "short circuit" error for following codes. // https://eslint.org/docs/rules/no-unused-expressions#allowshortcircuit // To allow those usages, I disable the rule temporarily. /* eslint-disable no-unused-expressions */ const emulate = configs.emulateDefaultContextMenu; updateItem('context_newTab', { visible: emulate, tab: !!contextTab, }) && modifiedItemsCount++; updateItem('context_newGroup', { visible: emulate && configs.tabGroupsEnabled && !!contextTab && !hasChoosableNativeTabGroup, multiselected }) && modifiedItemsCount++; updateItem('context_addToGroup', { visible: emulate && configs.tabGroupsEnabled && !!contextTab && hasChoosableNativeTabGroup, multiselected }) && modifiedItemsCount++; updateItem('context_removeFromGroup', { visible: emulate && configs.tabGroupsEnabled && !!contextTab && contextTab.groupId != -1, multiselected }) && modifiedItemsCount++; updateItem('context_separator:afterNewTab', { visible: emulate, }) && modifiedItemsCount++; updateItem('context_reloadTab', { visible: emulate && !!contextTab, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_reloadTree', { visible: emulate && !!contextTab && configs.context_topLevel_reloadTree, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_reloadDescendants', { visible: emulate && !!contextTab && configs.context_topLevel_reloadDescendants, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_unblockAutoplay', { visible: emulate && contextTab?.$TST.autoplayBlocked, multiselected, title: contextTab && Commands.getMenuItemTitle(mItemsById.context_unblockAutoplay, { multiselected, }), }) && modifiedItemsCount++; updateItem('context_topLevel_unblockAutoplayTree', { visible: emulate && hasChild && hasAutoplayBlockedTab && configs.context_topLevel_unblockAutoplayTree, multiselected, title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayTree, { multiselected, }), }) && modifiedItemsCount++; updateItem('context_topLevel_unblockAutoplayDescendants', { visible: emulate && hasChild && hasAutoplayBlockedDescendant && configs.context_topLevel_unblockAutoplayDescendants, multiselected, title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayDescendants, { multiselected, }), }) && modifiedItemsCount++; updateItem('context_toggleMuteTab', { visible: emulate && !!contextTab, multiselected, title: contextTab && Commands.getMenuItemTitle(mItemsById.context_toggleMuteTab, { multiselected, unmuted: (!contextTab.mutedInfo || !contextTab.mutedInfo.muted), }), }) && modifiedItemsCount++; updateItem('context_topLevel_toggleMuteTree', { visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteTree, enabled: hasChild, multiselected, title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteTree, { multiselected, hasUnmutedTab, hasUnmutedDescendant }), hasUnmutedTab, hasUnmutedDescendant, }) && modifiedItemsCount++; updateItem('context_topLevel_toggleMuteDescendants', { visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteDescendants, enabled: hasChild, multiselected, title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteDescendants, { multiselected, hasUnmutedTab, hasUnmutedDescendant }), hasUnmutedTab, hasUnmutedDescendant, }) && modifiedItemsCount++; updateItem('context_pinTab', { visible: emulate && !!contextTab && !contextTab.pinned, multiselected }) && modifiedItemsCount++; updateItem('context_unpinTab', { visible: emulate && !!contextTab?.pinned, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_toggleSticky', { visible: emulate && !!contextTab, enabled: contextTab && !contextTab.pinned, multiselected, title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleSticky, { multiselected, sticky: contextTab?.$TST.sticky, }), }) && modifiedItemsCount++; const unloadableCount = Commands.filterUnloadableTabs(contextTabs).length; updateItem('context_unloadTab', { visible: emulate && unloadableCount > 0, multiselected: unloadableCount > 1, count: unloadableCount, }) && modifiedItemsCount++; updateItem('context_duplicateTab', { visible: emulate && !!contextTab, multiselected }) && modifiedItemsCount++; updateItem('context_bookmarkTab', { visible: emulate && !!contextTab, multiselected: multiselected || !contextTab }) && modifiedItemsCount++; updateItem('context_topLevel_bookmarkTree', { visible: emulate && !!contextTab && configs.context_topLevel_bookmarkTree, multiselected }) && modifiedItemsCount++; updateItem('context_moveTab', { visible: emulate && !!contextTab, enabled: contextTab && hasMultipleTabs, multiselected }) && modifiedItemsCount++; updateItem('context_moveTabToStart', { enabled: emulate && !!contextTab && hasMultipleTabs && (previousSiblingTab || previousTab) && ((previousSiblingTab || previousTab).pinned == contextTab.pinned), multiselected }) && modifiedItemsCount++; updateItem('context_moveTabToEnd', { enabled: emulate && !!contextTab && hasMultipleTabs && (nextSiblingTab || nextTab) && ((nextSiblingTab || nextTab).pinned == contextTab.pinned), multiselected }) && modifiedItemsCount++; updateItem('context_openTabInWindow', { enabled: emulate && !!contextTab && hasMultipleTabs, multiselected }) && modifiedItemsCount++; // Not implemented yet as a built-in. See also: https://github.com/piroor/treestyletab/issues/3423 updateItem('context_shareTabURL', { visible: emulate && !!contextTab && mSharingService && Sync.isSendableTab(contextTab), }) && modifiedItemsCount++; updateItem('context_sendTabsToDevice', { visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0, multiselected, count: contextTabs.length }) && modifiedItemsCount++; updateItem('context_topLevel_sendTreeToDevice', { visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0 && configs.context_topLevel_sendTreeToDevice && hasChild, enabled: hasChild, multiselected }) && modifiedItemsCount++; let showContextualIdentities = false; if (contextTab && !contextTab.incognito) { for (const item of mContextualIdentityItems.values()) { const id = item.id; let visible; if (!emulate) visible = false; else if (id == 'context_reopenInContainer_separator') visible = !!contextTab && contextTab.cookieStoreId != 'firefox-default'; else visible = !!contextTab && id != `context_reopenInContainer:${contextTab.cookieStoreId}`; updateItem(id, { visible }) && modifiedItemsCount++; if (visible) showContextualIdentities = true; } } updateItem('context_reopenInContainer', { visible: emulate && !!contextTab && showContextualIdentities && !contextTab.incognito, enabled: contextTabs.every(tab => TabsOpen.isOpenable(tab.url)), multiselected }) && modifiedItemsCount++; updateItem('context_selectAllTabs', { visible: emulate && !!contextTab, enabled: contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_collapseTree', { visible: emulate && !!contextTab && configs.context_topLevel_collapseTree, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_collapseTreeRecursively', { visible: emulate && !!contextTab && configs.context_topLevel_collapseTreeRecursively, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_collapseAll', { visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_collapseAll }) && modifiedItemsCount++; updateItem('context_topLevel_expandTree', { visible: emulate && !!contextTab && configs.context_topLevel_expandTree, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_expandTreeRecursively', { visible: emulate && !!contextTab && configs.context_topLevel_expandTreeRecursively, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_expandAll', { visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_expandAll }) && modifiedItemsCount++; updateItem('context_closeTab', { visible: emulate && !!contextTab, multiselected, count: contextTabs.length }) && modifiedItemsCount++; updateItem('context_closeDuplicatedTabs', { visible: emulate && !!contextTab, enabled: hasDuplicatedTabs && !multiselected, multiselected }) && modifiedItemsCount++; updateItem('context_closeMultipleTabs', { visible: emulate && !!contextTab, enabled: hasMultipleNormalTabs, multiselected }) && modifiedItemsCount++; updateItem('context_closeTabsToTheStart', { visible: emulate && !!contextTab, enabled: nextTab, multiselected }) && modifiedItemsCount++; updateItem('context_closeTabsToTheEnd', { visible: emulate && !!contextTab, enabled: nextTab, multiselected }) && modifiedItemsCount++; updateItem('context_closeOtherTabs', { visible: emulate && !!contextTab, enabled: hasMultipleNormalTabs, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_closeTree', { visible: emulate && !!contextTab && configs.context_topLevel_closeTree, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_closeDescendants', { visible: emulate && !!contextTab && configs.context_topLevel_closeDescendants, enabled: hasChild, multiselected }) && modifiedItemsCount++; updateItem('context_topLevel_closeOthers', { visible: emulate && !!contextTab && configs.context_topLevel_closeOthers, multiselected }) && modifiedItemsCount++; const undoCloseTabLabel = mItemsById.context_undoCloseTab[configs.undoMultipleTabsClose && mMultipleTabsRestorable ? 'titleMultipleTabsRestorable' : 'titleRegular']; updateItem('context_undoCloseTab', { title: undoCloseTabLabel, visible: emulate && !!contextTab, multiselected }) && modifiedItemsCount++; updateItem('noContextTab:context_reloadTab', { visible: emulate && !contextTab }) && modifiedItemsCount++; updateItem('noContextTab:context_bookmarkSelected', { visible: emulate && !contextTab }) && modifiedItemsCount++; updateItem('noContextTab:context_selectAllTabs', { visible: emulate && !contextTab, enabled: !contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length }) && modifiedItemsCount++; updateItem('noContextTab:context_undoCloseTab', { title: undoCloseTabLabel, visible: emulate && !contextTab }) && modifiedItemsCount++; updateSeparator('context_separator:afterDuplicate') && modifiedItemsCount++; updateSeparator('context_separator:afterSelectAllTabs') && modifiedItemsCount++; updateSeparator('context_separator:afterCollapseExpand') && modifiedItemsCount++; updateSeparator('context_separator:afterClose') && modifiedItemsCount++; const flattenExtraItems = Array.from(mExtraItems.values()).flat(); updateSeparator('lastSeparatorBeforeExtraItems', { hasVisibleFollowing: contextTab && flattenExtraItems.some(item => !item.parentId && item.visible !== false) }) && modifiedItemsCount++; // these items should be updated at the last to reduce flicking of showing context menu await Promise.all([ updateNativeTabGroups(contextTab), updateSendToDeviceItems('context_sendTabsToDevice', { manage: true }), mItemsById.context_topLevel_sendTreeToDevice.lastVisible && updateSendToDeviceItems('context_topLevel_sendTreeToDevice'), modifiedItemsCount > 0 && browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()).then(_ => false), updateSharingServiceItems('context_shareTabURL', contextTab), ]).then(results => { modifiedItemsCount = 0; for (const modified of results) { if (modified) modifiedItemsCount++; } }); if (mLastContextTabId != contextTabId) return; // Skip further operations if the menu was already reopened on a different context tab. /* eslint-enable no-unused-expressions */ if (modifiedItemsCount > 0) browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); } catch(error) { console.error(error); } } let mLastOverriddenContextOwner = null; function onOverriddenMenuShown(info, contextTab, windowId) { if (!mLastOverriddenContextOwner) { for (const itemId of Object.keys(mItemsById)) { if (!mItemsById[itemId].lastVisible) continue; mItemsById[itemId].lastVisible = false; browser.menus.update(itemId, { visible: false }); } mLastOverriddenContextOwner = mOverriddenContext.owner; } for (const item of (mExtraItems.get(mLastOverriddenContextOwner) || [])) { if (item.$topLevel && item.lastVisible) { browser.menus.update( getExternalTopLevelItemId(mOverriddenContext.owner, item.id), { visible: true } ); } } const cache = {}; const message = { type: TSTAPI.kCONTEXT_MENU_SHOWN, info: { bookmarkId: info.bookmarkId || null, button: info.button, checked: info.checked, contexts: contextTab ? ['tab'] : info.bookmarkId ? ['bookmark'] : [], editable: false, frameId: null, frameUrl: null, linkText: null, linkUrl: null, mediaType: null, menuIds: [], menuItemId: null, modifiers: [], pageUrl: null, parentMenuItemId: null, selectionText: null, srcUrl: null, targetElementId: null, viewType: 'sidebar', wasChecked: false }, tab: contextTab, windowId } TSTAPI.broadcastMessage(message, { targets: [mOverriddenContext.owner], tabProperties: ['tab'], isContextTab: true, cache, }); TSTAPI.broadcastMessage({ ...message, type: TSTAPI.kFAKE_CONTEXT_MENU_SHOWN }, { targets: [mOverriddenContext.owner], tabProperties: ['tab'] }); reserveRefresh(); } function cleanupOverriddenMenu() { if (!mLastOverriddenContextOwner) return 0; let modifiedItemsCount = 0; const owner = mLastOverriddenContextOwner; mLastOverriddenContextOwner = null; for (const itemId of Object.keys(mItemsById)) { if (!mItemsById[itemId].lastVisible) continue; mItemsById[itemId].lastVisible = true; browser.menus.update(itemId, { visible: true }); modifiedItemsCount++; } for (const item of (mExtraItems.get(owner) || [])) { if (item.$topLevel && item.lastVisible) { browser.menus.update( getExternalTopLevelItemId(owner, item.id), { visible: false } ); modifiedItemsCount++; } } return modifiedItemsCount; } function onHidden() { if (!mInitialized) return; const owner = mOverriddenContext?.owner; const windowId = mOverriddenContext?.windowId; if (mLastOverriddenContextOwner && owner == mLastOverriddenContextOwner) { mOverriddenContext = null; TSTAPI.broadcastMessage({ type: TSTAPI.kCONTEXT_MENU_HIDDEN, windowId }, { targets: [owner] }); TSTAPI.broadcastMessage({ type: TSTAPI.kFAKE_CONTEXT_MENU_HIDDEN, windowId }, { targets: [owner] }); } } async function onClick(info, contextTab) { if (!mInitialized) return; contextTab = Tab.get(contextTab?.id); const win = await browser.windows.getLastFocused({ populate: true }).catch(ApiTabs.createErrorHandler()); const windowId = contextTab?.windowId || win.id; const activeTab = TabsStore.activeTabInWindow.get(windowId); let multiselectedTabs = Tab.getSelectedTabs(windowId); const isMultiselected = contextTab ? contextTab.$TST.multiselected : multiselectedTabs.length > 1; if (!isMultiselected) multiselectedTabs = null; switch (info.menuItemId.replace(/^noContextTab:/, '')) { case 'context_newTab': { const behavior = info.button == 1 ? configs.autoAttachOnNewTabButtonMiddleClick : (info.modifiers && (info.modifiers.includes('Ctrl') || info.modifiers.includes('Command'))) ? configs.autoAttachOnNewTabButtonAccelClick : contextTab ? configs.autoAttachOnContextNewTabCommand : configs.autoAttachOnNewTabCommand; Commands.openNewTabAs({ baseTab: contextTab || activeTab, as: behavior, }); }; break; case 'context_newGroup': case 'context_addToGroup_newGroup': NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab]).then(({ groupId, created }) => { if (!created) { return; } SidebarConnection.sendMessage({ type: Constants.kCOMMAND_SHOW_NATIVE_TAB_GROUP_MENU_PANEL, windowId, groupId, }); }); break; case 'context_removeFromGroup': NativeTabGroups.removeTabsFromGroup(multiselectedTabs || [contextTab]); break; case 'context_reloadTab': if (multiselectedTabs) { for (const tab of multiselectedTabs) { browser.tabs.reload(tab.id) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } } else { const tab = contextTab || activeTab; browser.tabs.reload(tab.id) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } break; case 'context_toggleMuteTab': { const tab = contextTab || activeTab; const unmuted = !tab.mutedInfo || !tab.mutedInfo.muted; if (multiselectedTabs) { for (const tab of multiselectedTabs) { browser.tabs.update(tab.id, { muted: unmuted }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } } else { browser.tabs.update(contextTab.id, { muted: unmuted }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } }; break; case 'context_pinTab': if (multiselectedTabs) { for (const tab of multiselectedTabs) { browser.tabs.update(tab.id, { pinned: true }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } } else { browser.tabs.update(contextTab.id, { pinned: true }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } break; case 'context_unpinTab': if (multiselectedTabs) { for (const tab of multiselectedTabs) { browser.tabs.update(tab.id, { pinned: false }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } } else { browser.tabs.update(contextTab.id, { pinned: false }) .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); } break; case 'context_toggleSticky': Commands.toggleSticky(multiselectedTabs, !(contextTab || activeTab).$TST.sticky); break; case 'context_unloadTab': Commands.unloadTabs(multiselectedTabs || [contextTab]); break; case 'context_duplicateTab': Commands.duplicateTab(contextTab, { destinationWindowId: windowId }); break; case 'context_moveTabToStart': Commands.moveTabToStart(contextTab); break; case 'context_moveTabToEnd': Commands.moveTabToEnd(contextTab); break; case 'context_openTabInWindow': Commands.openTabInWindow(contextTab, { withTree: true }); break; case 'context_shareTabURL': if (mSharingService) mSharingService.share(contextTab); break; case 'context_shareTabURL:more': if (mSharingService) mSharingService.openPreferences(); break; case 'context_sendTabsToDevice:all': Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab]); break; case 'context_sendTabsToDevice:manage': Sync.manageDevices(windowId); break; case 'context_topLevel_sendTreeToDevice:all': Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab], { recursively: true }); break; case 'context_selectAllTabs': { const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); TabsInternalOperation.highlightTabs( [activeTab].concat(mapAndFilter(tabs, tab => !tab.active ? tab : undefined)) ).catch(ApiTabs.createErrorSuppressor()); }; break; case 'context_bookmarkTab': Commands.bookmarkTab(contextTab); break; case 'context_bookmarkSelected': Commands.bookmarkTab(contextTab || activeTab); break; case 'context_closeDuplicatedTabs': { const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); tabs.sort((a, b) => b.lastAccessed - a.lastAccessed); const tabKeys = new Set(); const closeTabs = []; for (const tab of tabs) { const key = `${tab.cookieStoreId}\n${tab.url}`; if (tabKeys.has(key)) { closeTabs.push(Tab.get(tab.id)); continue; } tabKeys.add(key); } const canceled = (await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, tabs: closeTabs.map(tab => tab.$TST.sanitized), windowId, }).catch(ApiTabs.createErrorHandler())) === false; if (canceled) break; TabsInternalOperation.removeTabs(closeTabs); } break; case 'context_closeTabsToTheStart': { const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); const closeTabs = []; const keptTabIds = new Set( multiselectedTabs ? multiselectedTabs.map(tab => tab.id) : [contextTab.id] ); for (const tab of tabs) { if (keptTabIds.has(tab.id)) break; if (!tab.pinned && !tab.hidden) closeTabs.push(Tab.get(tab.id)); } const canceled = (await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, tabs: closeTabs.map(tab => tab.$TST.sanitized), windowId }).catch(ApiTabs.createErrorHandler())) === false; if (canceled) break; TabsInternalOperation.removeTabs(closeTabs); }; break; case 'context_closeTabsToTheEnd': { const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); tabs.reverse(); const closeTabs = []; const keptTabIds = new Set( (multiselectedTabs ? multiselectedTabs : [contextTab] ).reduce((tabIds, tab, _index) => { if (tab.$TST.subtreeCollapsed) tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id)) else tabIds.push(tab.id); return tabIds; }, []) ); for (const tab of tabs) { if (keptTabIds.has(tab.id)) break; if (!tab.pinned && !tab.hidden) closeTabs.push(Tab.get(tab.id)); } closeTabs.reverse(); const canceled = (await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, tabs: closeTabs.map(tab => tab.$TST.sanitized), windowId }).catch(ApiTabs.createErrorHandler())) === false; if (canceled) break; TabsInternalOperation.removeTabs(closeTabs); }; break; case 'context_closeOtherTabs': { const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); const keptTabIds = new Set( (multiselectedTabs ? multiselectedTabs : [contextTab] ).reduce((tabIds, tab, _index) => { if (tab.$TST.subtreeCollapsed) tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id)) else tabIds.push(tab.id); return tabIds; }, []) ); const closeTabs = mapAndFilter(tabs, tab => !tab.pinned && !tab.hidden && !keptTabIds.has(tab.id) && Tab.get(tab.id) || undefined); const canceled = (await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, tabs: closeTabs.map(tab => tab.$TST.sanitized), windowId }).catch(ApiTabs.createErrorHandler())) === false; if (canceled) break; TabsInternalOperation.removeTabs(closeTabs); }; break; case 'context_undoCloseTab': { const sessions = await browser.sessions.getRecentlyClosed({ maxResults: 1 }).catch(ApiTabs.createErrorHandler()); if (sessions.length && sessions[0].tab) browser.sessions.restore(sessions[0].tab.sessionId).catch(ApiTabs.createErrorSuppressor()); }; break; case 'context_closeTab': { const closeTabs = (multiselectedTabs || TreeBehavior.getClosingTabsFromParent(contextTab, { byInternalOperation: true })).reverse(); // close down to top, to keep tree structure of Tree Style Tab const canceled = (await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, tabs: closeTabs.map(tab => tab.$TST.sanitized), windowId }).catch(ApiTabs.createErrorHandler())) === false; if (canceled) return; TabsInternalOperation.removeTabs(closeTabs); }; break; default: { const nativeTabGroupMatch = info.menuItemId.match(/^context_addToGroup:group:(.+)$/); if (contextTab && nativeTabGroupMatch) NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab], parseInt(nativeTabGroupMatch[1])); const contextualIdentityMatch = info.menuItemId.match(/^context_reopenInContainer:(.+)$/); if (contextTab && contextualIdentityMatch) Commands.reopenInContainer(contextTab, contextualIdentityMatch[1]); const shareTabsMatch = info.menuItemId.match(/^context_shareTabURL:service:(.+)$/); if (mSharingService && contextTab && shareTabsMatch) mSharingService.share(contextTab, shareTabsMatch[1]); const sendTabsToDeviceMatch = info.menuItemId.match(/^context_sendTabsToDevice:device:(.+)$/); if (contextTab && sendTabsToDeviceMatch) Sync.sendTabsToDevice( multiselectedTabs || [contextTab], { to: sendTabsToDeviceMatch[1] } ); const sendTreeToDeviceMatch = info.menuItemId.match(/^context_topLevel_sendTreeToDevice:device:(.+)$/); if (contextTab && sendTreeToDeviceMatch) Sync.sendTabsToDevice( multiselectedTabs || [contextTab], { to: sendTreeToDeviceMatch[1], recursively: true } ); if (EXTERNAL_TOP_LEVEL_ITEM_MATCHER.test(info.menuItemId)) { const owner = RegExp.$1; const menuItemId = RegExp.$2; const message = { type: TSTAPI.kCONTEXT_MENU_CLICK, info: { bookmarkId: info.bookmarkId || null, button: info.button, checked: info.checked, editable: false, frameId: null, frameUrl: null, linkText: null, linkUrl: null, mediaType: null, menuItemId, modifiers: [], pageUrl: null, parentMenuItemId: null, selectionText: null, srcUrl: null, targetElementId: null, viewType: 'sidebar', wasChecked: info.wasChecked }, tab: contextTab, }; if (owner == browser.runtime.id) { browser.runtime.sendMessage(message).catch(ApiTabs.createErrorSuppressor()); } else { const cache = {}; TSTAPI.sendMessage( owner, message, { tabProperties: ['tab'], cache, isContextTab: true } ).catch(ApiTabs.createErrorSuppressor()); TSTAPI.sendMessage( owner, { ...message, type: TSTAPI.kFAKE_CONTEXT_MENU_CLICK }, { tabProperties: ['tab'], cache, isContextTab: true } ).catch(ApiTabs.createErrorSuppressor()); } } }; break; } } function getItemsFor(addonId) { if (mExtraItems.has(addonId)) { return mExtraItems.get(addonId); } const items = []; mExtraItems.set(addonId, items); return items; } function exportExtraItems() { const exported = {}; for (const [id, items] of mExtraItems.entries()) { exported[id] = items; } return exported; } async function notifyUpdated() { await browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_CONTEXT_MENU_UPDATED, items: exportExtraItems() }).catch(ApiTabs.createErrorSuppressor()); } let mReservedNotifyUpdate; let mNotifyUpdatedHandlers = []; function reserveNotifyUpdated() { return new Promise((resolve, _aReject) => { mNotifyUpdatedHandlers.push(resolve); if (mReservedNotifyUpdate) clearTimeout(mReservedNotifyUpdate); mReservedNotifyUpdate = setTimeout(async () => { mReservedNotifyUpdate = undefined; await notifyUpdated(); const handlers = mNotifyUpdatedHandlers; mNotifyUpdatedHandlers = []; for (const handler of handlers) { handler(); } }, 10); }); } function reserveRefresh() { if (reserveRefresh.reserved) clearTimeout(reserveRefresh.reserved); reserveRefresh.reserved = setTimeout(() => { reserveRefresh.reserved = null;; browser.menus.refresh(); }, 0); } function onMessage(message, _sender) { if (!mInitialized) return; log('tab-context-menu: internally called:', message); switch (message.type) { case Constants.kCOMMAND_GET_CONTEXT_MENU_ITEMS: return Promise.resolve(exportExtraItems()); case TSTAPI.kCONTEXT_MENU_CLICK: onTSTItemClick.dispatch(message.info, message.tab); return; case TSTAPI.kCONTEXT_MENU_SHOWN: onShown(message.info, message.tab); onTSTTabContextMenuShown.dispatch(message.info, message.tab); return; case TSTAPI.kCONTEXT_MENU_HIDDEN: onTSTTabContextMenuHidden.dispatch(); return; case Constants.kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED: for (const itemData of mExtraItems.get(message.ownerId)) { if (!itemData.id != message.id) continue; itemData.checked = message.checked; break; } return; case Constants.kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN: mOverriddenContext = message.context || null; if (mOverriddenContext) { mOverriddenContext.owner = message.owner; mOverriddenContext.windowId = message.windowId; } break; // For optimization we update the context menu before the menu is actually opened if possible, to reduce visual flickings. // But this optimization does not work as expected on environments which shows the context menu with mousedown, like macOS. case TSTAPI.kNOTIFY_TAB_MOUSEDOWN: if (message.button == 2) { onShown( message, message.tab, ); onTSTTabContextMenuShown.dispatch(message, message.tab); } break; } } export function onMessageExternal(message, sender) { if (!mInitialized) return; switch (message.type) { case TSTAPI.kCONTEXT_MENU_CREATE: case TSTAPI.kFAKE_CONTEXT_MENU_CREATE: { log('TSTAPI.kCONTEXT_MENU_CREATE:', message, { id: sender.id, url: sender.url }); const items = getItemsFor(sender.id); let params = message.params; if (Array.isArray(params)) params = params[0]; const parent = params.parentId && items.filter(item => item.id == params.parentId)[0]; if (params.parentId && !parent) break; let shouldAdd = true; if (params.id) { for (let i = 0, maxi = items.length; i < maxi; i++) { const item = items[i]; if (item.id != params.id) continue; items.splice(i, 1, params); shouldAdd = false; break; } } if (shouldAdd) { items.push(params); if (parent?.id) { parent.children = parent.children || []; parent.children.push(params.id); } } mExtraItems.set(sender.id, items); params.$topLevel = ( Array.isArray(params.viewTypes) && params.viewTypes.includes('sidebar') ); if (sender.id != browser.runtime.id && params.$topLevel) { params.lastVisible = params.visible !== false; const visible = !!( params.lastVisible && mOverriddenContext && mLastOverriddenContextOwner == sender.id ); const createParams = { id: getExternalTopLevelItemId(sender.id, params.id), type: params.type || 'normal', visible, viewTypes: ['sidebar', 'tab', 'popup'], contexts: (params.contexts || []).filter(context => context == 'tab' || context == 'bookmark'), documentUrlPatterns: SIDEBAR_URL_PATTERN }; if (params.parentId) createParams.parentId = getExternalTopLevelItemId(sender.id, params.parentId); for (const property of SAFE_MENU_PROPERTIES) { if (property in params) createParams[property] = params[property]; } browser.menus.create(createParams); reserveRefresh(); onTopLevelItemAdded.dispatch(); } return reserveNotifyUpdated(); }; break; case TSTAPI.kCONTEXT_MENU_UPDATE: case TSTAPI.kFAKE_CONTEXT_MENU_UPDATE: { log('TSTAPI.kCONTEXT_MENU_UPDATE:', message, { id: sender.id, url: sender.url }); const items = getItemsFor(sender.id); for (let i = 0, maxi = items.length; i < maxi; i++) { const item = items[i]; if (item.id != message.params[0]) continue; const params = message.params[1]; const updateProperties = {}; for (const property of SAFE_MENU_PROPERTIES) { if (property in params) updateProperties[property] = params[property]; } if (sender.id != browser.runtime.id && item.$topLevel) { if ('visible' in updateProperties) item.lastVisible = updateProperties.visible; if (!mOverriddenContext || mLastOverriddenContextOwner != sender.id) delete updateProperties.visible; } items.splice(i, 1, { ...item, ...updateProperties }); if (sender.id != browser.runtime.id && item.$topLevel && Object.keys(updateProperties).length > 0) { browser.menus.update( getExternalTopLevelItemId(sender.id, item.id), updateProperties ); reserveRefresh() } break; } mExtraItems.set(sender.id, items); return reserveNotifyUpdated(); }; break; case TSTAPI.kCONTEXT_MENU_REMOVE: case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE: { log('TSTAPI.kCONTEXT_MENU_REMOVE:', message, { id: sender.id, url: sender.url }); let items = getItemsFor(sender.id); let id = message.params; if (Array.isArray(id)) id = id[0]; const item = items.filter(item => item.id == id)[0]; if (!item) break; const parent = item.parentId && items.filter(item => item.id == item.parentId)[0]; items = items.filter(item => item.id != id); mExtraItems.set(sender.id, items); if (parent?.children) parent.children = parent.children.filter(childId => childId != id); if (item.children) { for (const childId of item.children) { onMessageExternal({ type: message.type, params: childId }, sender); } } if (sender.id != browser.runtime.id && item.$topLevel) { browser.menus.remove(getExternalTopLevelItemId(sender.id, item.id)); reserveRefresh(); } return reserveNotifyUpdated(); }; break; case TSTAPI.kCONTEXT_MENU_REMOVE_ALL: case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE_ALL: case TSTAPI.kUNREGISTER_SELF: { delete mExtraItems.delete(sender.id); return reserveNotifyUpdated(); }; break; } }