/* 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, isSearchEnabled, placeLinkOnClipboard, searchTabList, MAX_TABS_FOR_RECENT_BROWSING, } from "./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", ContextualIdentityService: "resource://gre/modules/ContextualIdentityService.sys.mjs", NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.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: "fxview-search-textbox", }; 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( "fxview-search-textbox-query", 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( "fxview-search-textbox-query", 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( isSearchEnabled(), () => html`
` )}
${when( currentWindowIndex && currentWindowTabs, () => html` ` )} ${map( otherWindows, ([winID, tabs, win]) => html` ` )}
`; } onSearchQuery(e) { 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 === "fxview-search-textbox-query") { 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; } 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 ); } 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(); Services.telemetry.recordEvent( "firefoxview_next", "search_show_all", "showallbutton", null, { 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; Services.telemetry.recordEvent( "firefoxview_next", "open_tab", "tabs", null, { page: this.recentBrowsing ? "recentbrowsing" : "opentabs", window: this.title || "Window 1 (Current)", } ); if (this.searchQuery) { const searchesHistogram = Services.telemetry.getKeyedHistogramById( "FIREFOX_VIEW_CUMULATIVE_SEARCHES" ); searchesHistogram.add( this.recentBrowsing ? "recentbrowsing" : "opentabs", this.cumulativeSearches ); this.cumulativeSearches = 0; } } closeTab(event) { const tab = event.originalTarget.tabElement; tab?.ownerGlobal.gBrowser.removeTab(tab); Services.telemetry.recordEvent( "firefoxview_next", "close_open_tab", "tabs", null ); } 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, 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 = 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); } copyLink(e) { placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url); 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); if (device && this.triggerNode) { await viewPage .getWindow() .gSync.sendTabToDevice( this.triggerNode.url, [device], this.triggerNode.title ); } } 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); /** * Checks if a given tab is within a container (contextual identity) * * @param {MozTabbrowserTab[]} tab * Tab to fetch container info on. * @returns {object[]} * Container object. */ function getContainerObj(tab) { let userContextId = tab.getAttribute("usercontextid"); let containerObj = null; if (userContextId) { containerObj = lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId); } return containerObj; } /** * Gets an array of tab indicators (if any) when normalizing for fxview-tab-list * * @param {MozTabbrowserTab[]} tab * Tab to fetch container info on. * @returns {Array[]} * Array of named tab indicators */ function getIndicatorsForTab(tab) { const url = tab.linkedBrowser?.currentURI?.spec || ""; let tabIndicators = []; let hasAttention = (tab.pinned && (tab.hasAttribute("attention") || tab.hasAttribute("titlechanged"))) || (!tab.pinned && tab.hasAttribute("attention")); if (tab.pinned) { tabIndicators.push("pinned"); } if (getContainerObj(tab)) { tabIndicators.push("container"); } if (hasAttention) { tabIndicators.push("attention"); } if (tab.hasAttribute("soundplaying") && !tab.hasAttribute("muted")) { tabIndicators.push("soundplaying"); } if (tab.hasAttribute("muted")) { tabIndicators.push("muted"); } if (checkIfPinnedNewTab(url)) { tabIndicators.push("pinnedOnNewTab"); } return tabIndicators; } /** * Gets the primary l10n id for a tab when normalizing for fxview-tab-list * * @param {boolean} isRecentBrowsing * Whether the tabs are going to be displayed on the Recent Browsing page or not * @param {Array[]} tabIndicators * Array of tab indicators for the given tab * @returns {string} * L10n ID string */ function getPrimaryL10nId(isRecentBrowsing, tabIndicators) { let indicatorL10nId = null; if (!isRecentBrowsing) { if ( tabIndicators?.includes("pinned") && tabIndicators?.includes("bookmark") ) { indicatorL10nId = "firefoxview-opentabs-bookmarked-pinned-tab"; } else if (tabIndicators?.includes("pinned")) { indicatorL10nId = "firefoxview-opentabs-pinned-tab"; } else if (tabIndicators?.includes("bookmark")) { indicatorL10nId = "firefoxview-opentabs-bookmarked-tab"; } } return indicatorL10nId; } /** * Gets the primary l10n args for a tab when normalizing for fxview-tab-list * * @param {MozTabbrowserTab[]} tab * Tab to fetch container info on. * @param {boolean} isRecentBrowsing * Whether the tabs are going to be displayed on the Recent Browsing page or not * @param {string} url * URL for the given tab * @returns {string} * L10n ID args */ function getPrimaryL10nArgs(tab, isRecentBrowsing, url) { return JSON.stringify({ tabTitle: tab.label, url }); } /** * Check if a given url is pinned on the new tab page * * @param {string} url * url to check * @returns {boolean} * is tabbed pinned on new tab page */ function checkIfPinnedNewTab(url) { return url && lazy.NewTabUtils.pinnedLinks.isPinned({ url }); } /** * Convert a list of tabs into the format expected by the fxview-tab-list * component. * * @param {MozTabbrowserTab[]} tabs * Tabs to format. * @param {boolean} isRecentBrowsing * Whether the tabs are going to be displayed on the Recent Browsing page or not * @returns {object[]} * Formatted objects. */ function getTabListItems(tabs, isRecentBrowsing) { let filtered = tabs?.filter(tab => !tab.closing && !tab.hidden); return filtered.map(tab => { let tabIndicators = getIndicatorsForTab(tab); let containerObj = getContainerObj(tab); const url = tab?.linkedBrowser?.currentURI?.spec || ""; return { containerObj, indicators: tabIndicators, icon: tab.getAttribute("image"), primaryL10nId: getPrimaryL10nId(isRecentBrowsing, tabIndicators), primaryL10nArgs: getPrimaryL10nArgs(tab, isRecentBrowsing, url), secondaryL10nId: isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) ? "fxviewtabrow-options-menu-button" : null, secondaryL10nArgs: isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) ? JSON.stringify({ tabTitle: tab.label }) : null, tertiaryL10nId: isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) ? "fxviewtabrow-close-tab-button" : null, tertiaryL10nArgs: isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) ? JSON.stringify({ tabTitle: tab.label }) : null, tabElement: tab, time: tab.lastSeenActive, title: tab.label, url, }; }); }