/* 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, { ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", SyncedTabsManagement: "resource://services-sync/SyncedTabs.sys.mjs", COMMAND_CLOSETAB: "resource://gre/modules/FxAccountsCommon.sys.mjs", }); import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs"; import { TabsSetupFlowManager } from "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs"; import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs"; const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; /** * The controller for synced tabs components. * * @implements {ReactiveController} */ export class SyncedTabsController { /** * @type {boolean} */ contextMenu; currentSetupStateIndex = -1; currentSyncedTabs = []; devices = []; /** * The current error state as determined by `SyncedTabsErrorHandler`. * * @type {number} */ errorState = null; /** * Component associated with this controller. * * @type {ReactiveControllerHost} */ host; /** * @type {Function} */ pairDeviceCallback; searchQuery = ""; /** * @type {Function} */ signupCallback; /** * Construct a new SyncedTabsController. * * @param {ReactiveControllerHost} host * @param {object} options * @param {boolean} [options.contextMenu] * Whether synced tab items have a secondary context menu. * @param {Function} [options.pairDeviceCallback] * The function to call when the pair device window is opened. * @param {Function} [options.signupCallback] * The function to call when the signup window is opened. */ constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) { this.contextMenu = contextMenu; this.pairDeviceCallback = pairDeviceCallback; this.signupCallback = signupCallback; this.observe = this.observe.bind(this); this.host = host; this.host.addController(this); // Track tabs requested close per device but not-yet-sent, // it'll be in the form of {fxaDeviceId: Set(urls)} this._pendingCloseTabs = new Map(); // The last closed URL, for undo purposes this.lastClosedURL = null; } hostConnected() { this.host.addEventListener("click", this); } hostDisconnected() { this.host.removeEventListener("click", this); } get isSyncedTabsLoaded() { return this.currentSetupStateIndex === 4; } addSyncObservers() { Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED); Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); } removeSyncObservers() { Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED); Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); } 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); this.signupCallback?.(); break; } case "add-device": { TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); this.pairDeviceCallback?.(); 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; } } } else if (event.type == "click" && event.composedTarget.href) { const { switchToTabHavingURI } = event.view.browsingContext.topChromeWindow; switchToTabHavingURI(event.composedTarget.href, true, { ignoreFragment: true, }); } } async observe(_, topic, errorState) { if (topic == TOPIC_SETUPSTATE_CHANGED) { await this.updateStates(errorState); } if (topic == SYNCED_TABS_CHANGED) { // Usually this means we performed a sync, so clear the // "in-queue" things as those most likely got flushed this._pendingCloseTabs = new Map(); this.lastClosedURL = null; await this.getSyncedTabData(); } } async 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 await this.getSyncedTabData(); } this.currentSetupStateIndex = stateIndex; this.errorState = errorState; this.host.requestUpdate(); } actionMappings = { "sign-in": { header: "firefoxview-syncedtabs-signin-header-2", description: "firefoxview-syncedtabs-signin-description-2", buttonLabel: "firefoxview-syncedtabs-signin-primarybutton-2", }, "add-device": { header: "firefoxview-syncedtabs-adddevice-header-2", description: "firefoxview-syncedtabs-adddevice-description-2", 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", }, }; #getMessageCardForState({ error = false, action, errorState }) { errorState = errorState || this.errorState; let header, description, descriptionLink, buttonLabel, mainImageUrl; let descriptionArray; if (error) { let link; ({ header, description, link, buttonLabel } = SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); action = `${errorState}`; 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-empty.svg"; descriptionArray = [description]; } return { action, buttonLabel, descriptionArray, descriptionLink, error, header, mainImageUrl, }; } getRenderInfo() { let renderInfo = {}; for (let tab of this.currentSyncedTabs) { if (!(tab.client in renderInfo)) { renderInfo[tab.client] = { name: tab.device, deviceType: tab.deviceType, canClose: !!tab.availableCommands[lazy.COMMAND_CLOSETAB], 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) { renderInfo[id].tabItems = this.searchQuery ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id])) : this.getTabItems(renderInfo[id]); } return renderInfo; } getMessageCard() { switch (this.currentSetupStateIndex) { case 0 /* error-state */: if (this.errorState) { return this.#getMessageCardForState({ error: true }); } return this.#getMessageCardForState({ 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.#getMessageCardForState({ error: true, errorState: "signed-out", }); } return this.#getMessageCardForState({ action: "sign-in" }); case 2 /* connect-secondary-device*/: return this.#getMessageCardForState({ action: "add-device" }); case 3 /* disabled-tab-sync */: return this.#getMessageCardForState({ 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.#getMessageCardForState({ action: "add-device" }); } } return null; } /** * Turn renderInfo into a list of tabs for syncedtabs-tab-list * * @param {object} renderInfo * @param {Array} [renderInfo.tabs] * tabs to display to the user * @param {string} [renderInfo.name] * The name of the device for use when the user hovers over * the close button for context * @param {boolean} [renderInfo.canClose] * Whether the list should support remotely closing tabs */ getTabItems({ tabs, name, canClose }) { return tabs ?.map(tab => { let tabItem = { icon: tab.icon, title: tab.title, time: tab.lastUsed * 1000, url: tab.url, fxaDeviceId: tab.fxaDeviceId, primaryL10nId: "firefoxview-tabs-list-tab-button", primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), secondaryL10nId: this.contextMenu ? "fxviewtabrow-options-menu-button" : undefined, secondaryL10nArgs: this.contextMenu ? JSON.stringify({ tabTitle: tab.title }) : undefined, }; // We don't want to show the option to close remotely if the // device doesn't support it if (!canClose) { return tabItem; } // If this item has been requested to be closed, show // the undo instead until removed from the list if (tabItem.url === this.lastClosedURL) { tabItem.tertiaryL10nId = "text-action-undo"; tabItem.tertiaryActionClass = "undo-button"; tabItem.tertiaryL10nArgs = null; tabItem.closeRequested = true; } else { // Otherwise default to showing the close/dismiss button tabItem.tertiaryL10nId = "synced-tabs-context-close-tab-title"; tabItem.tertiaryL10nArgs = JSON.stringify({ deviceName: name }); tabItem.tertiaryActionClass = "dismiss-button"; tabItem.closeRequested = false; } return tabItem; }) .filter( item => !this.isURLQueuedToClose(item.fxaDeviceId, item.url) || item.url === this.lastClosedURL ); } updateTabsList(syncedTabs) { if (!syncedTabs.length) { this.currentSyncedTabs = syncedTabs; } const tabsToRender = syncedTabs; // Return early if new tabs are the same as previous ones if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) { return; } this.currentSyncedTabs = tabsToRender; this.host.requestUpdate(); } async getSyncedTabData() { this.devices = await lazy.SyncedTabs.getTabClients(); let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 5000, { removeAllDupes: false, removeDeviceDupes: true, }); this.updateTabsList(tabs); } // Wrappers and helpful methods for SyncedTabManagement // so FxView and Sidebar don't need to import requestCloseRemoteTab(fxaDeviceId, url) { if (!this._pendingCloseTabs.has(fxaDeviceId)) { this._pendingCloseTabs.set(fxaDeviceId, new Set()); } this._pendingCloseTabs.get(fxaDeviceId).add(url); this.lastClosedURL = url; lazy.SyncedTabsManagement.enqueueTabToClose(fxaDeviceId, url); } removePendingTabToClose(fxaDeviceId, url) { const urls = this._pendingCloseTabs.get(fxaDeviceId); if (urls) { urls.delete(url); if (!urls.size) { this._pendingCloseTabs.delete(fxaDeviceId); } } this.lastClosedURL = null; lazy.SyncedTabsManagement.removePendingTabToClose(fxaDeviceId, url); } isURLQueuedToClose(fxaDeviceId, url) { const urls = this._pendingCloseTabs.get(fxaDeviceId); return urls && urls.has(url); } }