diff --git a/browser/components/firefoxview/content/synced-tabs-error.svg b/browser/components/firefoxview/content/synced-tabs-error.svg new file mode 100644 index 000000000000..b2a322ef7492 --- /dev/null +++ b/browser/components/firefoxview/content/synced-tabs-error.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs index 3696ae17b1f4..926424180d44 100644 --- a/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs +++ b/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs @@ -200,6 +200,18 @@ export const TabsSetupFlowManager = new (class { syncState.syncEnabled ); } + + get currentDevice() { + if (!this.fxaSignedIn) { + return null; + } + let recentDevices = lazy.fxAccounts.device?.recentDeviceList; + if (!recentDevices) { + return null; + } + return recentDevices.find(device => device.isCurrentDevice)?.name; + } + get secondaryDeviceConnected() { if (!this.fxaSignedIn) { return false; diff --git a/browser/components/firefoxview/firefoxview-next.css b/browser/components/firefoxview/firefoxview-next.css index 9713ef7c5600..0c596bb82a2b 100644 --- a/browser/components/firefoxview/firefoxview-next.css +++ b/browser/components/firefoxview/firefoxview-next.css @@ -14,6 +14,7 @@ --fxview-text-primary-color: var(--newtab-text-primary-color, var(--in-content-page-color)); --fxview-text-color-hover: var(--newtab-text-primary-color); --fxview-primary-action-background: var(--newtab-primary-action-background, #0061e0); + --fxview-border: var(--fc-border-light, #CFCFD8); /* ensure utility button hover states match those of the rest of the page */ --in-content-button-background-hover: var(--fxview-element-background-hover); diff --git a/browser/components/firefoxview/firefoxview-next.html b/browser/components/firefoxview/firefoxview-next.html index ac882680925e..6d29177c67f8 100644 --- a/browser/components/firefoxview/firefoxview-next.html +++ b/browser/components/firefoxview/firefoxview-next.html @@ -13,6 +13,7 @@ + @@ -40,6 +41,10 @@ type="module" src="chrome://browser/content/firefoxview/fxview-category-navigation.mjs" > + @@ -95,6 +100,7 @@ + diff --git a/browser/components/firefoxview/fxview-empty-state.mjs b/browser/components/firefoxview/fxview-empty-state.mjs index 3c062931361a..76822e776f81 100644 --- a/browser/components/firefoxview/fxview-empty-state.mjs +++ b/browser/components/firefoxview/fxview-empty-state.mjs @@ -29,7 +29,7 @@ class FxviewEmptyState extends MozLitElement { headerLabel: { type: String }, headerIconUrl: { type: String }, isSelectedTab: { type: Boolean }, - descriptionLabel: { type: Array }, + descriptionLabels: { type: Array }, desciptionLink: { type: Object }, mainImageUrl: { type: String }, }; @@ -63,12 +63,12 @@ class FxviewEmptyState extends MozLitElement { this.descriptionLabels, (descLabel, index) => html`

` diff --git a/browser/components/firefoxview/jar.mn b/browser/components/firefoxview/jar.mn index f5f4720eb1f9..14ae3a42b847 100644 --- a/browser/components/firefoxview/jar.mn +++ b/browser/components/firefoxview/jar.mn @@ -13,6 +13,8 @@ browser.jar: content/browser/firefoxview/history.mjs content/browser/firefoxview/opentabs.mjs content/browser/firefoxview/view-opentabs.css + content/browser/firefoxview/syncedtabs.mjs + content/browser/firefoxview/view-syncedtabs.css content/browser/firefoxview/overview.mjs content/browser/firefoxview/firefoxview.css content/browser/firefoxview/firefoxview-next.css @@ -38,6 +40,7 @@ browser.jar: content/browser/firefoxview/category-recentlyclosed.svg (content/category-recentlyclosed.svg) content/browser/firefoxview/category-syncedtabs.svg (content/category-syncedtabs.svg) content/browser/firefoxview/tab-pickup-empty.svg (content/tab-pickup-empty.svg) + content/browser/firefoxview/synced-tabs-error.svg (content/synced-tabs-error.svg) content/browser/callout-tab-pickup.svg (content/callout-tab-pickup.svg) content/browser/callout-tab-pickup-dark.svg (content/callout-tab-pickup-dark.svg) content/browser/cfr-lightning.svg (content/cfr-lightning.svg) diff --git a/browser/components/firefoxview/syncedtabs.mjs b/browser/components/firefoxview/syncedtabs.mjs new file mode 100644 index 000000000000..9b06b6a16c07 --- /dev/null +++ b/browser/components/firefoxview/syncedtabs.mjs @@ -0,0 +1,440 @@ +/* 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 } from "chrome://global/content/vendor/lit.all.mjs"; +import { ViewPage } from "./viewpage.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.boundObserve = (...args) => this.observe(...args); + this._currentSetupStateIndex = -1; + this.errorState = null; + this._id = Math.floor(Math.random() * 10e6); + this.currentSyncedTabs = []; + if (this.overview) { + this.maxTabsLength = 5; + } else { + // Setting maxTabsLength to -1 for no max + this.maxTabsLength = -1; + } + this.devices = []; + } + + static properties = { + ...ViewPage.properties, + errorState: { type: Number }, + currentSyncedTabs: { type: Array }, + _currentSetupStateIndex: { type: Number }, + devices: { type: Array }, + }; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("click", this); + this.ownerDocument.addEventListener("visibilitychange", this); + Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + this.updateStates(); + this.onVisibilityChange(); + } + + cleanup() { + TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); + this.ownerDocument?.removeEventListener("visibilitychange", this); + Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED); + Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + } + + disconnectedCallback() { + this.cleanup(); + } + + 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); + } + + // Returning to fxview seems like a likely time for a device check + if (event.type == "visibilitychange") { + this.onVisibilityChange(); + } + } + onVisibilityChange() { + const isVisible = document.visibilityState == "visible"; + const isOpen = this.open; + if (isVisible && isOpen) { + this.update(); + TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); + } else { + TabsSetupFlowManager.updateViewVisibility( + this._id, + isVisible ? "closed" : "hidden" + ); + } + } + + async observe(subject, topic, errorState) { + if (topic == TOPIC_SETUPSTATE_CHANGED) { + this.updateStates({ errorState }); + } + if (topic == SYNCED_TABS_CHANGED) { + this.getSyncedTabData(); + } + } + + updateStates({ + stateIndex = TabsSetupFlowManager.uiStateIndex, + 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", + link: "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", + checkboxLabel: "firefoxview-syncedtabs-synctabs-checkbox", + }, + }; + + generateMessageCard({ error = false, action, errorState }) { + errorState = errorState || this.errorState; + let header, + description, + descriptionLink, + buttonLabel, + checkboxLabel, + 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; + checkboxLabel = this.actionMappings[action].checkboxLabel; + descriptionLink = this.actionMappings[action]; + 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); + } + } + + onContextMenu(event) { + //TODO bug 1833664 + } + + noDeviceTabsTemplate(deviceName, deviceType) { + return html` +

+ + ${deviceName} +

+
No tabs open on this device
+
`; + } + + generateTabList() { + let renderArray = []; + let renderInfo = {}; + for (let tab of this.currentSyncedTabs) { + if (!(tab.device in renderInfo)) { + renderInfo[tab.device] = { + deviceType: tab.deviceType, + tabs: [], + }; + } + renderInfo[tab.device].tabs.push(tab); + } + // Add devices without tabs + let currentDevice = TabsSetupFlowManager.currentDevice; + for (let device of this.devices) { + if (device.name == currentDevice) { + continue; + } + if (!(device.name in renderInfo)) { + renderInfo[device.name] = { + deviceType: device.type, + tabs: [], + }; + } + } + for (let deviceName in renderInfo) { + if (renderInfo[deviceName].tabs.length) { + renderArray.push(html` +

+ + ${deviceName} +

+ +
`); + } else { + renderArray.push( + this.noDeviceTabsTemplate( + deviceName, + renderInfo[deviceName].deviceType + ) + ); + } + } + return renderArray; + } + render() { + const stateIndex = this._currentSetupStateIndex; + + this.open = + !TabsSetupFlowManager.isTabSyncSetupComplete || + Services.prefs.getBoolPref(UI_OPEN_STATE, true); + + let renderArray = []; + renderArray.push(html` `); + renderArray.push(html` `); + if (!this.overview) { + renderArray.push(html`
+ +
`); + } + + switch (stateIndex) { + case 0 /* error-state */: + if (this.errorState) { + renderArray.push(this.generateMessageCard({ error: true })); + } + break; + 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 + renderArray.push( + this.generateMessageCard({ error: true, errorState: "signed-out" }) + ); + } else { + renderArray.push(this.generateMessageCard({ action: "sign-in" })); + } + break; + case 2 /* connect-secondary-device*/: + renderArray.push(this.generateMessageCard({ action: "add-device" })); + break; + case 3 /* disabled-tab-sync */: + renderArray.push( + this.generateMessageCard({ action: "sync-tabs-disabled" }) + ); + break; + case 4 /* synced-tabs-loaded*/: + renderArray = renderArray.concat(this.generateTabList()); + break; + } + 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: "firefoxview-close-button", + })); + } + + 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.getRecentTabs(50, { + removeAllDupes: false, + removeDeviceDupes: true, + }); + + this.updateTabsList(tabs); + } + + sendTabTelemetry(numTabs) { + Services.telemetry.recordEvent( + "firefoxview-next", + "synced_tabs", + "tabs", + null, + { + count: numTabs.toString(), + } + ); + } +} +customElements.define("view-syncedtabs", SyncedTabsInView); diff --git a/browser/components/firefoxview/tests/browser/browser.ini b/browser/components/firefoxview/tests/browser/browser.ini index f1cf58777959..b679afe9921a 100644 --- a/browser/components/firefoxview/tests/browser/browser.ini +++ b/browser/components/firefoxview/tests/browser/browser.ini @@ -30,6 +30,8 @@ skip-if = true # Bug 1783684 [browser_setup_state.js] [browser_setup_synced_tabs_loading.js] [browser_sync_admin_disabled.js] +[browser_syncedtabs_errors_firefoxview_next.js] +[browser_syncedtabs_firefoxview_next.js] [browser_tab_close_last_tab.js] [browser_tab_on_close_warning.js] [browser_tab_pickup_device_added_telemetry.js] diff --git a/browser/components/firefoxview/tests/browser/browser_notification_dot.js b/browser/components/firefoxview/tests/browser/browser_notification_dot.js index 01411ee260eb..6dc067775adc 100644 --- a/browser/components/firefoxview/tests/browser/browser_notification_dot.js +++ b/browser/components/firefoxview/tests/browser/browser_notification_dot.js @@ -17,11 +17,13 @@ function setupRecentDeviceListMocks() { name: "My desktop", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "My iphone", type: "mobile", + tabs: [], }, ]); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_errors.js b/browser/components/firefoxview/tests/browser/browser_setup_errors.js index e2733945a075..073a1b788289 100644 --- a/browser/components/firefoxview/tests/browser/browser_setup_errors.js +++ b/browser/components/firefoxview/tests/browser/browser_setup_errors.js @@ -14,11 +14,13 @@ async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "desktop", + tabs: [], }, ], }); diff --git a/browser/components/firefoxview/tests/browser/browser_setup_state.js b/browser/components/firefoxview/tests/browser/browser_setup_state.js index 5582f52f5da9..21e8eaf2bd5d 100644 --- a/browser/components/firefoxview/tests/browser/browser_setup_state.js +++ b/browser/components/firefoxview/tests/browser/browser_setup_state.js @@ -32,11 +32,13 @@ async function setupWithDesktopDevices() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "desktop", + tabs: [], }, ], }); @@ -119,6 +121,7 @@ add_task(async function test_signed_in() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, ], }); @@ -176,6 +179,7 @@ add_task(async function test_support_links() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, ], }); @@ -202,11 +206,13 @@ add_task(async function test_2nd_desktop_connected() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "desktop", + tabs: [], }, ], }); @@ -246,11 +252,13 @@ add_task(async function test_mobile_connected() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "mobile", + tabs: [], }, ], }); @@ -290,11 +298,13 @@ add_task(async function test_tablet_connected() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "tablet", + tabs: [], }, ], }); @@ -334,11 +344,13 @@ add_task(async function test_tab_sync_enabled() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other Device", type: "mobile", + tabs: [], }, ], }); @@ -413,6 +425,7 @@ add_task(async function test_mobile_promo() { id: 3, name: "Mobile Device", type: "mobile", + tabs: [], }); Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); @@ -555,6 +568,7 @@ add_task(async function test_mobile_promo_windows() { id: 3, name: "Mobile Device", type: "mobile", + tabs: [], }); Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated"); @@ -711,6 +725,7 @@ add_task(async function test_close_device_connected_tab() { name: "This Device", isCurrentDevice: true, type: "desktop", + tabs: [], }, ], }); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview_next.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview_next.js new file mode 100644 index 000000000000..5b1c9c9cc69c --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_errors_firefoxview_next.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) { + const sandbox = setupSyncFxAMocks({ + state, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "desktop", + tabs: [], + }, + ], + }); + return sandbox; +} + +async function tearDown(sandbox) { + sandbox?.restore(); + Services.prefs.clearUserPref("services.sync.lastTabFetch"); + Services.prefs.clearUserPref("identity.fxaccounts.enabled"); +} + +add_setup(async function () { + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.firefox-view-next", true], + ["services.sync.engine.tabs", true], + ["identity.fxaccounts.enabled", true], + ], + }); + + registerCleanupFunction(async function () { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + await tearDown(gSandbox); + }); +}); + +add_task(async function test_network_offline() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "offline" + ); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsComponent.shadowRoot, + { childList: true }, + () => syncedTabsComponent.shadowRoot.innerHTML.includes("network-offline") + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("network-offline"), + "Network offline message is shown" + ); + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "online" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_sync_error() { + const sandbox = await setupWithDesktopDevices(); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await BrowserTestUtils.waitForMutationCondition( + syncedTabsComponent.shadowRoot, + { childList: true }, + () => syncedTabsComponent.shadowRoot.innerHTML.includes("sync-error") + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("sync-error"), + "Correct message should show when there's a sync service error" + ); + + // Clear the error. + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview_next.js b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview_next.js new file mode 100644 index 000000000000..2e1ea5a3e3d3 --- /dev/null +++ b/browser/components/firefoxview/tests/browser/browser_syncedtabs_firefoxview_next.js @@ -0,0 +1,267 @@ +add_setup(async function () { + registerCleanupFunction(() => { + // reset internal state so it doesn't affect the next tests + TabsSetupFlowManager.resetInternalState(); + }); + + // gSync.init() is called in a requestIdleCallback. Force its initialization. + gSync.init(); + + registerCleanupFunction(async function () { + await tearDown(gSandbox); + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.firefox-view-next", true]], + }); +}); + +add_task(async function test_unconfigured_initial_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_NOT_CONFIGURED, + syncEnabled: false, + }); + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-signin"), + "Signin message is shown" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_signed_in() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await syncedTabsComponent.updateComplete; + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-adddevice"), + "Add device message is shown" + ); + }); + await tearDown(sandbox); +}); + +add_task(async function test_no_synced_tabs() { + Services.prefs.setBoolPref("services.sync.engine.tabs", false); + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + type: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await syncedTabsComponent.updateComplete; + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + ok( + emptyState.getAttribute("headerlabel").includes("syncedtabs-synctabs"), + "Enable synced tabs message is shown" + ); + }); + await tearDown(sandbox); + Services.prefs.setBoolPref("services.sync.engine.tabs", true); +}); + +add_task(async function test_no_error_for_two_desktop() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + clientType: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Device", + clientType: "desktop", + tabs: [], + }, + ], + }); + + await withFirefoxView({}, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await syncedTabsComponent.updateComplete; + // I don't love this, but I'm out of ideas + await TestUtils.waitForTick(); + let emptyState = + syncedTabsComponent.shadowRoot.querySelector("fxview-empty-state"); + is(emptyState, null, "No empty state should be shown"); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 1, "Should be 1 empty device"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_empty_state() { + const sandbox = setupMocks({ + state: UIState.STATUS_SIGNED_IN, + fxaDevices: [ + { + id: 1, + name: "This Device", + isCurrentDevice: true, + type: "desktop", + tabs: [], + }, + { + id: 2, + name: "Other Desktop", + type: "desktop", + tabs: [], + }, + { + id: 3, + name: "Other Mobile", + type: "phone", + tabs: [], + }, + ], + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await syncedTabsComponent.updateComplete; + // I don't love this, but I'm out of ideas + await TestUtils.waitForTick(); + let noTabs = syncedTabsComponent.shadowRoot.querySelectorAll(".notabs"); + is(noTabs.length, 2, "Should be 2 empty devices"); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h2[slot=header]"); + ok( + headers[0].textContent.includes("Other Desktop"), + "Text is correct (Desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("Other Mobile"), + "Text is correct (Mobile)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + }); + await tearDown(sandbox); +}); + +add_task(async function test_tabs() { + TabsSetupFlowManager.resetInternalState(); + + const sandbox = setupRecentDeviceListMocks(); + const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs"); + let mockTabs1 = getMockTabData(syncedTabsData1); + let getRecentTabsResult = mockTabs1; + syncedTabsMock.callsFake(() => { + info( + `Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n` + ); + return Promise.resolve(getRecentTabsResult); + }); + + await withFirefoxView({ openNewWindow: true }, async browser => { + const { document } = browser.contentWindow; + navigateToCategory(document, "syncedtabs"); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let syncedTabsComponent = document.querySelector( + "view-syncedtabs:not([slot=syncedtabs])" + ); + await syncedTabsComponent.updateComplete; + // I don't love this, but I'm out of ideas + await TestUtils.waitForTick(); + + let headers = + syncedTabsComponent.shadowRoot.querySelectorAll("h2[slot=header]"); + ok( + headers[0].textContent.includes("My desktop"), + "Text is correct (My desktop)" + ); + ok(headers[0].innerHTML.includes("icon desktop"), "Icon should be desktop"); + ok( + headers[1].textContent.includes("My iphone"), + "Text is correct (My iphone)" + ); + ok(headers[1].innerHTML.includes("icon phone"), "Icon should be phone"); + + let tabLists = + syncedTabsComponent.shadowRoot.querySelectorAll("fxview-tab-list"); + + let tabRow1 = tabLists[0].shadowRoot.querySelectorAll("fxview-tab-row"); + ok( + tabRow1[0].shadowRoot.textContent.includes, + "Internet for people, not profits - Mozilla" + ); + ok(tabRow1[1].shadowRoot.textContent.includes, "Sandboxes - Sinon.JS"); + is(tabRow1.length, 2, "Correct number of rows are displayed."); + let tabRow2 = tabLists[1].shadowRoot.querySelectorAll("fxview-tab-row"); + is(tabRow2.length, 2, "Correct number of rows are dispayed."); + ok(tabRow1[0].shadowRoot.textContent.includes, "The Guardian"); + ok(tabRow1[1].shadowRoot.textContent.includes, "The Times"); + }); + await tearDown(sandbox); +}); diff --git a/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js index ce770900777c..a6b764f50e7b 100644 --- a/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js +++ b/browser/components/firefoxview/tests/browser/browser_tab_pickup_device_added_telemetry.js @@ -15,12 +15,14 @@ function setupWithFxaDevices() { name: "My desktop", isCurrentDevice: true, type: "desktop", + tabs: [], }, { id: 2, name: "Other device", isCurrentDevice: false, type: "mobile", + tabs: [], }, ], })); diff --git a/browser/components/firefoxview/tests/browser/head.js b/browser/components/firefoxview/tests/browser/head.js index fb8feec5b0d8..dd35dee0c15b 100644 --- a/browser/components/firefoxview/tests/browser/head.js +++ b/browser/components/firefoxview/tests/browser/head.js @@ -243,7 +243,7 @@ function setupRecentDeviceListMocks() { } function getMockTabData(clients) { - return SyncedTabs._internal._createRecentTabsList(clients, 3); + return SyncedTabs._internal._createRecentTabsList(clients, 10); } async function setupListState(browser) { @@ -336,6 +336,9 @@ function setupMocks({ fxaDevices = null, state, syncEnabled = true }) { }), }; }); + sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => { + return Promise.resolve(fxaDevices); + }); return sandbox; } @@ -570,3 +573,13 @@ registerCleanupFunction(() => { // that might have prevented it gSandbox?.restore(); }); + +function navigateToCategory(document, category) { + const navigation = document.querySelector("fxview-category-navigation"); + let navButton = Array.from(navigation.categoryButtons).filter( + categoryButton => { + return categoryButton.name === category; + } + )[0]; + navButton.buttonEl.click(); +} diff --git a/browser/components/firefoxview/view-syncedtabs.css b/browser/components/firefoxview/view-syncedtabs.css new file mode 100644 index 000000000000..5158c873e06f --- /dev/null +++ b/browser/components/firefoxview/view-syncedtabs.css @@ -0,0 +1,59 @@ +/* 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 url("chrome://global/skin/in-content/common.css"); + +.icon { + vertical-align: bottom; + margin-inline-end: 5px; + display: inline-block; + width: 16px; + height: 16px; + background-position: center center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; +} + +.phone, .mobile { + background-image: url('chrome://browser/skin/device-phone.svg'); +} + +.desktop { + background-image: url('chrome://browser/skin/device-desktop.svg'); +} + +.tablet { + background-image: url('chrome://browser/skin/device-tablet.svg'); +} + +h2 { + display: flex; + align-items: center; +} + +.notabs { + margin-top: 15px; +} + +.blackbox { + border: 1px solid var(--fxview-border); + text-align: center; + height: 70px; + border-radius: 8px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +button.primary { + white-space: nowrap; + min-width: fit-content; +} + +label { + display: flex; + align-items: center; +} diff --git a/browser/locales/en-US/browser/firefoxView.ftl b/browser/locales/en-US/browser/firefoxView.ftl index 96aeb090a55e..41eb5eb4a50c 100644 --- a/browser/locales/en-US/browser/firefoxView.ftl +++ b/browser/locales/en-US/browser/firefoxView.ftl @@ -31,16 +31,28 @@ firefoxview-tabpickup-step-signin-header = Switch seamlessly between devices firefoxview-tabpickup-step-signin-description = To grab your phone tabs here, first sign in or create an account. firefoxview-tabpickup-step-signin-primarybutton = Continue +firefoxview-syncedtabs-signin-header = Grab tabs from anywhere +firefoxview-syncedtabs-signin-description = To see your tabs from wherever you use { -brand-product-name }, sign in to your account. If you don’t have an account, we’ll take you through the steps to sign up. +firefoxview-syncedtabs-signin-primarybutton = Sign in or sign up + firefoxview-tabpickup-adddevice-header = Sync { -brand-product-name } on your phone or tablet firefoxview-tabpickup-adddevice-description = Download { -brand-product-name } for mobile and sign in there. firefoxview-tabpickup-adddevice-learn-how = Learn how firefoxview-tabpickup-adddevice-primarybutton = Get { -brand-product-name } for mobile +firefoxview-syncedtabs-adddevice-header = Sign in to { -brand-product-name } on your other devices +firefoxview-syncedtabs-adddevice-description = To see your tabs from wherever you use { -brand-product-name }, sign in on all your devices. Learn how to
connect additional devices. +firefoxview-syncedtabs-adddevice-primarybutton = Try { -brand-product-name } for mobile + firefoxview-tabpickup-synctabs-header = Turn on tab syncing firefoxview-tabpickup-synctabs-description = Allow { -brand-short-name } to share tabs between devices. firefoxview-tabpickup-synctabs-learn-how = Learn how firefoxview-tabpickup-synctabs-primarybutton = Sync open tabs +firefoxview-syncedtabs-synctabs-header = Update your sync settings +firefoxview-syncedtabs-synctabs-description = To see tabs from other devices, you need to sync your open tabs. +firefoxview-syncedtabs-synctabs-checkbox = Allow open tabs to sync + firefoxview-tabpickup-fxa-admin-disabled-header = Your organization has disabled sync firefoxview-tabpickup-fxa-admin-disabled-description = { -brand-short-name } is not able to sync tabs between devices because your administrator has disabled syncing. @@ -60,6 +72,7 @@ firefoxview-tabpickup-password-locked-header = Enter your Primary Password to vi firefoxview-tabpickup-password-locked-description = To grab your tabs, you’ll need to enter the Primary Password for { -brand-short-name }. firefoxview-tabpickup-password-locked-link = Learn more firefoxview-tabpickup-password-locked-primarybutton = Enter Primary Password +firefoxview-syncedtab-password-locked-link = Learn more firefoxview-tabpickup-signed-out-header = Sign in to reconnect firefoxview-tabpickup-signed-out-description = To reconnect and grab your tabs, sign in to your { -fxaccount-brand-name }. diff --git a/services/sync/modules/SyncedTabs.sys.mjs b/services/sync/modules/SyncedTabs.sys.mjs index 234b4eef24a6..f32b9e36f3a2 100644 --- a/services/sync/modules/SyncedTabs.sys.mjs +++ b/services/sync/modules/SyncedTabs.sys.mjs @@ -81,17 +81,26 @@ let SyncedTabsInternal = { return reFilter.test(tab.url) || reFilter.test(tab.title); }, - _createRecentTabsList(clients, maxCount) { + _createRecentTabsList( + clients, + maxCount, + extraParams = { removeAllDupes: true, removeDeviceDupes: false } + ) { let tabs = []; for (let client of clients) { + if (extraParams.removeDeviceDupes) { + client.tabs = this._filterRecentTabsDupes(client.tabs); + } for (let tab of client.tabs) { tab.device = client.name; tab.deviceType = client.clientType; } tabs = [...tabs, ...client.tabs.reverse()]; } - tabs = this._filterRecentTabsDupes(tabs); + if (extraParams.removeAllDupes) { + tabs = this._filterRecentTabsDupes(tabs); + } tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, maxCount); return tabs; }, @@ -327,8 +336,8 @@ export var SyncedTabs = { // Get list of synced tabs across all devices/clients // truncated by value of maxCount param, sorted by // lastUsed value, and filtered for duplicate URLs - async getRecentTabs(maxCount) { + async getRecentTabs(maxCount, extraParams) { let clients = await this.getTabClients(); - return this._internal._createRecentTabsList(clients, maxCount); + return this._internal._createRecentTabsList(clients, maxCount, extraParams); }, };