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);
},
};