/* 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 { ViewPage } from "./viewpage.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { EveryWindow: "resource:///modules/EveryWindow.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", UIState: "resource://services-sync/UIState.sys.mjs", TabsSetupFlowManager: "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { return ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ).getFxAccountsSingleton(); }); const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; /** * A collection of open tabs grouped by window. * * @property {Map} windows * A mapping of windows to their respective list of open tabs. */ class OpenTabsInView extends ViewPage { static properties = { windows: { type: Map }, }; static TAB_ATTRS_TO_WATCH = Object.freeze(["image", "label"]); constructor() { super(); this.everyWindowCallbackId = `firefoxview-${Services.uuid.generateUUID()}`; this.windows = new Map(); this.currentWindow = this.getWindow(); this.isPrivateWindow = lazy.PrivateBrowsingUtils.isWindowPrivate( this.currentWindow ); this.boundObserve = (...args) => this.observe(...args); this.devices = []; } connectedCallback() { super.connectedCallback(); lazy.EveryWindow.registerCallback( this.everyWindowCallbackId, win => { if (win.gBrowser && this._shouldShowOpenTabs(win) && !win.closed) { const { tabContainer } = win.gBrowser; tabContainer.addEventListener("TabAttrModified", this); tabContainer.addEventListener("TabClose", this); tabContainer.addEventListener("TabMove", this); tabContainer.addEventListener("TabOpen", this); tabContainer.addEventListener("TabPinned", this); tabContainer.addEventListener("TabUnpinned", this); this._updateOpenTabsList(); } }, win => { if (win.gBrowser && this._shouldShowOpenTabs(win)) { const { tabContainer } = win.gBrowser; tabContainer.removeEventListener("TabAttrModified", this); tabContainer.removeEventListener("TabClose", this); tabContainer.removeEventListener("TabMove", this); tabContainer.removeEventListener("TabOpen", this); tabContainer.removeEventListener("TabPinned", this); tabContainer.removeEventListener("TabUnpinned", this); this._updateOpenTabsList(); } } ); this._updateOpenTabsList(); this.addObserversIfNeeded(); if (this.currentWindow.gSync) { this.devices = this.currentWindow.gSync.getSendTabTargets(); } } disconnectedCallback() { lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId); this.removeObserversIfNeeded(); } addObserversIfNeeded() { if (!this.observerAdded) { Services.obs.addObserver(this.boundObserve, lazy.UIState.ON_UPDATE); Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); this.observerAdded = true; } } removeObserversIfNeeded() { if (this.observerAdded) { Services.obs.removeObserver(this.boundObserve, lazy.UIState.ON_UPDATE); Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED); this.observerAdded = false; } } async observe(subject, topic, data) { switch (topic) { case lazy.UIState.ON_UPDATE: if (!this.devices.length && lazy.TabsSetupFlowManager.fxaSignedIn) { this.devices = this.currentWindow.gSync.getSendTabTargets(); } break; case TOPIC_DEVICELIST_UPDATED: const deviceListUpdated = await lazy.fxAccounts.device.refreshDeviceList(); if (deviceListUpdated) { this.devices = this.currentWindow.gSync.getSendTabTargets(); } } } render() { if (!this.selectedTab && !this.recentBrowsing) { return null; } if (this.recentBrowsing) { return this.getRecentBrowsingTemplate(); } let currentWindowIndex, currentWindowTabs; let index = 1; const otherWindows = []; this.windows.forEach((tabs, win) => { if (win === this.currentWindow) { currentWindowIndex = index++; currentWindowTabs = tabs; } else { otherWindows.push([index++, tabs, win]); } }); const cardClasses = classMap({ "height-limited": this.windows.size > 3, "width-limited": this.windows.size > 1, }); return html`
= 3, })} cards-container" > ${when( currentWindowIndex && currentWindowTabs, () => html` ` )} ${map( otherWindows, ([winID, tabs, win]) => html` ` )}
`; } /** * 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 = Array.from(this.windows.values()) .flat() .sort((a, b) => b.lastAccessed - a.lastAccessed); return html``; } handleEvent({ detail, target, type }) { const win = target.ownerGlobal; const tabs = this.windows.get(win); switch (type) { case "TabAttrModified": if ( !detail.changed.some(attr => OpenTabsInView.TAB_ATTRS_TO_WATCH.includes(attr) ) ) { // We don't care about this attr, bail out to avoid change detection. return; } break; case "TabClose": tabs.splice(target._tPos, 1); break; case "TabMove": [tabs[detail], tabs[target._tPos]] = [tabs[target._tPos], tabs[detail]]; break; case "TabOpen": tabs.splice(target._tPos, 0, target); break; case "TabPinned": case "TabUnpinned": this.windows.set(win, [...win.gBrowser.tabs]); break; } this.requestUpdate(); if (!this.recentBrowsing) { const selector = `view-opentabs-card[data-inner-id="${win.windowGlobalChild.innerWindowId}"]`; this.shadowRoot.querySelector(selector)?.requestUpdate(); } } _updateOpenTabsList() { this.windows = this._getOpenTabsPerWindow(); } /** * Get a list of open tabs for each window. * * @returns {Map} */ _getOpenTabsPerWindow() { return new Map( Array.from(Services.wm.getEnumerator("navigator:browser")) .filter( win => win.gBrowser && this._shouldShowOpenTabs(win) && !win.closed ) .map(win => [win, [...win.gBrowser.tabs]]) ); } _shouldShowOpenTabs(win) { return ( win == this.currentWindow || (!this.isPrivateWindow && !lazy.PrivateBrowsingUtils.isWindowPrivate(win)) ); } } 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 ViewPage { static properties = { showMore: { type: Boolean }, tabs: { type: Array }, title: { type: String }, recentBrowsing: { type: Boolean }, devices: { type: Array }, triggerNode: { type: Object }, }; static MAX_TABS_FOR_COMPACT_HEIGHT = 7; constructor() { super(); this.showMore = false; this.tabs = []; this.title = ""; this.recentBrowsing = false; this.devices = []; } static queries = { cardEl: "card-container", panelList: "panel-list", tabList: "fxview-tab-list", }; closeTab(e) { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.removeTab(tab); this.recordContextMenuTelemetry("close-tab", e); } moveTabsToStart() { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.moveTabsToStart(tab); } moveTabsToEnd() { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab); } moveTabsToWindow() { const tab = this.triggerNode.tabElement; tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab); } moveMenuTemplate() { const tab = this.triggerNode?.tabElement; const browserWindow = tab?.ownerGlobal; const position = tab?._tPos; const tabs = browserWindow?.gBrowser.tabs || []; 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); if (device && this.triggerNode) { await this.getWindow().gSync.sendTabToDevice( this.triggerNode.url, [device], this.triggerNode.title ); } } sendTabTemplate() { return html` ${this.devices.map(device => { return html` ${device.name} `; })} `; } panelListTemplate() { return html` ${this.moveMenuTemplate()}
${this.devices.length >= 1 ? html`${this.sendTabTemplate()}` : null}
`; } openContextMenu(e) { this.triggerNode = e.originalTarget; this.panelList.toggle(e.detail.originalEvent); } getMaxTabsLength() { if (this.recentBrowsing) { return 5; } else if (this.classList.contains("height-limited") && !this.showMore) { return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT; } return -1; } toggleShowMore(event) { if ( event.type == "click" || (event.type == "keydown" && event.code == "Enter") || (event.type == "keydown" && event.code == "Space") ) { event.preventDefault(); this.showMore = !this.showMore; } } render() { return html` ${this.recentBrowsing ? html`

` : html`

${this.title}

`}
${this.panelListTemplate()}
${!this.recentBrowsing ? html`
` : null}
`; } } customElements.define("view-opentabs-card", OpenTabsInViewCard); /** * Convert a list of tabs into the format expected by the fxview-tab-list * component. * * @param {MozTabbrowserTab[]} tabs * Tabs to format. * @returns {object[]} * Formatted objects. */ function getTabListItems(tabs) { return tabs ?.filter(tab => !tab.closing && !tab.hidden && !tab.pinned) .map(tab => ({ icon: tab.getAttribute("image"), primaryL10nId: "firefoxview-opentabs-tab-row", primaryL10nArgs: JSON.stringify({ url: tab.linkedBrowser?.currentURI?.spec, }), secondaryL10nId: "fxviewtabrow-options-menu-button", secondaryL10nArgs: JSON.stringify({ tabTitle: tab.label }), tabElement: tab, time: tab.lastAccessed, title: tab.label, url: tab.linkedBrowser?.currentURI?.spec, })); } function onTabListRowClick(event) { const tab = event.originalTarget.tabElement; const browserWindow = tab.ownerGlobal; browserWindow.focus(); browserWindow.gBrowser.selectedTab = tab; }