/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { classMap, html, map, when, } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; import { getLogger, MAX_TABS_FOR_RECENT_BROWSING } from "./helpers.mjs"; import { searchTabList } from "./search-helpers.mjs"; import { ViewPage, ViewPageContent } from "./viewpage.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://browser/content/firefoxview/opentabs-tab-list.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BookmarkList: "resource://gre/modules/BookmarkList.sys.mjs", BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", OpenTabsController: "resource:///modules/OpenTabsController.sys.mjs", getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { return ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ).getFxAccountsSingleton(); }); const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; /** * A collection of open tabs grouped by window. * * @property {Array} windows * A list of windows with the same privateness * @property {string} sortOption * The sorting order of open tabs: * - "recency": Sorted by recent activity. (For recent browsing, this is the only option.) * - "tabStripOrder": Match the order in which they appear on the tab strip. */ class OpenTabsInView extends ViewPage { static properties = { ...ViewPage.properties, windows: { type: Array }, searchQuery: { type: String }, sortOption: { type: String }, }; static queries = { viewCards: { all: "view-opentabs-card" }, optionsContainer: ".open-tabs-options", searchTextbox: "moz-input-search", }; initialWindowsReady = false; currentWindow = null; openTabsTarget = null; constructor() { super(); this._started = false; this.windows = []; this.currentWindow = this.getWindow(); if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) { this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow); } else { this.openTabsTarget = lazy.NonPrivateTabs; } this.searchQuery = ""; this.sortOption = this.recentBrowsing ? "recency" : Services.prefs.getStringPref( "browser.tabs.firefox-view.ui-state.opentabs.sort-option", "recency" ); } start() { if (this._started) { return; } this._started = true; this.#setupTabChangeListener(); // To resolve the race between this component wanting to render all the windows' // tabs, while those windows are still potentially opening, flip this property // once the promise resolves and we'll bail out of rendering until then. this.openTabsTarget.readyWindowsPromise.finally(() => { this.initialWindowsReady = true; this._updateWindowList(); }); for (let card of this.viewCards) { card.paused = false; card.viewVisibleCallback?.(); } if (this.recentBrowsing) { this.recentBrowsingElement.addEventListener( "MozInputSearch:search", this ); } this.bookmarkList = new lazy.BookmarkList(this.#getAllTabUrls(), () => this.viewCards.forEach(card => card.requestUpdate()) ); } shouldUpdate(changedProperties) { if (!this.initialWindowsReady) { return false; } return super.shouldUpdate(changedProperties); } disconnectedCallback() { super.disconnectedCallback(); this.stop(); } stop() { if (!this._started) { return; } this._started = false; this.paused = true; this.openTabsTarget.removeEventListener("TabChange", this); this.openTabsTarget.removeEventListener("TabRecencyChange", this); for (let card of this.viewCards) { card.paused = true; card.viewHiddenCallback?.(); } if (this.recentBrowsing) { this.recentBrowsingElement.removeEventListener( "MozInputSearch:search", this ); } this.bookmarkList.removeListeners(); } viewVisibleCallback() { this.start(); } viewHiddenCallback() { this.stop(); } #setupTabChangeListener() { if (this.sortOption === "recency") { this.openTabsTarget.addEventListener("TabRecencyChange", this); this.openTabsTarget.removeEventListener("TabChange", this); } else { this.openTabsTarget.removeEventListener("TabRecencyChange", this); this.openTabsTarget.addEventListener("TabChange", this); } } #getAllTabUrls() { return this.openTabsTarget .getAllTabs() .map(({ linkedBrowser }) => linkedBrowser?.currentURI?.spec) .filter(Boolean); } render() { if (this.recentBrowsing) { return this.getRecentBrowsingTemplate(); } let currentWindowIndex, currentWindowTabs; let index = 1; const otherWindows = []; this.windows.forEach(win => { const tabs = this.openTabsTarget.getTabsForWindow( win, this.sortOption === "recency" ); if (win === this.currentWindow) { currentWindowIndex = index++; currentWindowTabs = tabs; } else { otherWindows.push([index++, tabs, win]); } }); const cardClasses = classMap({ "height-limited": this.windows.length > 3, "width-limited": this.windows.length > 1, }); let cardCount; if (this.windows.length <= 1) { cardCount = "one"; } else if (this.windows.length === 2) { cardCount = "two"; } else { cardCount = "three-or-more"; } return html`
${when( currentWindowIndex && currentWindowTabs, () => html` ` )} ${map( otherWindows, ([winID, tabs, win]) => html` ` )}
`; } onSearchQuery(e) { if (!this.recentBrowsing) { Glean.firefoxviewNext.searchInitiatedSearch.record({ page: "opentabs", }); } this.searchQuery = e.detail.query; } onChangeSortOption(e) { this.sortOption = e.target.value; this.#setupTabChangeListener(); if (!this.recentBrowsing) { Services.prefs.setStringPref( "browser.tabs.firefox-view.ui-state.opentabs.sort-option", this.sortOption ); } } /** * Render a template for the 'Recent browsing' page, which shows a shorter list of * open tabs in the current window. * * @returns {TemplateResult} * The recent browsing template. */ getRecentBrowsingTemplate() { const tabs = this.openTabsTarget.getRecentTabs(); return html``; } handleEvent({ detail, type }) { if (this.recentBrowsing && type === "MozInputSearch:search") { this.onSearchQuery({ detail }); return; } let windowIds; switch (type) { case "TabRecencyChange": case "TabChange": windowIds = detail.windowIds; this._updateWindowList(); this.bookmarkList.setTrackedUrls(this.#getAllTabUrls()); break; } if (this.recentBrowsing) { return; } if (windowIds?.length) { // there were tab changes to one or more windows for (let winId of windowIds) { const cardForWin = this.shadowRoot.querySelector( `view-opentabs-card[data-inner-id="${winId}"]` ); if (this.searchQuery) { cardForWin?.updateSearchResults(); } cardForWin?.requestUpdate(); } } else { let winId = window.windowGlobalChild.innerWindowId; let cardForWin = this.shadowRoot.querySelector( `view-opentabs-card[data-inner-id="${winId}"]` ); if (this.searchQuery) { cardForWin?.updateSearchResults(); } } } async _updateWindowList() { this.windows = this.openTabsTarget.currentWindows; } } customElements.define("view-opentabs", OpenTabsInView); /** * A card which displays a list of open tabs for a window. * * @property {boolean} showMore * Whether to force all tabs to be shown, regardless of available space. * @property {MozTabbrowserTab[]} tabs * The open tabs to show. * @property {string} title * The window title. */ class OpenTabsInViewCard extends ViewPageContent { static properties = { showMore: { type: Boolean }, tabs: { type: Array }, title: { type: String }, recentBrowsing: { type: Boolean }, searchQuery: { type: String }, searchResults: { type: Array }, showAll: { type: Boolean }, cumulativeSearches: { type: Number }, bookmarkList: { type: Object }, }; static MAX_TABS_FOR_COMPACT_HEIGHT = 7; constructor() { super(); this.showMore = false; this.tabs = []; this.title = ""; this.recentBrowsing = false; this.devices = []; this.searchQuery = ""; this.searchResults = null; this.showAll = false; this.cumulativeSearches = 0; this.controller = new lazy.OpenTabsController(this, {}); } static queries = { cardEl: "card-container", tabContextMenu: "view-opentabs-contextmenu", tabList: "opentabs-tab-list", }; openContextMenu(e) { let { originalEvent } = e.detail; this.tabContextMenu.toggle({ triggerNode: e.originalTarget, originalEvent, }); } getMaxTabsLength() { if (this.recentBrowsing && !this.showAll) { return MAX_TABS_FOR_RECENT_BROWSING; } else if (this.classList.contains("height-limited") && !this.showMore) { return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT; } return -1; } isShowAllLinkVisible() { return ( this.recentBrowsing && this.searchQuery && this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING && !this.showAll ); } isShowMoreLinkVisible() { if (!this.classList.contains("height-limited")) { return false; } let tabCount = (this.searchQuery ? this.searchResults : this.tabs).length; return tabCount > OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT; } toggleShowMore(event) { if ( event.type == "click" || (event.type == "keydown" && event.code == "Enter") || (event.type == "keydown" && event.code == "Space") ) { event.preventDefault(); this.showMore = !this.showMore; } } enableShowAll(event) { if ( event.type == "click" || (event.type == "keydown" && event.code == "Enter") || (event.type == "keydown" && event.code == "Space") ) { event.preventDefault(); Glean.firefoxviewNext.searchShowAllShowallbutton.record({ section: "opentabs", }); this.showAll = true; } } onTabListRowClick(event) { // Don't open pinned tab if mute/unmute indicator button selected if ( Array.from(event.explicitOriginalTarget.classList).includes( "fxview-tab-row-pinned-media-button" ) ) { return; } const tab = event.originalTarget.tabElement; const browserWindow = tab.ownerGlobal; browserWindow.focus(); browserWindow.gBrowser.selectedTab = tab; Glean.firefoxviewNext.openTabTabs.record({ page: this.recentBrowsing ? "recentbrowsing" : "opentabs", window: this.title || "Window 1 (Current)", }); if (this.searchQuery) { Glean.firefoxview.cumulativeSearches[ this.recentBrowsing ? "recentbrowsing" : "opentabs" ].accumulateSingleSample(this.cumulativeSearches); this.cumulativeSearches = 0; } } closeTab(event) { const tab = event.originalTarget.tabElement; tab?.ownerGlobal.gBrowser.removeTab( tab, lazy.TabMetrics.userTriggeredContext() ); Glean.firefoxviewNext.closeOpenTabTabs.record(); } viewVisibleCallback() { this.getRootNode().host.toggleVisibilityInCardContainer(true); } viewHiddenCallback() { this.getRootNode().host.toggleVisibilityInCardContainer(true); } firstUpdated() { this.getRootNode().host.toggleVisibilityInCardContainer(true); } render() { return html` ${when( this.recentBrowsing, () => html`

`, () => html`

${this.title}

` )}
${when( this.recentBrowsing, () => html`
`, () => html`
` )}
`; } willUpdate(changedProperties) { if (changedProperties.has("searchQuery")) { this.showAll = false; this.cumulativeSearches = this.searchQuery ? this.cumulativeSearches + 1 : 0; } if (changedProperties.has("searchQuery") || changedProperties.has("tabs")) { this.updateSearchResults(); } } updateSearchResults() { this.searchResults = this.searchQuery ? searchTabList( this.searchQuery, this.controller.getTabListItems(this.tabs) ) : null; } updated() { this.updateBookmarkStars(); } async updateBookmarkStars() { const tabItems = [...this.tabList.tabItems]; for (const row of tabItems) { const isBookmark = await this.bookmarkList.isBookmark(row.url); if (isBookmark && !row.indicators.includes("bookmark")) { row.indicators.push("bookmark"); } if (!isBookmark && row.indicators.includes("bookmark")) { row.indicators = row.indicators.filter(i => i !== "bookmark"); } row.primaryL10nId = this.controller.getPrimaryL10nId( this.isRecentBrowsing, row.indicators ); } this.tabList.tabItems = tabItems; } } customElements.define("view-opentabs-card", OpenTabsInViewCard); /** * A context menu of actions available for open tab list items. */ class OpenTabsContextMenu extends MozLitElement { static properties = { devices: { type: Array }, triggerNode: { hasChanged: () => true, type: Object }, }; static queries = { panelList: "panel-list", }; constructor() { super(); this.triggerNode = null; this.boundObserve = (...args) => this.observe(...args); this.devices = []; } get logger() { return getLogger("OpenTabsContextMenu"); } get ownerViewPage() { return this.ownerDocument.querySelector("view-opentabs"); } connectedCallback() { super.connectedCallback(); this.fetchDevicesPromise = this.fetchDevices(); Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); Services.obs.addObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED); } disconnectedCallback() { super.disconnectedCallback(); Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED); } observe(_subject, topic, _data) { if ( topic == TOPIC_DEVICELIST_UPDATED || topic == TOPIC_DEVICESTATE_CHANGED ) { this.fetchDevicesPromise = this.fetchDevices(); } } async fetchDevices() { const currentWindow = this.ownerViewPage.getWindow(); if (currentWindow?.gSync) { try { await lazy.fxAccounts.device.refreshDeviceList(); } catch (e) { this.logger.warn("Could not refresh the FxA device list", e); } this.devices = currentWindow.gSync.getSendTabTargets(); } } async toggle({ triggerNode, originalEvent }) { if (this.panelList?.open) { // the menu will close so avoid all the other work to update its contents this.panelList.toggle(originalEvent); return; } this.triggerNode = triggerNode; await this.fetchDevicesPromise; await this.getUpdateComplete(); this.panelList.toggle(originalEvent); if (this.devices.length >= 1) { Glean.firefoxview.sendTabExposed.record({ device_count: String(this.devices.length), }); } } copyLink(e) { lazy.BrowserUtils.copyLink(this.triggerNode.url, this.triggerNode.title); this.ownerViewPage.recordContextMenuTelemetry("copy-link", e); } closeTab(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.removeTab(tab); this.ownerViewPage.recordContextMenuTelemetry("close-tab", e); } pinTab(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.pinTab(tab); this.ownerViewPage.recordContextMenuTelemetry("pin-tab", e); } unpinTab(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.unpinTab(tab); this.ownerViewPage.recordContextMenuTelemetry("unpin-tab", e); } toggleAudio(e) { const tab = this.triggerNode.tabElement; tab.toggleMuteAudio(); this.ownerViewPage.recordContextMenuTelemetry( `${ this.triggerNode.indicators.includes("muted") ? "unmute" : "mute" }-tab`, e ); } moveTabsToStart(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.moveTabsToStart(tab); this.ownerViewPage.recordContextMenuTelemetry("move-tab-start", e); } moveTabsToEnd(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab); this.ownerViewPage.recordContextMenuTelemetry("move-tab-end", e); } moveTabsToWindow(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab); this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e); } moveMenuTemplate() { const tab = this.triggerNode?.tabElement; if (!tab) { return null; } const browserWindow = tab.ownerGlobal; const tabs = browserWindow?.gBrowser.visibleTabs || []; const position = tabs.indexOf(tab); return html` ${position > 0 ? html`` : null} ${position < tabs.length - 1 ? html`` : null} `; } async sendTabToDevice(e) { let deviceId = e.target.getAttribute("device-id"); let device = this.devices.find(dev => dev.id == deviceId); const viewPage = this.ownerViewPage; viewPage.recordContextMenuTelemetry("send-tab-device", e); Glean.firefoxview.clickSendTab.record({ device_count: String(this.devices.length), action: "device", }); if (device && this.triggerNode) { await viewPage .getWindow() .gSync.sendTabToDevice( this.triggerNode.url, [device], this.triggerNode.title ); } } onSendTabSubmenuClick() { Glean.firefoxview.sendTabOpened.record({ device_count: String(this.devices.length), }); } sendTabTemplate() { return html` ${this.devices.map(device => { return html` ${device.name} `; })} `; } render() { const tab = this.triggerNode?.tabElement; if (!tab) { return null; } return html` ${this.moveMenuTemplate()}
${this.devices.length >= 1 ? html`${this.sendTabTemplate()}` : null}
`; } } customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu);