Bug 1359894 - Show Send Tab to all users. r?markh draft
authorEdouard Oger <eoger@fastmail.com>
Mon, 01 May 2017 10:56:19 -0400
changeset 610595 0980eec9be413423b085d3fbb582fd08c878e398
parent 609865 e0b0865639cebc1b5afa0268a4b073fcdde0e69c
child 637912 2839b4c50b6b772a4446c350008341e160f7e6e4
push id68950
push userbmo:eoger@fastmail.com
push dateTue, 18 Jul 2017 14:49:46 +0000
reviewersmarkh
bugs1359894
milestone56.0a1
Bug 1359894 - Show Send Tab to all users. r?markh MozReview-Commit-ID: EzgiQjMQsaJ
browser/app/profile/firefox.js
browser/base/content/browser-sync.js
browser/base/content/browser.css
browser/base/content/browser.js
browser/base/content/browser.xul
browser/base/content/nsContextMenu.js
browser/base/content/test/contextMenu/browser_contextmenu_mozextension.js
browser/base/content/test/general/browser_contextmenu.js
browser/base/content/test/general/browser_contextmenu_input.js
browser/base/content/test/sync/browser_contextmenu_sendpage.js
browser/base/content/test/sync/browser_contextmenu_sendtab.js
browser/base/content/test/sync/head.js
browser/base/content/test/urlbar/browser_page_action_menu.js
browser/locales/en-US/chrome/browser/accounts.properties
browser/locales/en-US/chrome/browser/browser.dtd
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -992,16 +992,18 @@ pref("app.support.e10sAccessibilityUrl",
 
 // base url for web-based feedback pages
 #ifdef MOZ_DEV_EDITION
 pref("app.feedback.baseURL", "https://input.mozilla.org/%LOCALE%/feedback/firefoxdev/%VERSION%/");
 #else
 pref("app.feedback.baseURL", "https://input.mozilla.org/%LOCALE%/feedback/%APP%/%VERSION%/");
 #endif
 
+// base URL for web-based marketing pages
+pref("app.productInfo.baseURL", "https://www.mozilla.org/firefox/features/");
 
 // Name of alternate about: page for certificate errors (when undefined, defaults to about:neterror)
 pref("security.alternate_certificate_error_page", "certerror");
 
 // Whether to start the private browsing mode at application startup
 pref("browser.privatebrowsing.autostart", false);
 
 // Don't try to alter this pref, it'll be reset the next time you use the
--- a/browser/base/content/browser-sync.js
+++ b/browser/base/content/browser-sync.js
@@ -39,16 +39,25 @@ var gSync = {
       "chrome://weave/locale/sync.properties"
     );
   },
 
   get syncReady() {
     return Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject.ready;
   },
 
+  // Returns true if sync is configured but hasn't loaded or is yet to determine
+  // if any remote clients exist.
+  get syncConfiguredAndLoading() {
+    return UIState.get().status == UIState.STATUS_SIGNED_IN &&
+           (!this.syncReady ||
+           // lastSync will be non-zero after the first sync
+           Weave.Service.clientsEngine.lastSync == 0);
+  },
+
   get isSignedIn() {
     return UIState.get().status == UIState.STATUS_SIGNED_IN;
   },
 
   get remoteClients() {
     return Weave.Service.clientsEngine.remoteClients
            .sort((a, b) => a.name.localeCompare(b.name));
   },
@@ -277,16 +286,22 @@ var gSync = {
   async openDevicesManagementPage(entryPoint) {
     let url = await fxAccounts.promiseAccountsManageDevicesURI(entryPoint);
     switchToTabHavingURI(url, true, {
       replaceQueryString: true,
       triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
     });
   },
 
+  openSendToDevicePromo() {
+    let url = Services.prefs.getCharPref("app.productInfo.baseURL");
+    url += "send-tab/?utm_source=" + Services.appinfo.name.toLowerCase();
+    switchToTabHavingURI(url, true, { replaceQueryString: true });
+  },
+
   sendTabToDevice(url, clientId, title) {
     Weave.Service.clientsEngine.sendURIToClientForDisplay(url, clientId, title).catch(e => {
       console.error("Could not send tab to device", e);
     });
   },
 
   populateSendTabToDevicesMenu(devicesPopup, url, title, createDeviceNodeFn) {
     if (!createDeviceNodeFn) {
@@ -299,52 +314,116 @@ var gSync = {
     // remove existing menu items
     for (let i = devicesPopup.childNodes.length - 1; i >= 0; --i) {
       let child = devicesPopup.childNodes[i];
       if (child.classList.contains("sync-menuitem")) {
         child.remove();
       }
     }
 
+    if (gSync.syncConfiguredAndLoading) {
+      // We can only be in this case in the page action menu.
+      return;
+    }
+
     const fragment = document.createDocumentFragment();
-    if (this.syncReady) {
-      const onTargetDeviceCommand = (event) => {
-        let clients = event.target.getAttribute("clientId") ?
-          [event.target.getAttribute("clientId")] :
-          this.remoteClients.map(client => client.id);
+
+    const state = UIState.get();
+    if (state.status == UIState.STATUS_SIGNED_IN && this.remoteClients.length > 0) {
+      this._appendSendTabDeviceList(fragment, createDeviceNodeFn, url, title);
+    } else if (state.status == UIState.STATUS_SIGNED_IN) {
+      this._appendSendTabSingleDevice(fragment, createDeviceNodeFn);
+    } else if (state.status == UIState.STATUS_NOT_VERIFIED ||
+               state.status == UIState.STATUS_LOGIN_FAILED) {
+      this._appendSendTabVerify(fragment, createDeviceNodeFn);
+    } else /* status is STATUS_NOT_CONFIGURED */ {
+      this._appendSendTabUnconfigured(fragment, createDeviceNodeFn);
+    }
+
+    devicesPopup.appendChild(fragment);
+  },
 
-        clients.forEach(clientId => this.sendTabToDevice(url, clientId, title));
-        gPageActionButton.panel.hidePopup();
-      }
+  _appendSendTabDeviceList(fragment, createDeviceNodeFn, url, title) {
+    const onTargetDeviceCommand = (event) => {
+      let clients = event.target.getAttribute("clientId") ?
+        [event.target.getAttribute("clientId")] :
+        this.remoteClients.map(client => client.id);
+
+      clients.forEach(clientId => this.sendTabToDevice(url, clientId, title));
+      gPageActionButton.panel.hidePopup();
+    }
+
+    function addTargetDevice(clientId, name, clientType) {
+      const targetDevice = createDeviceNodeFn(clientId, name, clientType);
+      targetDevice.addEventListener("command", onTargetDeviceCommand, true);
+      targetDevice.classList.add("sync-menuitem", "sendtab-target");
+      targetDevice.setAttribute("clientId", clientId);
+      targetDevice.setAttribute("clientType", clientType);
+      targetDevice.setAttribute("label", name);
+      fragment.appendChild(targetDevice);
+    }
+
+    const clients = this.remoteClients;
+    for (let client of clients) {
+      addTargetDevice(client.id, client.name, client.type);
+    }
 
-      function addTargetDevice(clientId, name, clientType) {
-        const targetDevice = createDeviceNodeFn(clientId, name, clientType);
-        targetDevice.addEventListener("command", onTargetDeviceCommand, true);
-        targetDevice.classList.add("sync-menuitem", "sendtab-target");
-        targetDevice.setAttribute("clientId", clientId);
-        targetDevice.setAttribute("clientType", clientType);
-        targetDevice.setAttribute("label", name);
-        fragment.appendChild(targetDevice);
-      }
+    // "Send to All Devices" menu item
+    if (clients.length > 1) {
+      const separator = createDeviceNodeFn();
+      separator.classList.add("sync-menuitem");
+      fragment.appendChild(separator);
+      const allDevicesLabel = this.fxaStrings.GetStringFromName("sendToAllDevices.menuitem");
+      addTargetDevice("", allDevicesLabel, "");
+    }
+  },
+
+  _appendSendTabSingleDevice(fragment, createDeviceNodeFn) {
+    const noDevices = this.fxaStrings.GetStringFromName("sendTabToDevice.singledevice.status");
+    const learnMore = this.fxaStrings.GetStringFromName("sendTabToDevice.singledevice");
+    this._appendSendTabInfoItems(fragment, createDeviceNodeFn, noDevices, learnMore, () => {
+      this.openSendToDevicePromo();
+      gPageActionButton.panel.hidePopup();
+    });
+  },
 
-      const clients = this.remoteClients;
-      for (let client of clients) {
-        addTargetDevice(client.id, client.name, client.type);
-      }
+  _appendSendTabVerify(fragment, createDeviceNodeFn) {
+    const notVerified = this.fxaStrings.GetStringFromName("sendTabToDevice.verify.status");
+    const verifyAccount = this.fxaStrings.GetStringFromName("sendTabToDevice.verify");
+    this._appendSendTabInfoItems(fragment, createDeviceNodeFn, notVerified, verifyAccount, () => {
+      this.openPrefs("sendtab");
+      gPageActionButton.panel.hidePopup();
+    });
+  },
 
-      // "Send to All Devices" menu item
-      if (clients.length > 1) {
-        const separator = createDeviceNodeFn();
-        separator.classList.add("sync-menuitem");
-        fragment.appendChild(separator);
-        const allDevicesLabel = this.fxaStrings.GetStringFromName("sendToAllDevices.menuitem");
-        addTargetDevice("", allDevicesLabel, "");
-      }
-    }
-    devicesPopup.appendChild(fragment);
+  _appendSendTabUnconfigured(fragment, createDeviceNodeFn) {
+    const notConnected = this.fxaStrings.GetStringFromName("sendTabToDevice.unconfigured.status");
+    const learnMore = this.fxaStrings.GetStringFromName("sendTabToDevice.unconfigured");
+    this._appendSendTabInfoItems(fragment, createDeviceNodeFn, notConnected, learnMore, () => {
+      this.openSendToDevicePromo();
+      gPageActionButton.panel.hidePopup();
+    });
+  },
+
+  _appendSendTabInfoItems(fragment, createDeviceNodeFn, statusLabel, actionLabel, actionCommand) {
+    const status = createDeviceNodeFn(null, statusLabel, null);
+    status.setAttribute("label", statusLabel);
+    status.setAttribute("disabled", true);
+    status.classList.add("sync-menuitem");
+    fragment.appendChild(status);
+
+    const separator = createDeviceNodeFn(null, null, null);
+    separator.classList.add("sync-menuitem");
+    fragment.appendChild(separator);
+
+    const actionItem = createDeviceNodeFn(null, actionLabel, null);
+    actionItem.addEventListener("command", actionCommand, true);
+    actionItem.classList.add("sync-menuitem");
+    actionItem.setAttribute("label", actionLabel);
+    fragment.appendChild(actionItem);
   },
 
   isSendableURI(aURISpec) {
     if (!aURISpec) {
       return false;
     }
     // Disallow sending tabs with more than 65535 characters.
     if (aURISpec.length > 65535) {
@@ -361,47 +440,47 @@ var gSync = {
       // the length, which we've already addressed.
       Cu.reportError(`Failed to build url filter regexp for send tab: ${e}`);
       return true;
     }
   },
 
   // "Send Tab to Device" menu item
   updateTabContextMenu(aPopupMenu, aTargetTab) {
-    const show = this.syncReady &&
-                 this.remoteClients.length > 0 &&
-                 this.isSendableURI(aTargetTab.linkedBrowser.currentURI.spec);
+    const enabled = !this.syncConfiguredAndLoading &&
+                    this.isSendableURI(aTargetTab.linkedBrowser.currentURI.spec);
 
-    ["context_sendTabToDevice", "context_sendTabToDevice_separator"]
-    .forEach(id => document.getElementById(id).hidden = !show);
+    document.getElementById("context_sendTabToDevice").disabled = !enabled;
   },
 
   // "Send Page to Device" and "Send Link to Device" menu items
-  initPageContextMenu(contextMenu) {
-    const remoteClientPresent = this.syncReady && this.remoteClients.length > 0;
+  updateContentContextMenu(contextMenu) {
     // showSendLink and showSendPage are mutually exclusive
-    let showSendLink = remoteClientPresent
-                       && (contextMenu.onSaveableLink || contextMenu.onPlainTextLink);
-    const showSendPage = !showSendLink && remoteClientPresent
+    const showSendLink = contextMenu.onSaveableLink || contextMenu.onPlainTextLink;
+    const showSendPage = !showSendLink
                          && !(contextMenu.isContentSelected ||
                               contextMenu.onImage || contextMenu.onCanvas ||
                               contextMenu.onVideo || contextMenu.onAudio ||
-                              contextMenu.onLink || contextMenu.onTextInput)
-                         && this.isSendableURI(contextMenu.browser.currentURI.spec);
-
-    if (showSendLink) {
-      // This isn't part of the condition above since we don't want to try and
-      // send the page if a link is clicked on or selected but is not sendable.
-      showSendLink = this.isSendableURI(contextMenu.linkURL);
-    }
+                              contextMenu.onLink || contextMenu.onTextInput);
 
     ["context-sendpagetodevice", "context-sep-sendpagetodevice"]
     .forEach(id => contextMenu.showItem(id, showSendPage));
     ["context-sendlinktodevice", "context-sep-sendlinktodevice"]
     .forEach(id => contextMenu.showItem(id, showSendLink));
+
+    if (!showSendLink && !showSendPage) {
+      return;
+    }
+
+    const targetURI = showSendLink ? contextMenu.linkURL :
+                                     contextMenu.browser.currentURI.spec;
+    const enabled = !this.syncConfiguredAndLoading && this.isSendableURI(targetURI);
+    contextMenu.setItemAttr(showSendPage ? "context-sendpagetodevice" :
+                                           "context-sendlinktodevice",
+                                           "disabled", !enabled || null);
   },
 
   // Functions called by observers
   onActivityStart() {
     clearTimeout(this._syncAnimationTimer);
     this._syncStartTime = Date.now();
 
     let broadcaster = document.getElementById("sync-status");
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -1480,15 +1480,13 @@ toolbarpaletteitem[place="palette"][hidd
 
 .dragfeedback-tab {
   -moz-appearance: none;
   opacity: 0.65;
   -moz-window-shadow: none;
 }
 
 /* Page action menu */
-#page-action-sendToDeviceView-body:not([state="notsignedin"]) > #page-action-sendToDevice-fxa-button,
-#page-action-sendToDeviceView-body:not([state="nodevice"]) > #page-action-no-devices-button,
 #page-action-sendToDeviceView-body:not([state="notready"]) > #page-action-sync-not-ready-button {
   display: none;
 }
 
 %include theme-vars.inc.css
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -7982,46 +7982,31 @@ var gPageActionButton = {
       item.classList.add("page-action-sendToDevice-device", "subviewbutton");
       if (clientId) {
         item.classList.add("subviewbutton-iconic");
       }
       item.setAttribute("tooltiptext", name);
       return item;
     });
 
-    if (!gSync.isSignedIn) {
-      // Could be unconfigured or unverified
-      body.setAttribute("state", "notsignedin");
-      return;
-    }
-
+    body.removeAttribute("state");
     // In the first ~10 sec after startup, Sync may not be loaded and the list
     // of devices will be empty.
-    if (!gSync.syncReady) {
+    if (gSync.syncConfiguredAndLoading) {
       body.setAttribute("state", "notready");
       // Force a background Sync
       Services.tm.dispatchToMainThread(() => {
         Weave.Service.sync([]);  // [] = clients engine only
-        if (!window.closed && gSync.syncReady) {
+        // There's no way Sync is still syncing at this point, but we check
+        // anyway to avoid infinite looping.
+        if (!window.closed && !gSync.syncConfiguredAndLoading) {
           this.setupSendToDeviceView();
         }
       });
-      return;
-    }
-    if (!gSync.remoteClients.length) {
-      body.setAttribute("state", "nodevice");
-      return;
-    }
-
-    body.setAttribute("state", "signedin");
-  },
-
-  fxaButtonClicked() {
-    this.panel.hidePopup();
-    gSync.openPrefs();
+    }
   },
 };
 
 /**
  * Fired on the "marionette-remote-control" system notification,
  * indicating if the browser session is under remote control.
  */
 const gRemoteControl = {
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -480,25 +480,16 @@
                            closemenu="none"
                            oncommand="gPageActionButton.showSendToDeviceView(this);"/>
           </vbox>
         </panelview>
         <panelview id="page-action-sendToDeviceView"
                    class="PanelUI-subView"
                    title="&sendToDevice.viewTitle;">
           <vbox id="page-action-sendToDeviceView-body" class="panel-subview-body">
-            <toolbarbutton id="page-action-sendToDevice-fxa-button"
-                           class="subviewbutton subviewbutton-iconic"
-                           label="&syncBrand.fxAccount.label;"
-                           shortcut="&sendToDevice.fxaRequired.label;"
-                           oncommand="gPageActionButton.fxaButtonClicked();"/>
-            <toolbarbutton id="page-action-no-devices-button"
-                           class="subviewbutton"
-                           label="&sendToDevice.noDevices.label;"
-                           disabled="true"/>
             <toolbarbutton id="page-action-sync-not-ready-button"
                            class="subviewbutton"
                            label="&sendToDevice.syncNotReady.label;"
                            disabled="true"/>
           </vbox>
         </panelview>
       </photonpanelmultiview>
     </panel>
--- a/browser/base/content/nsContextMenu.js
+++ b/browser/base/content/nsContextMenu.js
@@ -625,17 +625,17 @@ nsContextMenu.prototype = {
       return;
     }
     let popup = document.getElementById("fill-login-popup");
     let insertBeforeElement = document.getElementById("fill-login-no-logins");
     popup.insertBefore(fragment, insertBeforeElement);
   },
 
   initSyncItems() {
-    gSync.initPageContextMenu(this);
+    gSync.updateContentContextMenu(this);
   },
 
   openPasswordManager() {
     LoginHelper.openPasswordManager(window, gContextMenuContentData.documentURIObject.host);
   },
 
   inspectNode() {
     return DevToolsShim.inspectNode(gBrowser.selectedTab, this.targetSelectors);
--- a/browser/base/content/test/contextMenu/browser_contextmenu_mozextension.js
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_mozextension.js
@@ -39,17 +39,21 @@ add_task(async function test_link() {
        // We need a blank entry here because the containers submenu is
        // dynamically generated with no ids.
        ...(hasContainers ? ["", null] : []),
        "context-openlink",      true,
        "context-openlinkprivate", true,
        "---",                   null,
        "context-savelink",      true,
        "context-copylink",      true,
-       "context-searchselect",  true]);
+       "context-searchselect",  true,
+       "---", null,
+       "context-sendlinktodevice", true, [], null,
+       ]
+    );
 });
 
 add_task(async function test_video() {
   await test_contextmenu("#video",
   ["context-media-play",         null,
    "context-media-mute",         null,
    "context-media-playbackrate", null,
        ["context-media-playbackrate-050x", null,
--- a/browser/base/content/test/general/browser_contextmenu.js
+++ b/browser/base/content/test/general/browser_contextmenu.js
@@ -33,17 +33,19 @@ add_task(async function test_xul_text_li
      ...(hasContainers ? ["", null] : []),
      "context-openlink",      true,
      "context-openlinkprivate", true,
      "---",                   null,
      "context-bookmarklink",  true,
      "context-savelink",      true,
      ...(hasPocket ? ["context-savelinktopocket", true] : []),
      "context-copylink",      true,
-     "context-searchselect",  true
+     "context-searchselect",  true,
+     "---", null,
+     "context-sendlinktodevice", true, [], null,
     ]
   );
 
   // Clean up so won't affect HTML element test cases
   lastElementSelector = null;
   gBrowser.removeCurrentTab();
 });
 
@@ -84,16 +86,18 @@ add_task(async function test_plaintext()
   plainTextItems = ["context-navigation",   null,
                         ["context-back",         false,
                          "context-forward",      false,
                          "context-reload",       true,
                          "context-bookmarkpage", true], null,
                     "---",                  null,
                     "context-savepage",     true,
                     ...(hasPocket ? ["context-pocket", true] : []),
+                    "---", null,
+                    "context-sendpagetodevice", true, [], null,
                     "---",                  null,
                     "context-viewbgimage",  false,
                     "context-selectall",    true,
                     "---",                  null,
                     "context-viewsource",   true,
                     "context-viewinfo",     true
                    ];
   await test_contextmenu("#test-text", plainTextItems, {
@@ -110,17 +114,19 @@ add_task(async function test_link() {
      ...(hasContainers ? ["", null] : []),
      "context-openlink",      true,
      "context-openlinkprivate", true,
      "---",                   null,
      "context-bookmarklink",  true,
      "context-savelink",      true,
      ...(hasPocket ? ["context-savelinktopocket", true] : []),
      "context-copylink",      true,
-     "context-searchselect",  true
+     "context-searchselect",  true,
+     "---", null,
+     "context-sendlinktodevice", true, [], null,
     ]
   );
 });
 
 add_task(async function test_mailto() {
   await test_contextmenu("#test-mailto",
     ["context-copyemail", true,
      "context-searchselect", true
@@ -257,16 +263,18 @@ add_task(async function test_iframe() {
     ["context-navigation", null,
          ["context-back",         false,
           "context-forward",      false,
           "context-reload",       true,
           "context-bookmarkpage", true], null,
      "---",                  null,
      "context-savepage",     true,
      ...(hasPocket ? ["context-pocket", true] : []),
+     "---", null,
+     "context-sendpagetodevice", true, [], null,
      "---",                  null,
      "context-viewbgimage",  false,
      "context-selectall",    true,
      "frame",                null,
          ["context-showonlythisframe", true,
           "context-openframeintab",    true,
           "context-openframe",         true,
           "---",                       null,
@@ -561,16 +569,18 @@ add_task(async function test_pagemenu() 
          ["+Radio1",             {type: "checkbox", icon: "", checked: false, disabled: false},
           "+Radio2",             {type: "checkbox", icon: "", checked: true, disabled: false},
           "+Radio3",             {type: "checkbox", icon: "", checked: false, disabled: false},
           "---",                 null,
           "+Checkbox",           {type: "checkbox", icon: "", checked: false, disabled: false}], null,
      "---",                  null,
      "context-savepage",     true,
      ...(hasPocket ? ["context-pocket", true] : []),
+     "---", null,
+     "context-sendpagetodevice", true, [], null,
      "---",                  null,
      "context-viewbgimage",  false,
      "context-selectall",    true,
      "---",                  null,
      "context-viewsource",   true,
      "context-viewinfo",     true
     ],
     {async postCheckContextMenuFn() {
@@ -593,16 +603,18 @@ add_task(async function test_dom_full_sc
           "context-forward",         false,
           "context-reload",          true,
           "context-bookmarkpage",    true], null,
      "---",                          null,
      "context-leave-dom-fullscreen", true,
      "---",                          null,
      "context-savepage",             true,
      ...(hasPocket ? ["context-pocket", true] : []),
+     "---", null,
+     "context-sendpagetodevice", true, [], null,
      "---",                          null,
      "context-viewbgimage",          false,
      "context-selectall",            true,
      "---",                          null,
      "context-viewsource",           true,
      "context-viewinfo",             true
     ],
     {
@@ -641,16 +653,18 @@ add_task(async function test_pagemenu2()
          ["context-back",         false,
           "context-forward",      false,
           "context-reload",       true,
           "context-bookmarkpage", true], null,
      "---",                  null,
      "context-savepage",     true,
      ...(hasPocket ? ["context-pocket", true] : []),
      "---",                  null,
+     "context-sendpagetodevice", true, [], null,
+     "---",                  null,
      "context-viewbgimage",  false,
      "context-selectall",    true,
      "---",                  null,
      "context-viewsource",   true,
      "context-viewinfo",     true
     ],
     {maybeScreenshotsPresent: true,
      shiftkey: true}
@@ -687,16 +701,18 @@ add_task(async function test_select_text
      "context-openlinkprivate",             true,
      "---",                                 null,
      "context-bookmarklink",                true,
      "context-savelink",                    true,
      "context-copy",                        true,
      "context-selectall",                   true,
      "---",                                 null,
      "context-searchselect",                true,
+     "---",                                 null,
+     "context-sendlinktodevice", true, [],  null,
      "context-viewpartialsource-selection", true
     ],
     {
       offsetX: 6,
       offsetY: 6,
       async preCheckContextMenuFn() {
         await selectText("#test-select-text-link");
       },
@@ -727,17 +743,19 @@ add_task(async function test_imagelink()
      "---",                   null,
      "context-viewimage",            true,
      "context-copyimage-contents",   true,
      "context-copyimage",            true,
      "---",                          null,
      "context-saveimage",            true,
      "context-sendimage",            true,
      "context-setDesktopBackground", true,
-     "context-viewimageinfo",        true
+     "context-viewimageinfo",        true,
+     "---",                          null,
+     "context-sendlinktodevice",     true, [], null,
     ]
   );
 });
 
 add_task(async function test_select_input_text() {
   todo(false, "spell checker tests are failing, bug 1246296");
 
   /*
@@ -820,16 +838,18 @@ add_task(async function test_click_to_pl
           "context-bookmarkpage", true], null,
      "---",                  null,
      "context-ctp-play",     true,
      "context-ctp-hide",     true,
      "---",                  null,
      "context-savepage",     true,
      ...(hasPocket ? ["context-pocket", true] : []),
      "---",                  null,
+     "context-sendpagetodevice", true, [], null,
+     "---",                  null,
      "context-viewbgimage",  false,
      "context-selectall",    true,
      "---",                  null,
      "context-viewsource",   true,
      "context-viewinfo",     true
     ],
     {
       maybeScreenshotsPresent: true,
@@ -865,16 +885,18 @@ add_task(async function test_srcdoc() {
          ["context-back",         false,
           "context-forward",      false,
           "context-reload",       true,
           "context-bookmarkpage", true], null,
      "---",                  null,
      "context-savepage",     true,
      ...(hasPocket ? ["context-pocket", true] : []),
      "---",                  null,
+     "context-sendpagetodevice", true, [], null,
+     "---",                  null,
      "context-viewbgimage",  false,
      "context-selectall",    true,
      "frame",                null,
          ["context-reloadframe",       true,
           "---",                       null,
           "context-saveframe",         true,
           "---",                       null,
           "context-printframe",        true,
@@ -915,51 +937,57 @@ add_task(async function test_svg_link() 
      ...(hasContainers ? ["", null] : []),
      "context-openlink",      true,
      "context-openlinkprivate", true,
      "---",                   null,
      "context-bookmarklink",  true,
      "context-savelink",      true,
      ...(hasPocket ? ["context-savelinktopocket", true] : []),
      "context-copylink",      true,
-     "context-searchselect",  true
+     "context-searchselect",  true,
+     "---",                   null,
+     "context-sendlinktodevice", true, [], null,
     ]
   );
 
   await test_contextmenu("#svg-with-link2 > a",
     ["context-openlinkintab", true,
      ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
      // We need a blank entry here because the containers submenu is
      // dynamically generated with no ids.
      ...(hasContainers ? ["", null] : []),
      "context-openlink",      true,
      "context-openlinkprivate", true,
      "---",                   null,
      "context-bookmarklink",  true,
      "context-savelink",      true,
      ...(hasPocket ? ["context-savelinktopocket", true] : []),
      "context-copylink",      true,
-     "context-searchselect",  true
+     "context-searchselect",  true,
+     "---",                   null,
+     "context-sendlinktodevice", true, [], null,
     ]
   );
 
   await test_contextmenu("#svg-with-link3 > a",
     ["context-openlinkintab", true,
      ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
      // We need a blank entry here because the containers submenu is
      // dynamically generated with no ids.
      ...(hasContainers ? ["", null] : []),
      "context-openlink",      true,
      "context-openlinkprivate", true,
      "---",                   null,
      "context-bookmarklink",  true,
      "context-savelink",      true,
      ...(hasPocket ? ["context-savelinktopocket", true] : []),
      "context-copylink",      true,
-     "context-searchselect",  true
+     "context-searchselect",  true,
+     "---",                   null,
+     "context-sendlinktodevice", true, [], null,
     ]
   );
 });
 
 add_task(async function test_cleanup_html() {
   gBrowser.removeCurrentTab();
 });
 
--- a/browser/base/content/test/general/browser_contextmenu_input.js
+++ b/browser/base/content/test/general/browser_contextmenu_input.js
@@ -193,16 +193,18 @@ add_task(async function test_date_time_c
            ["context-back",         false,
             "context-forward",      false,
             "context-reload",       true,
             "context-bookmarkpage", true], null,
        "---",                  null,
        "context-savepage",     true,
        ...(hasPocket ? ["context-pocket", true] : []),
        "---",                  null,
+       "context-sendpagetodevice", null, [], null,
+       "---",                  null,
        "context-viewbgimage",  false,
        "context-selectall",    null,
        "---",                  null,
        "context-viewsource",   true,
        "context-viewinfo",     true], {
       // XXX Bug 1345081. Currently the Screenshots menu option is shown for
       // various text elements even though it is set to type "page". That bug
       // should remove the need for next line.
--- a/browser/base/content/test/sync/browser_contextmenu_sendpage.js
+++ b/browser/base/content/test/sync/browser_contextmenu_sendpage.js
@@ -1,92 +1,233 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const remoteClientsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ];
 
-const origRemoteClients = mockReturn(gSync, "remoteClients", remoteClientsFixture);
-const origSyncReady = mockReturn(gSync, "syncReady", true);
-const origIsSendableURI = mockReturn(gSync, "isSendableURI", true);
-
 add_task(async function setup() {
+  await promiseSyncReady();
   await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
 });
 
 add_task(async function test_page_contextmenu() {
-  await updateContentContextMenu("#moztext", "context-sendpagetodevice");
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture,
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
   is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
   is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
-  let devices = document.getElementById("context-sendpagetodevice-popup").childNodes;
-  is(devices[0].getAttribute("label"), "Foo", "Foo target is present");
-  is(devices[1].getAttribute("label"), "Bar", "Bar target is present");
-  is(devices[3].getAttribute("label"), "Send to All Devices", "All Devices target is present");
-});
+  checkPopup([
+    { label: "Foo" },
+    { label: "Bar" },
+    "----",
+    { label: "Send to All Devices" }
+  ]);
+  await hideContentContextMenu();
 
-add_task(async function test_page_contextmenu_notsendable() {
-  const isSendableURIMock = mockReturn(gSync, "isSendableURI", false);
-
-  await updateContentContextMenu("#moztext");
-  is(document.getElementById("context-sendpagetodevice").hidden, true, "Send tab to device is hidden");
-  is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
-
-  isSendableURIMock.restore();
+  sandbox.restore();
 });
 
 add_task(async function test_page_contextmenu_sendtab_no_remote_clients() {
-  let remoteClientsMock = mockReturn(gSync, "remoteClients", []);
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [],
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "No Devices Connected", disabled: true },
+    "----",
+    { label: "Learn About Sending Tabs..." }
+  ]);
+  await hideContentContextMenu();
+
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_sendtab_one_remote_client() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [{ id: 1, name: "Foo"}],
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "Foo" }
+  ]);
+  await hideContentContextMenu();
+
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_sendable() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture,
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: false });
+
+  await openContentContextMenu("#moztext");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled");
+  checkPopup();
+  await hideContentContextMenu();
 
-  await updateContentContextMenu("#moztext");
-  is(document.getElementById("context-sendpagetodevice").hidden, true, "Send tab to device is hidden");
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_synced_yet() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, remoteClients: [],
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await openContentContextMenu("#moztext");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled");
+  checkPopup();
+  await hideContentContextMenu();
+
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_sync_not_ready_configured() {
+  const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null,
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await openContentContextMenu("#moztext");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled");
+  checkPopup();
+  await hideContentContextMenu();
+
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_sync_not_ready_other_state() {
+  const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null,
+                                      state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
   is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "Account Not Verified", disabled: true },
+    "----",
+    { label: "Verify Your Account..." }
+  ]);
+  await hideContentContextMenu();
 
-  remoteClientsMock.restore();
+  sandbox.restore();
 });
 
-add_task(async function test_page_contextmenu_sync_not_ready() {
-  const syncReadyMock = mockReturn(gSync, "syncReady", false);
+add_task(async function test_page_contextmenu_unconfigured() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: null,
+                                      state: UIState.STATUS_NOT_CONFIGURED, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "Not Connected to Sync", disabled: true },
+    "----",
+    { label: "Learn About Sending Tabs..." }
+  ]);
+
+  await hideContentContextMenu();
 
-  await updateContentContextMenu("#moztext");
-  is(document.getElementById("context-sendpagetodevice").hidden, true, "Send tab to device is hidden");
+  sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_verified() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: null,
+                                      state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true });
+
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
   is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "Account Not Verified", disabled: true },
+    "----",
+    { label: "Verify Your Account..." }
+  ]);
 
-  syncReadyMock.restore();
+  await hideContentContextMenu();
+
+  sandbox.restore();
 });
 
-// We are not going to bother testing the states of context-sendlinktodevice since they use
-// the exact same code.
-// However, browser_contextmenu.js contains tests that verify the menu item is present.
+add_task(async function test_page_contextmenu_login_failed() {
+  const syncReady = sinon.stub(gSync, "syncReady").get(() => true);
+  const getState = sinon.stub(UIState, "get").returns({ status: UIState.STATUS_LOGIN_FAILED });
+  const isSendableURI = sinon.stub(gSync, "isSendableURI").returns(true);
 
-add_task(async function cleanup() {
-  gBrowser.removeCurrentTab();
-  origSyncReady.restore();
-  origRemoteClients.restore();
-  origIsSendableURI.restore();
+  await openContentContextMenu("#moztext", "context-sendpagetodevice");
+  is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled");
+  checkPopup([
+    { label: "Account Not Verified", disabled: true },
+    "----",
+    { label: "Verify Your Account..." }
+  ]);
+
+  await hideContentContextMenu();
+
+  syncReady.restore();
+  getState.restore();
+  isSendableURI.restore();
 });
 
-async function updateContentContextMenu(selector, openSubmenuId = null) {
-  let contextMenu = document.getElementById("contentAreaContextMenu");
+// We are not going to bother testing the visibility of context-sendlinktodevice
+// since it uses the exact same code.
+// However, browser_contextmenu.js contains tests that verify its presence.
+
+add_task(async function teardown() {
+  gBrowser.removeCurrentTab();
+});
+
+function checkPopup(expectedItems = null) {
+  const popup = document.getElementById("context-sendpagetodevice-popup");
+  if (!expectedItems) {
+    is(popup.state, "closed", "Popup should be hidden.");
+    return;
+  }
+  const menuItems = popup.children;
+  is(menuItems.length, expectedItems.length, "Popup has the expected children count.");
+  for (let i = 0; i < menuItems.length; i++) {
+    const menuItem = menuItems[i];
+    const expectedItem = expectedItems[i];
+    if (expectedItem === "----") {
+      is(menuItem.nodeName, "menuseparator", "Found a separator");
+      continue;
+    }
+    is(menuItem.nodeName, "menuitem", "Found a menu item");
+    // Bug workaround, menuItem.label "…" encoding is different than ours.
+    is(menuItem.label.normalize("NFKC"), expectedItem.label, "Correct menu item label");
+    is(menuItem.disabled, !!expectedItem.disabled, "Correct menu item disabled state");
+  }
+}
+
+async function openContentContextMenu(selector, openSubmenuId = null) {
+  const contextMenu = document.getElementById("contentAreaContextMenu");
   is(contextMenu.state, "closed", "checking if popup is closed");
 
-  let awaitPopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+  const awaitPopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
   await BrowserTestUtils.synthesizeMouse(selector, 0, 0, {
       type: "contextmenu",
       button: 2,
       shiftkey: false,
       centered: true
     },
     gBrowser.selectedBrowser);
   await awaitPopupShown;
 
   if (openSubmenuId) {
-    let menuPopup = document.getElementById(openSubmenuId).menupopup;
-    let menuPopupPromise = BrowserTestUtils.waitForEvent(menuPopup, "popupshown");
+    const menuPopup = document.getElementById(openSubmenuId).menupopup;
+    const menuPopupPromise = BrowserTestUtils.waitForEvent(menuPopup, "popupshown");
     menuPopup.showPopup();
     await menuPopupPromise;
   }
+}
 
-  let awaitPopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
-
+async function hideContentContextMenu() {
+  const contextMenu = document.getElementById("contentAreaContextMenu");
+  const awaitPopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
   contextMenu.hidePopup();
   await awaitPopupHidden;
 }
--- a/browser/base/content/test/sync/browser_contextmenu_sendtab.js
+++ b/browser/base/content/test/sync/browser_contextmenu_sendtab.js
@@ -4,82 +4,83 @@
 "use strict";
 
 const chrome_base = "chrome://mochitests/content/browser/browser/base/content/test/general/";
 Services.scriptloader.loadSubScript(chrome_base + "head.js", this);
 /* import-globals-from ../general/head.js */
 
 const remoteClientsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ];
 
-const origRemoteClients = mockReturn(gSync, "remoteClients", remoteClientsFixture);
-const origSyncReady = mockReturn(gSync, "syncReady", true);
-const origIsSendableURI = mockReturn(gSync, "isSendableURI", true);
 let [testTab] = gBrowser.visibleTabs;
 
 add_task(async function setup() {
+  await promiseSyncReady();
   is(gBrowser.visibleTabs.length, 1, "there is one visible tab");
 });
 
+// We are not testing the devices popup contents, since it is already tested by
+// browser_contextmenu_sendpage.js and the code to populate it is the same.
+
 add_task(async function test_tab_contextmenu() {
-  await updateTabContextMenu(testTab, openSendTabTargetsSubmenu);
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture,
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
+
+  await updateTabContextMenu(testTab);
   is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
   is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
-  let devices = document.getElementById("context_sendTabToDevicePopupMenu").childNodes;
-  is(devices[0].getAttribute("label"), "Foo", "Foo target is present");
-  is(devices[1].getAttribute("label"), "Bar", "Bar target is present");
-  is(devices[3].getAttribute("label"), "Send to All Devices", "All Devices target is present");
+
+  sandbox.restore();
 });
 
-add_task(async function test_tab_contextmenu_only_one_remote_device() {
-  const remoteClientsMock = mockReturn(gSync, "remoteClients", [{ id: 1, name: "Foo"}]);
+add_task(async function test_tab_contextmenu_unconfigured() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture,
+                                      state: UIState.STATUS_NOT_CONFIGURED, isSendableURI: true });
 
-  await updateTabContextMenu(testTab, openSendTabTargetsSubmenu);
+  await updateTabContextMenu(testTab);
   is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
   is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
-  let devices = document.getElementById("context_sendTabToDevicePopupMenu").childNodes;
-  is(devices.length, 1, "There should not be any separator or All Devices item");
-  is(devices[0].getAttribute("label"), "Foo", "Foo target is present");
 
-  remoteClientsMock.restore();
+  sandbox.restore();
 });
 
 add_task(async function test_tab_contextmenu_not_sendable() {
-  const isSendableURIMock = mockReturn(gSync, "isSendableURI", false);
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [{ id: 1, name: "Foo"}],
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: false });
 
-  updateTabContextMenu(testTab);
-  is(document.getElementById("context_sendTabToDevice").hidden, true, "Send tab to device is hidden");
-  is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
+  await updateTabContextMenu(testTab);
+  is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled");
 
-  isSendableURIMock.restore();
+  sandbox.restore();
 });
 
-add_task(async function test_tab_contextmenu_no_remote_clients() {
-  let remoteClientsMock = mockReturn(gSync, "remoteClients", []);
+add_task(async function test_tab_contextmenu_not_synced_yet() {
+  const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, remoteClients: [],
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
 
-  updateTabContextMenu(testTab);
-  is(document.getElementById("context_sendTabToDevice").hidden, true, "Send tab to device is hidden");
-  is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
+  await updateTabContextMenu(testTab);
+  is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled");
 
-  remoteClientsMock.restore();
+  sandbox.restore();
 });
 
-add_task(async function test_tab_contextmenu_sync_not_ready() {
-  const syncReadyMock = mockReturn(gSync, "syncReady", false);
+add_task(async function test_tab_contextmenu_sync_not_ready_configured() {
+  const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null,
+                                      state: UIState.STATUS_SIGNED_IN, isSendableURI: true });
 
-  updateTabContextMenu(testTab);
-  is(document.getElementById("context_sendTabToDevice").hidden, true, "Send tab to device is hidden");
-  is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
+  await updateTabContextMenu(testTab);
+  is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled");
 
-  syncReadyMock.restore();
+  sandbox.restore();
 });
 
-add_task(async function cleanup() {
-  origSyncReady.restore();
-  origRemoteClients.restore();
-  origIsSendableURI.restore();
-});
+add_task(async function test_tab_contextmenu_sync_not_ready_other_state() {
+  const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null,
+                                      state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true });
 
-async function openSendTabTargetsSubmenu() {
-  let menuPopup = document.getElementById("context_sendTabToDevice").menupopup;
-  let menuPopupPromise = BrowserTestUtils.waitForEvent(menuPopup, "popupshown");
-  menuPopup.showPopup();
-  await menuPopupPromise;
-}
+  await updateTabContextMenu(testTab);
+  is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown");
+  is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled");
+
+  sandbox.restore();
+});
--- a/browser/base/content/test/sync/head.js
+++ b/browser/base/content/test/sync/head.js
@@ -1,24 +1,23 @@
-// Mocks a getter or a function
-// This is basically sinon.js (our in-tree version doesn't do getters :/) (see bug 1369855)
-function mockReturn(obj, symbol, fixture) {
-  let getter = Object.getOwnPropertyDescriptor(obj, symbol).get;
-  if (getter) {
-    Object.defineProperty(obj, symbol, {
-      get() { return fixture; }
-    });
-    return {
-      restore() {
-        Object.defineProperty(obj, symbol, {
-          get: getter
-        });
-      }
-    }
-  }
-  let func = obj[symbol];
-  obj[symbol] = () => fixture;
-  return {
-    restore() {
-      obj[symbol] = func;
-    }
-  }
+/* global sinon */
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+
+registerCleanupFunction(function() {
+  delete window.sinon;
+});
+
+function promiseSyncReady() {
+  let service = Cc["@mozilla.org/weave/service;1"]
+                  .getService(Components.interfaces.nsISupports)
+                  .wrappedJSObject;
+  return service.whenLoaded();
 }
+
+function setupSendTabMocks({ syncReady, clientsSynced, remoteClients, state, isSendableURI }) {
+  const sandbox = sinon.sandbox.create();
+  sandbox.stub(gSync, "syncReady").get(() => syncReady);
+  sandbox.stub(Weave.Service.clientsEngine, "lastSync").get(() => clientsSynced ? Date.now() : 0);
+  sandbox.stub(gSync, "remoteClients").get(() => remoteClients);
+  sandbox.stub(UIState, "get").returns({ status: state });
+  sandbox.stub(gSync, "isSendableURI").returns(isSendableURI);
+  return sandbox;
+}
--- a/browser/base/content/test/urlbar/browser_page_action_menu.js
+++ b/browser/base/content/test/urlbar/browser_page_action_menu.js
@@ -1,10 +1,17 @@
 "use strict";
 
+/* global sinon */
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+
+registerCleanupFunction(function() {
+  delete window.sinon;
+});
+
 const mockRemoteClients = [
   { id: "0", name: "foo", type: "mobile" },
   { id: "1", name: "bar", type: "desktop" },
   { id: "2", name: "baz", type: "mobile" },
 ];
 
 add_task(async function bookmark() {
   // Open a unique page.
@@ -98,53 +105,110 @@ add_task(async function emailLink() {
   await hiddenPromise;
 
   Assert.ok(fnCalled);
 });
 
 add_task(async function sendToDevice_nonSendable() {
   // Open a tab that's not sendable.
   await BrowserTestUtils.withNewTab("about:blank", async () => {
+    await promiseSyncReady();
     // Open the panel.  Send to Device should be disabled.
     await promisePageActionPanelOpen();
     let sendToDeviceButton =
       document.getElementById("page-action-send-to-device-button");
     Assert.ok(sendToDeviceButton.disabled);
     let hiddenPromise = promisePageActionPanelHidden();
     gPageActionPanel.hidePopup();
     await hiddenPromise;
   });
 });
 
-add_task(async function sendToDevice_syncNotReady() {
+add_task(async function sendToDevice_syncNotReady_other_states() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
-    let syncReadyMock = mockReturn(gSync, "syncReady", false);
-    let signedInMock = mockReturn(gSync, "isSignedIn", true);
-
-    let remoteClientsMock;
-    let origSync = Weave.Service.sync;
-    Weave.Service.sync = () => {
-      mockReturn(gSync, "syncReady", true);
-      remoteClientsMock = mockReturn(gSync, "remoteClients", mockRemoteClients);
-    };
-
-    let origSetupSendToDeviceView = gPageActionButton.setupSendToDeviceView;
-    gPageActionButton.setupSendToDeviceView = () => {
-      this.numCall++ || (this.numCall = 1);
-      origSetupSendToDeviceView.call(gPageActionButton);
-      testSendTabToDeviceMenu(this.numCall);
-    }
+    await promiseSyncReady();
+    const sandbox = sinon.sandbox.create();
+    sandbox.stub(gSync, "syncReady").get(() => false);
+    sandbox.stub(Weave.Service.clientsEngine, "lastSync").get(() => 0);
+    sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_VERIFIED });
+    sandbox.stub(gSync, "isSendableURI").returns(true);
 
     let cleanUp = () => {
-      Weave.Service.sync = origSync;
-      gPageActionButton.setupSendToDeviceView = origSetupSendToDeviceView;
-      signedInMock.restore();
-      syncReadyMock.restore();
-      remoteClientsMock.restore();
+      sandbox.restore();
+    };
+    registerCleanupFunction(cleanUp);
+
+    // Open the panel.
+    await promisePageActionPanelOpen();
+    let sendToDeviceButton =
+      document.getElementById("page-action-send-to-device-button");
+    Assert.ok(!sendToDeviceButton.disabled);
+
+    // Click Send to Device.
+    let viewPromise = promisePageActionViewShown();
+    EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
+    let view = await viewPromise;
+    Assert.equal(view.id, "page-action-sendToDeviceView");
+
+    let expectedItems = [
+      {
+        id: "page-action-sync-not-ready-button",
+        display: "none",
+        disabled: true,
+      },
+      {
+        attrs: {
+          label: "Account Not Verified",
+        },
+        disabled: true
+      },
+      null,
+      {
+        attrs: {
+          label: "Verify Your Account...",
+        },
+      }
+    ];
+    checkSendToDeviceItems(expectedItems);
+
+    // Done, hide the panel.
+    let hiddenPromise = promisePageActionPanelHidden();
+    gPageActionPanel.hidePopup();
+    await hiddenPromise;
+
+    cleanUp();
+  });
+});
+
+add_task(async function sendToDevice_syncNotReady_configured() {
+  // Open a tab that's sendable.
+  await BrowserTestUtils.withNewTab("http://example.com/", async () => {
+    await promiseSyncReady();
+    const sandbox = sinon.sandbox.create();
+    const syncReady = sandbox.stub(gSync, "syncReady").get(() => false);
+    const lastSync = sandbox.stub(Weave.Service.clientsEngine, "lastSync").get(() => 0);
+    sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
+    sandbox.stub(gSync, "isSendableURI").returns(true);
+
+    sandbox.stub(Weave.Service, "sync").callsFake(() => {
+      syncReady.get(() => true);
+      lastSync.get(() => Date.now());
+      sandbox.stub(gSync, "remoteClients").get(() => mockRemoteClients);
+    });
+
+    const setupSendToDeviceView = gPageActionButton.setupSendToDeviceView;
+    sandbox.stub(gPageActionButton, "setupSendToDeviceView").callsFake(() => {
+      this.numCall++ || (this.numCall = 1);
+      setupSendToDeviceView.call(gPageActionButton);
+      testSendTabToDeviceMenu(this.numCall);
+    });
+
+    let cleanUp = () => {
+      sandbox.restore();
     };
     registerCleanupFunction(cleanUp);
 
     // Open the panel.
     await promisePageActionPanelOpen();
     let sendToDeviceButton =
       document.getElementById("page-action-send-to-device-button");
     Assert.ok(!sendToDeviceButton.disabled);
@@ -152,45 +216,27 @@ add_task(async function sendToDevice_syn
     // Click Send to Device.
     let viewPromise = promisePageActionViewShown();
     EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
     let view = await viewPromise;
     Assert.equal(view.id, "page-action-sendToDeviceView");
 
     function testSendTabToDeviceMenu(numCall) {
       if (numCall == 1) {
-        // The Fxa button should be shown.
+        // "Syncing devices" should be shown.
         checkSendToDeviceItems([
           {
-            id: "page-action-sendToDevice-fxa-button",
-            display: "none",
-          },
-          {
-            id: "page-action-no-devices-button",
-            display: "none",
-            disabled: true,
-          },
-          {
             id: "page-action-sync-not-ready-button",
             disabled: true,
           },
         ]);
       } else if (numCall == 2) {
         // The devices should be shown in the subview.
         let expectedItems = [
           {
-            id: "page-action-sendToDevice-fxa-button",
-            display: "none",
-          },
-          {
-            id: "page-action-no-devices-button",
-            display: "none",
-            disabled: true,
-          },
-          {
             id: "page-action-sync-not-ready-button",
             display: "none",
             disabled: true,
           },
         ];
         for (let client of mockRemoteClients) {
           expectedItems.push({
             attrs: {
@@ -198,17 +244,19 @@ add_task(async function sendToDevice_syn
               label: client.name,
               clientType: client.type,
             },
           });
         }
         expectedItems.push(
           null,
           {
-            label: "Send to All Devices",
+            attrs: {
+              label: "Send to All Devices"
+            }
           }
         );
         checkSendToDeviceItems(expectedItems);
       } else {
         ok(false, "This should never happen");
       }
     }
 
@@ -232,108 +280,117 @@ add_task(async function sendToDevice_not
     Assert.ok(!sendToDeviceButton.disabled);
 
     // Click Send to Device.
     let viewPromise = promisePageActionViewShown();
     EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
     let view = await viewPromise;
     Assert.equal(view.id, "page-action-sendToDeviceView");
 
-    // The Fxa button should be shown.
-    checkSendToDeviceItems([
-      {
-        id: "page-action-sendToDevice-fxa-button",
-      },
-      {
-        id: "page-action-no-devices-button",
-        display: "none",
-        disabled: true,
-      },
+    let expectedItems = [
       {
         id: "page-action-sync-not-ready-button",
         display: "none",
         disabled: true,
       },
-    ]);
+      {
+        attrs: {
+          label: "Not Connected to Sync",
+        },
+        disabled: true
+      },
+      null,
+      {
+        attrs: {
+          label: "Learn About Sending Tabs..."
+        },
+      }
+    ];
+    checkSendToDeviceItems(expectedItems);
 
-    // Click the Fxa button.
-    let body = view.firstChild;
-    let fxaButton = body.childNodes[0];
-    Assert.equal(fxaButton.id, "page-action-sendToDevice-fxa-button");
-    let prefsTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+    // Done, hide the panel.
     let hiddenPromise = promisePageActionPanelHidden();
-    EventUtils.synthesizeMouseAtCenter(fxaButton, {});
-    let values = await Promise.all([prefsTabPromise, hiddenPromise]);
-    let tab = values[0];
-
-    // The Fxa prefs pane should open.  The full URL is something like:
-    //   about:preferences?entrypoint=syncbutton#sync
-    // Just make sure it's about:preferences#sync.
-    let urlObj = new URL(gBrowser.selectedBrowser.currentURI.spec);
-    let url = urlObj.protocol + urlObj.pathname + urlObj.hash;
-    Assert.equal(url, "about:preferences#sync");
-
-    await BrowserTestUtils.removeTab(tab);
+    gPageActionPanel.hidePopup();
+    await hiddenPromise;
   });
 });
 
 add_task(async function sendToDevice_noDevices() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
-    UIState._internal._state = { status: UIState.STATUS_SIGNED_IN };
+    const sandbox = sinon.sandbox.create();
+    sandbox.stub(gSync, "syncReady").get(() => true);
+    sandbox.stub(Weave.Service.clientsEngine, "lastSync").get(() => Date.now());
+    sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
+    sandbox.stub(gSync, "isSendableURI").returns(true);
+    sandbox.stub(gSync, "remoteClients").get(() => []);
+
+    let cleanUp = () => {
+      sandbox.restore();
+    };
+    registerCleanupFunction(cleanUp);
 
     // Open the panel.
     await promisePageActionPanelOpen();
     let sendToDeviceButton =
       document.getElementById("page-action-send-to-device-button");
     Assert.ok(!sendToDeviceButton.disabled);
 
     // Click Send to Device.
     let viewPromise = promisePageActionViewShown();
     EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
     let view = await viewPromise;
     Assert.equal(view.id, "page-action-sendToDeviceView");
 
-    // The no-devices item should be shown.
-    checkSendToDeviceItems([
-      {
-        id: "page-action-sendToDevice-fxa-button",
-        display: "none",
-      },
-      {
-        id: "page-action-no-devices-button",
-        disabled: true,
-      },
+    let expectedItems = [
       {
         id: "page-action-sync-not-ready-button",
         display: "none",
         disabled: true,
       },
-    ]);
+      {
+        attrs: {
+          label: "No Devices Connected",
+        },
+        disabled: true
+      },
+      null,
+      {
+        attrs: {
+          label: "Learn About Sending Tabs..."
+        }
+      }
+    ];
+    checkSendToDeviceItems(expectedItems);
 
     // Done, hide the panel.
     let hiddenPromise = promisePageActionPanelHidden();
     gPageActionPanel.hidePopup();
     await hiddenPromise;
 
+    cleanUp();
+
     await UIState.reset();
   });
 });
 
 add_task(async function sendToDevice_devices() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
-    UIState._internal._state = { status: UIState.STATUS_SIGNED_IN };
+    const sandbox = sinon.sandbox.create();
+    sandbox.stub(gSync, "syncReady").get(() => true);
+    sandbox.stub(Weave.Service.clientsEngine, "lastSync").get(() => Date.now());
+    sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
+    sandbox.stub(gSync, "isSendableURI").returns(true);
+    sandbox.stub(gSync, "remoteClients").get(() => mockRemoteClients);
 
-    // Set up mock remote clients.
-    let remoteClientsMock = mockReturn(gSync, "remoteClients", mockRemoteClients);
     let cleanUp = () => {
-      remoteClientsMock.restore();
+      sandbox.restore();
     };
     registerCleanupFunction(cleanUp);
 
     // Open the panel.
     await promisePageActionPanelOpen();
     let sendToDeviceButton =
       document.getElementById("page-action-send-to-device-button");
     Assert.ok(!sendToDeviceButton.disabled);
@@ -342,25 +399,16 @@ add_task(async function sendToDevice_dev
     let viewPromise = promisePageActionViewShown();
     EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
     let view = await viewPromise;
     Assert.equal(view.id, "page-action-sendToDeviceView");
 
     // The devices should be shown in the subview.
     let expectedItems = [
       {
-        id: "page-action-sendToDevice-fxa-button",
-        display: "none",
-      },
-      {
-        id: "page-action-no-devices-button",
-        display: "none",
-        disabled: true,
-      },
-      {
         id: "page-action-sync-not-ready-button",
         display: "none",
         disabled: true,
       },
     ];
     for (let client of mockRemoteClients) {
       expectedItems.push({
         attrs: {
@@ -368,28 +416,29 @@ add_task(async function sendToDevice_dev
           label: client.name,
           clientType: client.type,
         },
       });
     }
     expectedItems.push(
       null,
       {
-        label: "Send to All Devices",
+        attrs: {
+          label: "Send to All Devices"
+        }
       }
     );
     checkSendToDeviceItems(expectedItems);
 
     // Done, hide the panel.
     let hiddenPromise = promisePageActionPanelHidden();
     gPageActionPanel.hidePopup();
     await hiddenPromise;
 
     cleanUp();
-    await UIState.reset();
   });
 });
 
 function promiseSyncReady() {
   let service = Cc["@mozilla.org/weave/service;1"]
                   .getService(Components.interfaces.nsISupports)
                   .wrappedJSObject;
   return service.whenLoaded().then(() => {
@@ -413,37 +462,17 @@ function checkSendToDeviceItems(expected
     }
     let display = "display" in expected ? expected.display : "-moz-box";
     Assert.equal(getComputedStyle(actual).display, display);
     let disabled = "disabled" in expected ? expected.disabled : false;
     Assert.equal(actual.disabled, disabled);
     if ("attrs" in expected) {
       for (let name in expected.attrs) {
         Assert.ok(actual.hasAttribute(name));
-        Assert.equal(actual.getAttribute(name), expected.attrs[name]);
+        let attrVal = actual.getAttribute(name)
+        if (name == "label") {
+          attrVal = attrVal.normalize("NFKC"); // There's a bug with …
+        }
+        Assert.equal(attrVal, expected.attrs[name]);
       }
     }
   }
 }
-
-// Copied from test/sync/head.js (see bug 1369855)
-function mockReturn(obj, symbol, fixture) {
-  let getter = Object.getOwnPropertyDescriptor(obj, symbol).get;
-  if (getter) {
-    Object.defineProperty(obj, symbol, {
-      get() { return fixture; }
-    });
-    return {
-      restore() {
-        Object.defineProperty(obj, symbol, {
-          get: getter
-        });
-      }
-    }
-  }
-  let func = obj[symbol];
-  obj[symbol] = () => fixture;
-  return {
-    restore() {
-      obj[symbol] = func;
-    }
-  }
-}
--- a/browser/locales/en-US/chrome/browser/accounts.properties
+++ b/browser/locales/en-US/chrome/browser/accounts.properties
@@ -34,16 +34,34 @@ syncStartNotification.body2 = %S will be
 # These strings are used in a notification shown after Sync was disconnected remotely.
 deviceDisconnectedNotification.title = Sync Disconnected
 deviceDisconnectedNotification.body = This computer has been successfully disconnected from Firefox Sync.
 
 # LOCALIZATION NOTE (sendToAllDevices.menuitem)
 # Displayed in the Send Tab/Page/Link to Device context menu when right clicking a tab, a page or a link.
 sendToAllDevices.menuitem = Send to All Devices
 
+# LOCALIZATION NOTE (sendTabToDevice.unconfigured, sendTabToDevice.unconfigured.status)
+# Displayed in the Send Tabs context menu when right clicking a tab, a page or a link
+# and the Sync account is unconfigured. Redirects to a marketing page.
+sendTabToDevice.unconfigured.status = Not Connected to Sync
+sendTabToDevice.unconfigured = Learn About Sending Tabs…
+
+# LOCALIZATION NOTE (sendTabToDevice.singledevice, sendTabToDevice.singledevice.status)
+# Displayed in the Send Tabs context menu when right clicking a tab, a page or a link
+# and the Sync account has only 1 device. Redirects to a marketing page.
+sendTabToDevice.singledevice.status = No Devices Connected
+sendTabToDevice.singledevice = Learn About Sending Tabs…
+
+# LOCALIZATION NOTE (sendTabToDevice.verify, sendTabToDevice.verify.status)
+# Displayed in the Send Tabs context menu when right clicking a tab, a page or a link
+# and the Sync account is unverified. Redirects to the Sync preferences page.
+sendTabToDevice.verify.status = Account Not Verified
+sendTabToDevice.verify = Verify Your Account…
+
 # LOCALIZATION NOTE (tabArrivingNotification.title, tabArrivingNotificationWithDevice.title,
 # multipleTabsArrivingNotification.title, unnamedTabsArrivingNotification2.body,
 # unnamedTabsArrivingNotificationMultiple2.body, unnamedTabsArrivingNotificationNoDevice.body)
 # These strings are used in a notification shown when we're opening tab(s) another device sent us to display.
 
 # LOCALIZATION NOTE (tabArrivingNotification.title, tabArrivingNotificationWithDevice.title)
 # The body for these is the URL of the tab recieved
 tabArrivingNotification.title = Tab Received
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -983,11 +983,9 @@ you can use these alternative items. Oth
 <!ENTITY updateRestart.cancelButton.label "Not Now">
 <!ENTITY updateRestart.cancelButton.accesskey "N">
 <!ENTITY updateRestart.panelUI.label2 "Restart to update &brandShorterName;">
 
 <!ENTITY pageActionButton.tooltip "Page actions">
 
 <!ENTITY sendToDevice.label2 "Send to Device">
 <!ENTITY sendToDevice.viewTitle "Send to Device">
-<!ENTITY sendToDevice.fxaRequired.label "Required">
-<!ENTITY sendToDevice.noDevices.label "No Devices Available">
 <!ENTITY sendToDevice.syncNotReady.label "Syncing Devices…">