/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", }); const { SyncedTabsErrorHandler } = ChromeUtils.importESModule( "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs" ); const { TabsSetupFlowManager } = ChromeUtils.importESModule( "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" ); import { html, ifDefined, when, } from "chrome://global/content/vendor/lit.all.mjs"; import { ViewPage } from "./viewpage.mjs"; import { escapeHtmlEntities, isSearchEnabled, searchTabList, MAX_TABS_FOR_RECENT_BROWSING, } from "./helpers.mjs"; const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; class SyncedTabsInView extends ViewPage { constructor() { super(); this._started = false; this.boundObserve = (...args) => this.observe(...args); this._currentSetupStateIndex = -1; this.errorState = null; this._id = Math.floor(Math.random() * 10e6); this.currentSyncedTabs = []; if (this.recentBrowsing) { this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING; } else { // Setting maxTabsLength to -1 for no max this.maxTabsLength = -1; } this.devices = []; this.fullyUpdated = false; this.searchQuery = ""; this.showAll = false; this.cumulativeSearches = 0; } static properties = { ...ViewPage.properties, errorState: { type: Number }, currentSyncedTabs: { type: Array }, _currentSetupStateIndex: { type: Number }, devices: { type: Array }, searchQuery: { type: String }, showAll: { type: Boolean }, cumulativeSearches: { type: Number }, }; static queries = { cardEls: { all: "card-container" }, emptyState: "fxview-empty-state", searchTextbox: "fxview-search-textbox", tabLists: { all: "fxview-tab-list" }, }; connectedCallback() { super.connectedCallback(); this.addEventListener("click", this); } start() { if (this._started) { return; } this._started = true; Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); this.updateStates(); this.onVisibilityChange(); if (this.recentBrowsing) { this.recentBrowsingElement.addEventListener( "fxview-search-textbox-query", this ); } } stop() { if (!this._started) { return; } this._started = false; TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); this.onVisibilityChange(); Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); if (this.recentBrowsing) { this.recentBrowsingElement.removeEventListener( "fxview-search-textbox-query", this ); } } willUpdate(changedProperties) { if (changedProperties.has("searchQuery")) { this.cumulativeSearches = this.searchQuery ? this.cumulativeSearches + 1 : 0; } } disconnectedCallback() { super.disconnectedCallback(); this.stop(); } handleEvent(event) { if (event.type == "click" && event.target.dataset.action) { const { ErrorType } = SyncedTabsErrorHandler; switch (event.target.dataset.action) { case `${ErrorType.SYNC_ERROR}`: case `${ErrorType.NETWORK_OFFLINE}`: case `${ErrorType.PASSWORD_LOCKED}`: { TabsSetupFlowManager.tryToClearError(); break; } case `${ErrorType.SIGNED_OUT}`: case "sign-in": { TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); break; } case "add-device": { TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); break; } case "sync-tabs-disabled": { TabsSetupFlowManager.syncOpenTabs(event.target); break; } case `${ErrorType.SYNC_DISCONNECTED}`: { const win = event.target.ownerGlobal; const { switchToTabHavingURI } = win.docShell.chromeEventHandler.ownerGlobal; switchToTabHavingURI( "about:preferences?action=choose-what-to-sync#sync", true, {} ); break; } } } if (event.type == "change") { TabsSetupFlowManager.syncOpenTabs(event.target); } if (this.recentBrowsing && event.type === "fxview-search-textbox-query") { this.onSearchQuery(event); } } viewVisibleCallback() { this.start(); } viewHiddenCallback() { this.stop(); } onVisibilityChange() { const isOpen = this.open; const isVisible = this.isVisible; if (isVisible && isOpen) { this.update(); TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); } else { TabsSetupFlowManager.updateViewVisibility( this._id, isVisible ? "closed" : "hidden" ); } this.toggleVisibilityInCardContainer(); } async observe(subject, topic, errorState) { if (topic == TOPIC_SETUPSTATE_CHANGED) { this.updateStates(errorState); } if (topic == SYNCED_TABS_CHANGED) { this.getSyncedTabData(); } } updateStates(errorState) { let stateIndex = TabsSetupFlowManager.uiStateIndex; errorState = errorState || SyncedTabsErrorHandler.getErrorType(); if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) { // trigger an initial request for the synced tabs list this.getSyncedTabData(); } this._currentSetupStateIndex = stateIndex; this.errorState = errorState; } actionMappings = { "sign-in": { header: "firefoxview-syncedtabs-signin-header", description: "firefoxview-syncedtabs-signin-description", buttonLabel: "firefoxview-syncedtabs-signin-primarybutton", }, "add-device": { header: "firefoxview-syncedtabs-adddevice-header", description: "firefoxview-syncedtabs-adddevice-description", buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton", descriptionLink: { name: "url", url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync", }, }, "sync-tabs-disabled": { header: "firefoxview-syncedtabs-synctabs-header", description: "firefoxview-syncedtabs-synctabs-description", buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton", }, loading: { header: "firefoxview-syncedtabs-loading-header", description: "firefoxview-syncedtabs-loading-description", }, }; generateMessageCard({ error = false, action, errorState }) { errorState = errorState || this.errorState; let header, description, descriptionLink, buttonLabel, headerIconUrl, mainImageUrl; let descriptionArray; if (error) { let link; ({ header, description, link, buttonLabel } = SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); action = `${errorState}`; headerIconUrl = "chrome://global/skin/icons/info-filled.svg"; mainImageUrl = "chrome://browser/content/firefoxview/synced-tabs-error.svg"; descriptionArray = [description]; if (errorState == "password-locked") { descriptionLink = {}; // This is ugly, but we need to special case this link so we can // coexist with the old view. descriptionArray.push("firefoxview-syncedtab-password-locked-link"); descriptionLink.name = "syncedtab-password-locked-link"; descriptionLink.url = link.href; } } else { header = this.actionMappings[action].header; description = this.actionMappings[action].description; buttonLabel = this.actionMappings[action].buttonLabel; descriptionLink = this.actionMappings[action].descriptionLink; mainImageUrl = "chrome://browser/content/firefoxview/synced-tabs-error.svg"; descriptionArray = [description]; } return html` `; } onOpenLink(event) { let currentWindow = this.getWindow(); if (currentWindow.openTrustedLinkIn) { let where = lazy.BrowserUtils.whereToOpenLink( event.detail.originalEvent, false, true ); if (where == "current") { where = "tab"; } currentWindow.openTrustedLinkIn(event.originalTarget.url, where); Services.telemetry.recordEvent( "firefoxview_next", "synced_tabs", "tabs", null, { page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", } ); } if (this.searchQuery) { const searchesHistogram = Services.telemetry.getKeyedHistogramById( "FIREFOX_VIEW_CUMULATIVE_SEARCHES" ); searchesHistogram.add( this.recentBrowsing ? "recentbrowsing" : "syncedtabs", this.cumulativeSearches ); this.cumulativeSearches = 0; } } onContextMenu(e) { this.triggerNode = e.originalTarget; e.target.querySelector("panel-list").toggle(e.detail.originalEvent); } panelListTemplate() { return html`
`; } noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) { const template = html`

${deviceName}

${when( isSearchResultsEmpty, () => html`
`, () => html`
` )}`; return this.recentBrowsing ? template : html`${template}`; } onSearchQuery(e) { this.searchQuery = e.detail.query; this.showAll = false; } deviceTemplate(deviceName, deviceType, tabItems) { return html`

${deviceName}

${this.panelListTemplate()} `; } generateTabList() { let renderArray = []; let renderInfo = {}; for (let tab of this.currentSyncedTabs) { if (!(tab.client in renderInfo)) { renderInfo[tab.client] = { name: tab.device, deviceType: tab.deviceType, tabs: [], }; } renderInfo[tab.client].tabs.push(tab); } // Add devices without tabs for (let device of this.devices) { if (!(device.id in renderInfo)) { renderInfo[device.id] = { name: device.name, deviceType: device.clientType, tabs: [], }; } } for (let id in renderInfo) { let tabItems = this.searchQuery ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs)) : this.getTabItems(renderInfo[id].tabs); if (tabItems.length) { const template = this.recentBrowsing ? this.deviceTemplate( renderInfo[id].name, renderInfo[id].deviceType, tabItems ) : html`${this.deviceTemplate( renderInfo[id].name, renderInfo[id].deviceType, tabItems )} `; renderArray.push(template); if (this.isShowAllLinkVisible(tabItems)) { renderArray.push(html` `); } } else { // Check renderInfo[id].tabs.length to determine whether to display an // empty tab list message or empty search results message. // If there are no synced tabs, we always display the empty tab list // message, even if there is an active search query. renderArray.push( this.noDeviceTabsTemplate( renderInfo[id].name, renderInfo[id].deviceType, Boolean(renderInfo[id].tabs.length) ) ); } } return renderArray; } isShowAllLinkVisible(tabItems) { return ( this.recentBrowsing && this.searchQuery && tabItems.length > this.maxTabsLength && !this.showAll ); } enableShowAll(event) { if ( event.type == "click" || (event.type == "keydown" && event.code == "Enter") || (event.type == "keydown" && event.code == "Space") ) { event.preventDefault(); this.showAll = true; Services.telemetry.recordEvent( "firefoxview_next", "search_show_all", "showallbutton", null, { section: "syncedtabs", } ); } } generateCardContent() { switch (this._currentSetupStateIndex) { case 0 /* error-state */: if (this.errorState) { return this.generateMessageCard({ error: true }); } return this.generateMessageCard({ action: "loading" }); case 1 /* not-signed-in */: if (Services.prefs.prefHasUserValue("services.sync.lastversion")) { // If this pref is set, the user has signed out of sync. // This path is also taken if we are disconnected from sync. See bug 1784055 return this.generateMessageCard({ error: true, errorState: "signed-out", }); } return this.generateMessageCard({ action: "sign-in" }); case 2 /* connect-secondary-device*/: return this.generateMessageCard({ action: "add-device" }); case 3 /* disabled-tab-sync */: return this.generateMessageCard({ action: "sync-tabs-disabled" }); case 4 /* synced-tabs-loaded*/: // There seems to be an edge case where sync says everything worked // fine but we have no devices. if (!this.devices.length) { return this.generateMessageCard({ action: "add-device" }); } return this.generateTabList(); } return html``; } render() { this.open = !TabsSetupFlowManager.isTabSyncSetupComplete || Services.prefs.getBoolPref(UI_OPEN_STATE, true); let renderArray = []; renderArray.push(html` `); renderArray.push(html` `); if (!this.recentBrowsing) { renderArray.push(html`
${when( isSearchEnabled() || this._currentSetupStateIndex === 4, () => html`
${when( isSearchEnabled(), () => html`
` )} ${when( this._currentSetupStateIndex === 4, () => html` ` )}
` )}
`); } if (this.recentBrowsing) { renderArray.push( html` >

${this.generateCardContent()}
` ); } else { renderArray.push( html`
${this.generateCardContent()}
` ); } return renderArray; } async onReload() { await TabsSetupFlowManager.syncOnPageReload(); } getTabItems(tabs) { tabs = tabs || this.tabs; return tabs?.map(tab => ({ icon: tab.icon, title: tab.title, time: tab.lastUsed * 1000, url: tab.url, primaryL10nId: "firefoxview-tabs-list-tab-button", primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), secondaryL10nId: "fxviewtabrow-options-menu-button", secondaryL10nArgs: JSON.stringify({ tabTitle: tab.title }), })); } updateTabsList(syncedTabs) { if (!syncedTabs.length) { this.currentSyncedTabs = syncedTabs; this.sendTabTelemetry(0); } const tabsToRender = syncedTabs; // Return early if new tabs are the same as previous ones if ( JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs) ) { return; } this.currentSyncedTabs = tabsToRender; // Record the full tab count this.sendTabTelemetry(syncedTabs.length); } async getSyncedTabData() { this.devices = await lazy.SyncedTabs.getTabClients(); let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, { removeAllDupes: false, removeDeviceDupes: true, }); this.updateTabsList(tabs); } updated() { this.fullyUpdated = true; this.toggleVisibilityInCardContainer(); } sendTabTelemetry(numTabs) { /* Services.telemetry.recordEvent( "firefoxview_next", "synced_tabs", "tabs", null, { count: numTabs.toString(), } ); */ } } customElements.define("view-syncedtabs", SyncedTabsInView);