Bug 1244622 - Add "Show More/All" (tabs) buttons in the Synced Tabs menu. r?markh draft
authorEdouard Oger <eoger@fastmail.com>
Mon, 06 Feb 2017 16:31:31 -0500
changeset 488253 c37d75e8ed2fd7a1af1ea83a775b281b237bf3a6
parent 487583 c7b015c488cfb2afbcff295a9639acd85df332f8
child 546688 1fa3b3a06c2a77162894835f3911d5c13fb9f7c3
push id46478
push userbmo:eoger@fastmail.com
push dateWed, 22 Feb 2017 22:09:57 +0000
reviewersmarkh
bugs1244622
milestone54.0a1
Bug 1244622 - Add "Show More/All" (tabs) buttons in the Synced Tabs menu. r?markh MozReview-Commit-ID: 7z9fTVCDGgF
browser/components/customizableui/CustomizableWidgets.jsm
browser/components/customizableui/content/panelUI.inc.xul
browser/components/customizableui/test/browser.ini
browser/components/customizableui/test/browser_967000_button_sync.js
browser/components/customizableui/test/browser_synced_tabs_menu.js
browser/locales/en-US/chrome/browser/browser.dtd
browser/themes/shared/customizableui/panelUI.inc.css
services/sync/modules/SyncedTabs.jsm
--- a/browser/components/customizableui/CustomizableWidgets.jsm
+++ b/browser/components/customizableui/CustomizableWidgets.jsm
@@ -304,16 +304,18 @@ const CustomizableWidgets = [
     viewId: "PanelUI-remotetabs",
     defaultArea: CustomizableUI.AREA_PANEL,
     deckIndices: {
       DECKINDEX_TABS: 0,
       DECKINDEX_TABSDISABLED: 1,
       DECKINDEX_FETCHING: 2,
       DECKINDEX_NOCLIENTS: 3,
     },
+    TABS_PER_PAGE: 25,
+    NEXT_PAGE_MIN_TABS: 5, // Minimum number of tabs displayed when we click "Show All"
     onCreated(aNode) {
       // Add an observer to the button so we get the animation during sync.
       // (Note the observer sets many attributes, including label and
       // tooltiptext, but we only want the 'syncstatus' attribute for the
       // animation)
       let doc = aNode.ownerDocument;
       let obnode = doc.createElementNS(kNSXUL, "observes");
       obnode.setAttribute("element", "sync-status");
@@ -396,23 +398,23 @@ const CustomizableWidgets = [
       // We call setAttribute instead of relying on the XBL property setter due
       // to things going wrong when we try and set the index before the XBL
       // binding has been created - see bug 1241851 for the gory details.
       deck.setAttribute("selectedIndex", index);
     },
 
     _showTabsPromise: Promise.resolve(),
     // Update the tab list after any existing in-flight updates are complete.
-    _showTabs() {
+    _showTabs(paginationInfo) {
       this._showTabsPromise = this._showTabsPromise.then(() => {
-        return this.__showTabs();
+        return this.__showTabs(paginationInfo);
       });
     },
     // Return a new promise to update the tab list.
-    __showTabs() {
+    __showTabs(paginationInfo) {
       let doc = this._tabsList.ownerDocument;
       return SyncedTabs.getTabClients().then(clients => {
         // The view may have been hidden while the promise was resolving.
         if (!this._tabsList) {
           return;
         }
         if (clients.length === 0 && !SyncedTabs.hasSyncedThisSession) {
           // the "fetching tabs" deck is being shown - let's leave it there.
@@ -422,26 +424,30 @@ const CustomizableWidgets = [
 
         if (clients.length === 0) {
           this.setDeckIndex(this.deckIndices.DECKINDEX_NOCLIENTS);
           return;
         }
 
         this.setDeckIndex(this.deckIndices.DECKINDEX_TABS);
         this._clearTabList();
-        SyncedTabs.sortTabClientsByLastUsed(clients, 50 /* maxTabs */);
+        SyncedTabs.sortTabClientsByLastUsed(clients);
         let fragment = doc.createDocumentFragment();
 
         for (let client of clients) {
           // add a menu separator for all clients other than the first.
           if (fragment.lastChild) {
             let separator = doc.createElementNS(kNSXUL, "menuseparator");
             fragment.appendChild(separator);
           }
-          this._appendClient(client, fragment);
+          if (paginationInfo && paginationInfo.clientId == client.id) {
+            this._appendClient(client, fragment, paginationInfo.maxTabs);
+          } else {
+            this._appendClient(client, fragment);
+          }
         }
         this._tabsList.appendChild(fragment);
       }).catch(err => {
         Cu.reportError(err);
       }).then(() => {
         // an observer for tests.
         Services.obs.notifyObservers(null, "synced-tabs-menu:test:tabs-updated", null);
       });
@@ -461,36 +467,56 @@ const CustomizableWidgets = [
       }
       let message = this._tabsList.getAttribute(messageAttr);
       let doc = this._tabsList.ownerDocument;
       let messageLabel = doc.createElementNS(kNSXUL, "label");
       messageLabel.textContent = message;
       appendTo.appendChild(messageLabel);
       return messageLabel;
     },
-    _appendClient(client, attachFragment) {
+    _appendClient(client, attachFragment, maxTabs = this.TABS_PER_PAGE) {
       let doc = attachFragment.ownerDocument;
       // Create the element for the remote client.
       let clientItem = doc.createElementNS(kNSXUL, "label");
       clientItem.setAttribute("itemtype", "client");
       let window = doc.defaultView;
       clientItem.setAttribute("tooltiptext",
         window.gSyncUI.formatLastSyncDate(new Date(client.lastModified)));
       clientItem.textContent = client.name;
 
       attachFragment.appendChild(clientItem);
 
       if (client.tabs.length == 0) {
         let label = this._appendMessageLabel("notabsforclientlabel", attachFragment);
         label.setAttribute("class", "PanelUI-remotetabs-notabsforclient-label");
       } else {
+        // If this page will display all tabs, show no additional buttons.
+        // If the next page will display all the remaining tabs, show a "Show All" button
+        // Otherwise, show a "Shore More" button
+        let hasNextPage = client.tabs.length > maxTabs;
+        let nextPageIsLastPage = hasNextPage && maxTabs + this.TABS_PER_PAGE >= client.tabs.length;
+        if (nextPageIsLastPage) {
+          // When the user clicks "Show All", try to have at least NEXT_PAGE_MIN_TABS more tabs
+          // to display in order to avoid user frustration
+          maxTabs = Math.min(client.tabs.length - this.NEXT_PAGE_MIN_TABS, maxTabs);
+        }
+        if (hasNextPage) {
+          client.tabs = client.tabs.slice(0, maxTabs);
+        }
         for (let tab of client.tabs) {
           let tabEnt = this._createTabElement(doc, tab);
           attachFragment.appendChild(tabEnt);
         }
+        if (hasNextPage) {
+          let showAllEnt = this._createShowMoreElement(doc, client.id,
+                                                       nextPageIsLastPage ?
+                                                       Infinity :
+                                                       maxTabs + this.TABS_PER_PAGE);
+          attachFragment.appendChild(showAllEnt);
+        }
       }
     },
     _createTabElement(doc, tabInfo) {
       let item = doc.createElementNS(kNSXUL, "toolbarbutton");
       let tooltipText = (tabInfo.title ? tabInfo.title + "\n" : "") + tabInfo.url;
       item.setAttribute("itemtype", "tab");
       item.setAttribute("class", "subviewbutton");
       item.setAttribute("targetURI", tabInfo.url);
@@ -501,16 +527,39 @@ const CustomizableWidgets = [
       // respects different buttons (eg, to open in a new tab).
       item.addEventListener("click", e => {
         doc.defaultView.openUILink(tabInfo.url, e);
         CustomizableUI.hidePanelForNode(item);
         BrowserUITelemetry.countSyncedTabEvent("open", "toolbarbutton-subview");
       });
       return item;
     },
+    _createShowMoreElement(doc, clientId, showCount) {
+      let labelAttr, tooltipAttr;
+      if (showCount === Infinity) {
+        labelAttr = "showAllLabel";
+        tooltipAttr = "showAllTooltipText";
+      } else {
+        labelAttr = "showMoreLabel";
+        tooltipAttr = "showMoreTooltipText";
+      }
+      let showAllItem = doc.createElementNS(kNSXUL, "toolbarbutton");
+      showAllItem.setAttribute("itemtype", "showmorebutton");
+      showAllItem.setAttribute("class", "subviewbutton");
+      let label = this._tabsList.getAttribute(labelAttr);
+      showAllItem.setAttribute("label", label);
+      let tooltipText = this._tabsList.getAttribute(tooltipAttr);
+      showAllItem.setAttribute("tooltiptext", tooltipText);
+      showAllItem.addEventListener("click", e => {
+        e.preventDefault();
+        e.stopPropagation();
+        this._showTabs({ clientId, maxTabs: showCount });
+      });
+      return showAllItem;
+    }
   }, {
     id: "privatebrowsing-button",
     shortcutId: "key_privatebrowsing",
     defaultArea: CustomizableUI.AREA_PANEL,
     onCommand(e) {
       let win = e.target.ownerGlobal;
       win.OpenBrowserWindow({private: true});
     }
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -120,16 +120,20 @@
                            oncommand="gSyncUI.doSync();"
                            closemenu="none"/>
             <menuseparator id="PanelUI-remotetabs-separator"/>
           </vbox>
           <deck id="PanelUI-remotetabs-deck">
             <!-- Sync is ready to Sync and the "tabs" engine is enabled -->
             <vbox id="PanelUI-remotetabs-tabspane">
               <vbox id="PanelUI-remotetabs-tabslist"
+                    showAllLabel="&appMenuRemoteTabs.showAll.label;"
+                    showAllTooltipText="&appMenuRemoteTabs.showAll.tooltip;"
+                    showMoreLabel="&appMenuRemoteTabs.showMore.label;"
+                    showMoreTooltipText="&appMenuRemoteTabs.showMore.tooltip;"
                     notabsforclientlabel="&appMenuRemoteTabs.notabs.label;"
                     />
             </vbox>
             <!-- Sync is ready to Sync but the "tabs" engine isn't enabled-->
             <hbox id="PanelUI-remotetabs-tabsdisabledpane" pack="center" flex="1">
               <vbox class="PanelUI-remotetabs-instruction-box">
                 <hbox pack="center">
                   <html:img class="fxaSyncIllustration" src="chrome://browser/skin/fxa/sync-illustration.svg"/>
--- a/browser/components/customizableui/test/browser.ini
+++ b/browser/components/customizableui/test/browser.ini
@@ -95,17 +95,16 @@ skip-if = os == "linux" # Intermittent f
 [browser_948985_non_removable_defaultArea.js]
 [browser_952963_areaType_getter_no_area.js]
 [browser_956602_remove_special_widget.js]
 [browser_962069_drag_to_overflow_chevron.js]
 [browser_962884_opt_in_disable_hyphens.js]
 [browser_963639_customizing_attribute_non_customizable_toolbar.js]
 [browser_967000_button_charEncoding.js]
 [browser_967000_button_feeds.js]
-[browser_967000_button_sync.js]
 [browser_968447_bookmarks_toolbar_items_in_panel.js]
 skip-if = os == "linux" # Intemittent failures - bug 979207
 [browser_968565_insert_before_hidden_items.js]
 [browser_969427_recreate_destroyed_widget_after_reset.js]
 [browser_969661_character_encoding_navbar_disabled.js]
 [browser_970511_undo_restore_default.js]
 [browser_972267_customizationchange_events.js]
 [browser_973641_button_addon.js]
@@ -146,9 +145,10 @@ skip-if = os == "mac"
 [browser_1087303_button_preferences.js]
 [browser_1089591_still_customizable_after_reset.js]
 [browser_1096763_seen_widgets_post_reset.js]
 [browser_1161838_inserted_new_default_buttons.js]
 [browser_bootstrapped_custom_toolbar.js]
 [browser_customizemode_contextmenu_menubuttonstate.js]
 [browser_panel_toggle.js]
 [browser_switch_to_customize_mode.js]
+[browser_synced_tabs_menu.js]
 [browser_check_tooltips_in_navbar.js]
deleted file mode 100644
--- a/browser/components/customizableui/test/browser_967000_button_sync.js
+++ /dev/null
@@ -1,337 +0,0 @@
-/* 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/. */
-
-"use strict";
-
-requestLongerTimeout(2);
-
-let {SyncedTabs} = Cu.import("resource://services-sync/SyncedTabs.jsm", {});
-
-XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm");
-
-// These are available on the widget implementation, but it seems impossible
-// to grab that impl at runtime.
-const DECKINDEX_TABS = 0;
-const DECKINDEX_TABSDISABLED = 1;
-const DECKINDEX_FETCHING = 2;
-const DECKINDEX_NOCLIENTS = 3;
-
-var initialLocation = gBrowser.currentURI.spec;
-var newTab = null;
-
-// A helper to notify there are new tabs. Returns a promise that is resolved
-// once the UI has been updated.
-function updateTabsPanel() {
-  let promiseTabsUpdated = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
-  Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED, null);
-  return promiseTabsUpdated;
-}
-
-// This is the mock we use for SyncedTabs.jsm - tests may override various
-// functions.
-let mockedInternal = {
-  get isConfiguredToSyncTabs() { return true; },
-  getTabClients() { return []; },
-  syncTabs() {},
-  hasSyncedThisSession: false,
-};
-
-
-add_task(function* setup() {
-  let oldInternal = SyncedTabs._internal;
-  SyncedTabs._internal = mockedInternal;
-
-  // This test hacks some observer states to simulate a user being signed
-  // in to Sync - restore them when the test completes.
-  let initialObserverStates = {};
-  for (let id of ["sync-reauth-state", "sync-setup-state", "sync-syncnow-state"]) {
-    initialObserverStates[id] = document.getElementById(id).hidden;
-  }
-
-  registerCleanupFunction(() => {
-    SyncedTabs._internal = oldInternal;
-    for (let [id, initial] of Object.entries(initialObserverStates)) {
-      document.getElementById(id).hidden = initial;
-    }
-  });
-});
-
-// The test expects the about:preferences#sync page to open in the current tab
-function* openPrefsFromMenuPanel(expectedPanelId, entryPoint) {
-  info("Check Sync button functionality");
-  Services.prefs.setCharPref("identity.fxaccounts.remote.signup.uri", "http://example.com/");
-
-  // check the button's functionality
-  yield PanelUI.show();
-
-  if (entryPoint == "uitour") {
-    UITour.tourBrowsersByWindow.set(window, new Set());
-    UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser);
-  }
-
-  let syncButton = document.getElementById("sync-button");
-  ok(syncButton, "The Sync button was added to the Panel Menu");
-
-  syncButton.click();
-  let syncPanel = document.getElementById("PanelUI-remotetabs");
-  ok(syncPanel.getAttribute("current"), "Sync Panel is in view");
-
-  // Sync is not configured - verify that state is reflected.
-  let subpanel = document.getElementById(expectedPanelId)
-  ok(!subpanel.hidden, "sync setup element is visible");
-
-  // Find and click the "setup" button.
-  let setupButton = subpanel.querySelector(".PanelUI-remotetabs-prefs-button");
-  setupButton.click();
-
-  let deferred = Promise.defer();
-  let handler = (e) => {
-    if (e.originalTarget != gBrowser.selectedBrowser.contentDocument ||
-        e.target.location.href == "about:blank") {
-      info("Skipping spurious 'load' event for " + e.target.location.href);
-      return;
-    }
-    gBrowser.selectedBrowser.removeEventListener("load", handler, true);
-    deferred.resolve();
-  }
-  gBrowser.selectedBrowser.addEventListener("load", handler, true);
-
-  yield deferred.promise;
-  newTab = gBrowser.selectedTab;
-
-  is(gBrowser.currentURI.spec, "about:preferences?entrypoint=" + entryPoint + "#sync",
-    "Firefox Sync preference page opened with `menupanel` entrypoint");
-  ok(!isPanelUIOpen(), "The panel closed");
-
-  if (isPanelUIOpen()) {
-    let panelHidePromise = promisePanelHidden(window);
-    PanelUI.hide();
-    yield panelHidePromise;
-  }
-}
-
-function* asyncCleanup() {
-  Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
-  // reset the panel UI to the default state
-  yield resetCustomization();
-  ok(CustomizableUI.inDefaultState, "The panel UI is in default state again.");
-
-  // restore the tabs
-  gBrowser.addTab(initialLocation);
-  gBrowser.removeTab(newTab);
-  UITour.tourBrowsersByWindow.delete(window);
-}
-
-// When Sync is not setup.
-add_task(() => openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs"));
-add_task(asyncCleanup);
-
-// When Sync is configured in a "needs reauthentication" state.
-add_task(function* () {
-  // configure our broadcasters so we are in the right state.
-  document.getElementById("sync-reauth-state").hidden = false;
-  document.getElementById("sync-setup-state").hidden = true;
-  document.getElementById("sync-syncnow-state").hidden = true;
-  yield openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs")
-});
-
-// Test the mobile promo links
-add_task(function* () {
-  // change the preferences for the mobile links.
-  Services.prefs.setCharPref("identity.mobilepromo.android", "http://example.com/?os=android&tail=");
-  Services.prefs.setCharPref("identity.mobilepromo.ios", "http://example.com/?os=ios&tail=");
-
-  mockedInternal.getTabClients = () => [];
-  mockedInternal.syncTabs = () => Promise.resolve();
-
-  document.getElementById("sync-reauth-state").hidden = true;
-  document.getElementById("sync-setup-state").hidden = true;
-  document.getElementById("sync-syncnow-state").hidden = false;
-
-  let syncPanel = document.getElementById("PanelUI-remotetabs");
-  let links = syncPanel.querySelectorAll(".remotetabs-promo-link");
-
-  is(links.length, 2, "found 2 links as expected");
-
-  // test each link and left and middle mouse buttons
-  for (let link of links) {
-    for (let button = 0; button < 2; button++) {
-      yield PanelUI.show();
-      EventUtils.sendMouseEvent({ type: "click", button }, link, window);
-      // the panel should have been closed.
-      ok(!isPanelUIOpen(), "click closed the panel");
-      // should be a new tab - wait for the load.
-      is(gBrowser.tabs.length, 2, "there's a new tab");
-      yield new Promise(resolve => {
-        if (gBrowser.selectedBrowser.currentURI.spec == "about:blank") {
-          gBrowser.selectedBrowser.addEventListener("load", function(e) {
-            resolve();
-          }, {capture: true, once: true});
-          return;
-        }
-        // the new tab has already transitioned away from about:blank so we
-        // are good to go.
-        resolve();
-      });
-
-      let os = link.getAttribute("mobile-promo-os");
-      let expectedUrl = `http://example.com/?os=${os}&tail=synced-tabs`;
-      is(gBrowser.selectedBrowser.currentURI.spec, expectedUrl, "correct URL");
-      gBrowser.removeTab(gBrowser.selectedTab);
-    }
-  }
-
-  // test each link and right mouse button - should be a noop.
-  yield PanelUI.show();
-  for (let link of links) {
-    EventUtils.sendMouseEvent({ type: "click", button: 2 }, link, window);
-    // the panel should still be open
-    ok(isPanelUIOpen(), "panel remains open after right-click");
-    is(gBrowser.tabs.length, 1, "no new tab was opened");
-  }
-  PanelUI.hide();
-
-  Services.prefs.clearUserPref("identity.mobilepromo.android");
-  Services.prefs.clearUserPref("identity.mobilepromo.ios");
-});
-
-// Test the "Sync Now" button
-add_task(function* () {
-  mockedInternal.getTabClients = () => [];
-  mockedInternal.syncTabs = () => {
-    return Promise.resolve();
-  }
-
-  // configure our broadcasters so we are in the right state.
-  document.getElementById("sync-reauth-state").hidden = true;
-  document.getElementById("sync-setup-state").hidden = true;
-  document.getElementById("sync-syncnow-state").hidden = false;
-
-  yield PanelUI.show();
-  document.getElementById("sync-button").click();
-  let syncPanel = document.getElementById("PanelUI-remotetabs");
-  ok(syncPanel.getAttribute("current"), "Sync Panel is in view");
-
-  let subpanel = document.getElementById("PanelUI-remotetabs-main")
-  ok(!subpanel.hidden, "main pane is visible");
-  let deck = document.getElementById("PanelUI-remotetabs-deck");
-
-  // The widget is still fetching tabs, as we've neutered everything that
-  // provides them
-  is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible");
-
-  let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow");
-
-  let didSync = false;
-  let oldDoSync = gSyncUI.doSync;
-  gSyncUI.doSync = function() {
-    didSync = true;
-    mockedInternal.hasSyncedThisSession = true;
-    gSyncUI.doSync = oldDoSync;
-  }
-  syncNowButton.click();
-  ok(didSync, "clicking the button called the correct function");
-
-  // Tell the widget there are tabs available, but with zero clients.
-  mockedInternal.getTabClients = () => {
-    return Promise.resolve([]);
-  }
-  yield updateTabsPanel();
-  // The UI should be showing the "no clients" pane.
-  is(deck.selectedIndex, DECKINDEX_NOCLIENTS, "no-clients deck entry is visible");
-
-  // Tell the widget there are tabs available - we have 3 clients, one with no
-  // tabs.
-  mockedInternal.getTabClients = () => {
-    return Promise.resolve([
-      {
-        id: "guid_mobile",
-        type: "client",
-        name: "My Phone",
-        tabs: [],
-      },
-      {
-        id: "guid_desktop",
-        type: "client",
-        name: "My Desktop",
-        tabs: [
-          {
-            title: "http://example.com/10",
-            lastUsed: 10, // the most recent
-          },
-          {
-            title: "http://example.com/1",
-            lastUsed: 1, // the least recent.
-          },
-          {
-            title: "http://example.com/5",
-            lastUsed: 5,
-          },
-        ],
-      },
-      {
-        id: "guid_second_desktop",
-        name: "My Other Desktop",
-        tabs: [
-          {
-            title: "http://example.com/6",
-            lastUsed: 6,
-          }
-        ],
-      },
-    ]);
-  };
-  yield updateTabsPanel();
-
-  // The UI should be showing tabs!
-  is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible");
-  let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
-  let node = tabList.firstChild;
-  // First entry should be the client with the most-recent tab.
-  is(node.getAttribute("itemtype"), "client", "node is a client entry");
-  is(node.textContent, "My Desktop", "correct client");
-  // Next entry is the most-recent tab
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "tab", "node is a tab");
-  is(node.getAttribute("label"), "http://example.com/10");
-
-  // Next entry is the next-most-recent tab
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "tab", "node is a tab");
-  is(node.getAttribute("label"), "http://example.com/5");
-
-  // Next entry is the least-recent tab from the first client.
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "tab", "node is a tab");
-  is(node.getAttribute("label"), "http://example.com/1");
-
-  // Next is a menuseparator between the clients.
-  node = node.nextSibling;
-  is(node.nodeName, "menuseparator");
-
-  // Next is the client with 1 tab.
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "client", "node is a client entry");
-  is(node.textContent, "My Other Desktop", "correct client");
-  // Its single tab
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "tab", "node is a tab");
-  is(node.getAttribute("label"), "http://example.com/6");
-
-  // Next is a menuseparator between the clients.
-  node = node.nextSibling;
-  is(node.nodeName, "menuseparator");
-
-  // Next is the client with no tab.
-  node = node.nextSibling;
-  is(node.getAttribute("itemtype"), "client", "node is a client entry");
-  is(node.textContent, "My Phone", "correct client");
-  // There is a single node saying there's no tabs for the client.
-  node = node.nextSibling;
-  is(node.nodeName, "label", "node is a label");
-  is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client");
-
-  node = node.nextSibling;
-  is(node, null, "no more entries");
-});
new file mode 100644
--- /dev/null
+++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js
@@ -0,0 +1,427 @@
+/* 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/. */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+let {SyncedTabs} = Cu.import("resource://services-sync/SyncedTabs.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm");
+
+// These are available on the widget implementation, but it seems impossible
+// to grab that impl at runtime.
+const DECKINDEX_TABS = 0;
+const DECKINDEX_TABSDISABLED = 1;
+const DECKINDEX_FETCHING = 2;
+const DECKINDEX_NOCLIENTS = 3;
+
+var initialLocation = gBrowser.currentURI.spec;
+var newTab = null;
+
+// A helper to notify there are new tabs. Returns a promise that is resolved
+// once the UI has been updated.
+function updateTabsPanel() {
+  let promiseTabsUpdated = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
+  Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED, null);
+  return promiseTabsUpdated;
+}
+
+// This is the mock we use for SyncedTabs.jsm - tests may override various
+// functions.
+let mockedInternal = {
+  get isConfiguredToSyncTabs() { return true; },
+  getTabClients() { return Promise.resolve([]); },
+  syncTabs() { return Promise.resolve(); },
+  hasSyncedThisSession: false,
+};
+
+
+add_task(function* setup() {
+  let oldInternal = SyncedTabs._internal;
+  SyncedTabs._internal = mockedInternal;
+
+  // This test hacks some observer states to simulate a user being signed
+  // in to Sync - restore them when the test completes.
+  let initialObserverStates = {};
+  for (let id of ["sync-reauth-state", "sync-setup-state", "sync-syncnow-state"]) {
+    initialObserverStates[id] = document.getElementById(id).hidden;
+  }
+
+  registerCleanupFunction(() => {
+    SyncedTabs._internal = oldInternal;
+    for (let [id, initial] of Object.entries(initialObserverStates)) {
+      document.getElementById(id).hidden = initial;
+    }
+  });
+});
+
+// The test expects the about:preferences#sync page to open in the current tab
+function* openPrefsFromMenuPanel(expectedPanelId, entryPoint) {
+  info("Check Sync button functionality");
+  Services.prefs.setCharPref("identity.fxaccounts.remote.signup.uri", "http://example.com/");
+
+  // check the button's functionality
+  yield PanelUI.show();
+
+  if (entryPoint == "uitour") {
+    UITour.tourBrowsersByWindow.set(window, new Set());
+    UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser);
+  }
+
+  let syncButton = document.getElementById("sync-button");
+  ok(syncButton, "The Sync button was added to the Panel Menu");
+
+  let tabsUpdatedPromise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
+  syncButton.click();
+  yield tabsUpdatedPromise;
+  let syncPanel = document.getElementById("PanelUI-remotetabs");
+  ok(syncPanel.getAttribute("current"), "Sync Panel is in view");
+
+  // Sync is not configured - verify that state is reflected.
+  let subpanel = document.getElementById(expectedPanelId)
+  ok(!subpanel.hidden, "sync setup element is visible");
+
+  // Find and click the "setup" button.
+  let setupButton = subpanel.querySelector(".PanelUI-remotetabs-prefs-button");
+  setupButton.click();
+
+  let deferred = Promise.defer();
+  let handler = (e) => {
+    if (e.originalTarget != gBrowser.selectedBrowser.contentDocument ||
+        e.target.location.href == "about:blank") {
+      info("Skipping spurious 'load' event for " + e.target.location.href);
+      return;
+    }
+    gBrowser.selectedBrowser.removeEventListener("load", handler, true);
+    deferred.resolve();
+  }
+  gBrowser.selectedBrowser.addEventListener("load", handler, true);
+
+  yield deferred.promise;
+  newTab = gBrowser.selectedTab;
+
+  is(gBrowser.currentURI.spec, "about:preferences?entrypoint=" + entryPoint + "#sync",
+    "Firefox Sync preference page opened with `menupanel` entrypoint");
+  ok(!isPanelUIOpen(), "The panel closed");
+
+  if (isPanelUIOpen()) {
+    yield panelUIHide();
+  }
+}
+
+function panelUIHide() {
+  let panelHidePromise = promisePanelHidden(window);
+  PanelUI.hide();
+  return panelHidePromise;
+}
+
+function* asyncCleanup() {
+  Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
+  // reset the panel UI to the default state
+  yield resetCustomization();
+  ok(CustomizableUI.inDefaultState, "The panel UI is in default state again.");
+
+  // restore the tabs
+  gBrowser.addTab(initialLocation);
+  gBrowser.removeTab(newTab);
+  UITour.tourBrowsersByWindow.delete(window);
+}
+
+// When Sync is not setup.
+add_task(function* () {
+  document.getElementById("sync-reauth-state").hidden = true;
+  document.getElementById("sync-setup-state").hidden = false;
+  document.getElementById("sync-syncnow-state").hidden = true;
+  yield openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs")
+});
+add_task(asyncCleanup);
+
+// When Sync is configured in a "needs reauthentication" state.
+add_task(function* () {
+  // configure our broadcasters so we are in the right state.
+  document.getElementById("sync-reauth-state").hidden = false;
+  document.getElementById("sync-setup-state").hidden = true;
+  document.getElementById("sync-syncnow-state").hidden = true;
+  yield openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs")
+});
+
+// Test the mobile promo links
+add_task(function* () {
+  // change the preferences for the mobile links.
+  Services.prefs.setCharPref("identity.mobilepromo.android", "http://example.com/?os=android&tail=");
+  Services.prefs.setCharPref("identity.mobilepromo.ios", "http://example.com/?os=ios&tail=");
+
+  document.getElementById("sync-reauth-state").hidden = true;
+  document.getElementById("sync-setup-state").hidden = true;
+  document.getElementById("sync-syncnow-state").hidden = false;
+
+  let syncPanel = document.getElementById("PanelUI-remotetabs");
+  let links = syncPanel.querySelectorAll(".remotetabs-promo-link");
+
+  is(links.length, 2, "found 2 links as expected");
+
+  // test each link and left and middle mouse buttons
+  for (let link of links) {
+    for (let button = 0; button < 2; button++) {
+      yield PanelUI.show();
+      EventUtils.sendMouseEvent({ type: "click", button }, link, window);
+      // the panel should have been closed.
+      ok(!isPanelUIOpen(), "click closed the panel");
+      // should be a new tab - wait for the load.
+      is(gBrowser.tabs.length, 2, "there's a new tab");
+      yield new Promise(resolve => {
+        if (gBrowser.selectedBrowser.currentURI.spec == "about:blank") {
+          gBrowser.selectedBrowser.addEventListener("load", function(e) {
+            resolve();
+          }, {capture: true, once: true});
+          return;
+        }
+        // the new tab has already transitioned away from about:blank so we
+        // are good to go.
+        resolve();
+      });
+
+      let os = link.getAttribute("mobile-promo-os");
+      let expectedUrl = `http://example.com/?os=${os}&tail=synced-tabs`;
+      is(gBrowser.selectedBrowser.currentURI.spec, expectedUrl, "correct URL");
+      gBrowser.removeTab(gBrowser.selectedTab);
+    }
+  }
+
+  // test each link and right mouse button - should be a noop.
+  yield PanelUI.show();
+  for (let link of links) {
+    EventUtils.sendMouseEvent({ type: "click", button: 2 }, link, window);
+    // the panel should still be open
+    ok(isPanelUIOpen(), "panel remains open after right-click");
+    is(gBrowser.tabs.length, 1, "no new tab was opened");
+  }
+  yield panelUIHide();
+
+  Services.prefs.clearUserPref("identity.mobilepromo.android");
+  Services.prefs.clearUserPref("identity.mobilepromo.ios");
+});
+
+// Test the "Sync Now" button
+add_task(function* () {
+  // configure our broadcasters so we are in the right state.
+  document.getElementById("sync-reauth-state").hidden = true;
+  document.getElementById("sync-setup-state").hidden = true;
+  document.getElementById("sync-syncnow-state").hidden = false;
+
+  yield PanelUI.show();
+  let tabsUpdatedPromise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
+  document.getElementById("sync-button").click();
+  yield tabsUpdatedPromise;
+  let syncPanel = document.getElementById("PanelUI-remotetabs");
+  ok(syncPanel.getAttribute("current"), "Sync Panel is in view");
+
+  let subpanel = document.getElementById("PanelUI-remotetabs-main")
+  ok(!subpanel.hidden, "main pane is visible");
+  let deck = document.getElementById("PanelUI-remotetabs-deck");
+
+  // The widget is still fetching tabs, as we've neutered everything that
+  // provides them
+  is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible");
+
+  let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow");
+
+  let didSync = false;
+  let oldDoSync = gSyncUI.doSync;
+  gSyncUI.doSync = function() {
+    didSync = true;
+    mockedInternal.hasSyncedThisSession = true;
+    gSyncUI.doSync = oldDoSync;
+  }
+  syncNowButton.click();
+  ok(didSync, "clicking the button called the correct function");
+
+  // Tell the widget there are tabs available, but with zero clients.
+  mockedInternal.getTabClients = () => {
+    return Promise.resolve([]);
+  }
+  yield updateTabsPanel();
+  // The UI should be showing the "no clients" pane.
+  is(deck.selectedIndex, DECKINDEX_NOCLIENTS, "no-clients deck entry is visible");
+
+  // Tell the widget there are tabs available - we have 3 clients, one with no
+  // tabs.
+  mockedInternal.getTabClients = () => {
+    return Promise.resolve([
+      {
+        id: "guid_mobile",
+        type: "client",
+        name: "My Phone",
+        tabs: [],
+      },
+      {
+        id: "guid_desktop",
+        type: "client",
+        name: "My Desktop",
+        tabs: [
+          {
+            title: "http://example.com/10",
+            lastUsed: 10, // the most recent
+          },
+          {
+            title: "http://example.com/1",
+            lastUsed: 1, // the least recent.
+          },
+          {
+            title: "http://example.com/5",
+            lastUsed: 5,
+          },
+        ],
+      },
+      {
+        id: "guid_second_desktop",
+        name: "My Other Desktop",
+        tabs: [
+          {
+            title: "http://example.com/6",
+            lastUsed: 6,
+          }
+        ],
+      },
+    ]);
+  };
+  yield updateTabsPanel();
+
+  // The UI should be showing tabs!
+  is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible");
+  let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
+  let node = tabList.firstChild;
+  // First entry should be the client with the most-recent tab.
+  is(node.getAttribute("itemtype"), "client", "node is a client entry");
+  is(node.textContent, "My Desktop", "correct client");
+  // Next entry is the most-recent tab
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "tab", "node is a tab");
+  is(node.getAttribute("label"), "http://example.com/10");
+
+  // Next entry is the next-most-recent tab
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "tab", "node is a tab");
+  is(node.getAttribute("label"), "http://example.com/5");
+
+  // Next entry is the least-recent tab from the first client.
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "tab", "node is a tab");
+  is(node.getAttribute("label"), "http://example.com/1");
+
+  // Next is a menuseparator between the clients.
+  node = node.nextSibling;
+  is(node.nodeName, "menuseparator");
+
+  // Next is the client with 1 tab.
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "client", "node is a client entry");
+  is(node.textContent, "My Other Desktop", "correct client");
+  // Its single tab
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "tab", "node is a tab");
+  is(node.getAttribute("label"), "http://example.com/6");
+
+  // Next is a menuseparator between the clients.
+  node = node.nextSibling;
+  is(node.nodeName, "menuseparator");
+
+  // Next is the client with no tab.
+  node = node.nextSibling;
+  is(node.getAttribute("itemtype"), "client", "node is a client entry");
+  is(node.textContent, "My Phone", "correct client");
+  // There is a single node saying there's no tabs for the client.
+  node = node.nextSibling;
+  is(node.nodeName, "label", "node is a label");
+  is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client");
+
+  node = node.nextSibling;
+  is(node, null, "no more entries");
+
+  yield panelUIHide();
+});
+
+// Test the pagination capabilities (Show More/All tabs)
+add_task(function* () {
+  mockedInternal.getTabClients = () => {
+    return Promise.resolve([
+      {
+        id: "guid_desktop",
+        type: "client",
+        name: "My Desktop",
+        tabs: function() {
+          let allTabsDesktop = [];
+          // We choose 77 tabs, because TABS_PER_PAGE is 25, which means
+          // on the second to last page we should have 22 items shown
+          // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page)
+          for (let i = 1; i <= 77; i++) {
+            allTabsDesktop.push({ title: "Tab #" + i });
+          }
+          return allTabsDesktop;
+        }(),
+      }
+    ]);
+  };
+
+  // configure our broadcasters so we are in the right state.
+  document.getElementById("sync-reauth-state").hidden = true;
+  document.getElementById("sync-setup-state").hidden = true;
+  document.getElementById("sync-syncnow-state").hidden = false;
+
+  yield PanelUI.show();
+  let tabsUpdatedPromise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
+  document.getElementById("sync-button").click();
+  yield tabsUpdatedPromise;
+
+  // Check pre-conditions
+  let syncPanel = document.getElementById("PanelUI-remotetabs");
+  ok(syncPanel.getAttribute("current"), "Sync Panel is in view");
+  let subpanel = document.getElementById("PanelUI-remotetabs-main")
+  ok(!subpanel.hidden, "main pane is visible");
+  let deck = document.getElementById("PanelUI-remotetabs-deck");
+  is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs");
+
+  function checkTabsPage(tabsShownCount, showMoreLabel) {
+    let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
+    let node = tabList.firstChild;
+    is(node.getAttribute("itemtype"), "client", "node is a client entry");
+    is(node.textContent, "My Desktop", "correct client");
+    for (let i = 0; i < tabsShownCount; i++) {
+      node = node.nextSibling;
+      is(node.getAttribute("itemtype"), "tab", "node is a tab");
+      is(node.getAttribute("label"), "Tab #" + (i + 1), "the tab is the correct one");
+    }
+    let showMoreButton;
+    if (showMoreLabel) {
+      node = showMoreButton = node.nextSibling;
+      is(node.getAttribute("itemtype"), "showmorebutton", "node is a show more button");
+      is(node.getAttribute("label"), showMoreLabel);
+    }
+    node = node.nextSibling;
+    is(node, null, "no more entries");
+
+    return showMoreButton;
+  }
+
+  let showMoreButton;
+  function clickShowMoreButton() {
+    let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
+    showMoreButton.click();
+    return promise;
+  }
+
+  showMoreButton = checkTabsPage(25, "Show More");
+  yield clickShowMoreButton();
+
+  showMoreButton = checkTabsPage(50, "Show More");
+  yield clickShowMoreButton();
+
+  showMoreButton = checkTabsPage(72, "Show All");
+  yield clickShowMoreButton();
+
+  checkTabsPage(77, null);
+
+  yield panelUIHide();
+});
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -353,16 +353,24 @@ These should match what Safari and other
 <!ENTITY appMenuHistory.restoreSession.label "Restore Previous Session">
 <!ENTITY appMenuHistory.viewSidebar.label "View History Sidebar">
 <!ENTITY appMenuHelp.tooltip "Open Help Menu">
 
 <!ENTITY appMenuRemoteTabs.label "Synced Tabs">
 <!-- LOCALIZATION NOTE (appMenuRemoteTabs.notabs.label): This is shown beneath
      the name of a device when that device has no open tabs -->
 <!ENTITY appMenuRemoteTabs.notabs.label "No open tabs">
+<!-- LOCALIZATION NOTE (appMenuRemoteTabs.showMore.label, appMenuRemoteTabs.showMore.tooltip):
+     This is shown after the tabs list if we can display more tabs by clicking on the button -->
+<!ENTITY appMenuRemoteTabs.showMore.label "Show More">
+<!ENTITY appMenuRemoteTabs.showMore.tooltip "Show more tabs from this device">
+<!-- LOCALIZATION NOTE (appMenuRemoteTabs.showAll.label, appMenuRemoteTabs.showAll.tooltip):
+     This is shown after the tabs list if we can all the remaining tabs by clicking on the button -->
+<!ENTITY appMenuRemoteTabs.showAll.label "Show All">
+<!ENTITY appMenuRemoteTabs.showAll.tooltip "Show all tabs from this device">
 <!-- LOCALIZATION NOTE (appMenuRemoteTabs.tabsnotsyncing.label): This is shown
      when Sync is configured but syncing tabs is disabled. -->
 <!ENTITY appMenuRemoteTabs.tabsnotsyncing.label "Turn on tab syncing to view a list of tabs from your other devices.">
 <!-- LOCALIZATION NOTE (appMenuRemoteTabs.noclients.label): This is shown
      when Sync is configured but this appears to be the only device attached to
      the account. We also show links to download Firefox for android/ios. -->
 <!ENTITY appMenuRemoteTabs.noclients.title "No synced tabs… yet!">
 <!ENTITY appMenuRemoteTabs.noclients.subtitle "Want to see your tabs from other devices here?">
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -1238,29 +1238,29 @@ menuitem.panel-subview-footer@menuStateA
 
 .subviewbutton > .menu-accel-container {
   -moz-box-pack: start;
   margin-inline-start: 10px;
   margin-inline-end: auto;
   color: GrayText;
 }
 
-#PanelUI-remotetabs-tabslist > toolbarbutton,
+#PanelUI-remotetabs-tabslist > toolbarbutton[itemtype="tab"],
 #PanelUI-historyItems > toolbarbutton {
   list-style-image: url("chrome://mozapps/skin/places/defaultFavicon.png");
 }
 
 @media (min-resolution: 1.1dppx) {
-  #PanelUI-remotetabs-tabslist > toolbarbutton,
+  #PanelUI-remotetabs-tabslist > toolbarbutton[itemtype="tab"],
   #PanelUI-historyItems > toolbarbutton {
     list-style-image: url("chrome://mozapps/skin/places/defaultFavicon@2x.png");
   }
 }
 
-#PanelUI-remotetabs-tabslist > toolbarbutton > .toolbarbutton-icon,
+#PanelUI-remotetabs-tabslist > toolbarbutton[itemtype="tab"] > .toolbarbutton-icon,
 #PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon,
 #PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon,
 #PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon {
   width: 16px;
   height: 16px;
 }
 
 toolbarbutton[panel-multiview-anchor="true"],
--- a/services/sync/modules/SyncedTabs.jsm
+++ b/services/sync/modules/SyncedTabs.jsm
@@ -267,26 +267,23 @@ this.SyncedTabs = {
   // resolves when the sync is complete, but there's no resolved value -
   // callers should be listening for TOPIC_TABS_CHANGED.
   // If |force| is true we always sync. If false, we only sync if the most
   // recent sync wasn't "recently".
   syncTabs(force) {
     return this._internal.syncTabs(force);
   },
 
-  sortTabClientsByLastUsed(clients, maxTabs = Infinity) {
-    // First sort and filter the list of tabs for each client. Note that
+  sortTabClientsByLastUsed(clients) {
+    // First sort the list of tabs for each client. Note that
     // this module promises that the objects it returns are never
     // shared, so we are free to mutate those objects directly.
     for (let client of clients) {
       let tabs = client.tabs;
       tabs.sort((a, b) => b.lastUsed - a.lastUsed);
-      if (Number.isFinite(maxTabs)) {
-        client.tabs = tabs.slice(0, maxTabs);
-      }
     }
     // Now sort the clients - the clients are sorted in the order of the
     // most recent tab for that client (ie, it is important the tabs for
     // each client are already sorted.)
     clients.sort((a, b) => {
       if (a.tabs.length == 0) {
         return 1; // b comes first.
       }