Bug 1132301 - Part 2: Add navigator.mozLoop methods to allow interaction between Loop and the Social API. r=Standard8, r=mixedpuppy, a=lizzard
☠☠ backed out by 9b2e30c662d6 ☠ ☠
authorMike de Boer <mdeboer@mozilla.com>
Fri, 10 Apr 2015 13:23:05 +0200
changeset 267069 f1064a1b4a2ab8f8e1e9cce0c1c9badf094740a9
parent 267068 8eafef4aa1c337976a92883496af909e5dc09cbb
child 267070 c3999b13c644ba1511350539a1ec45a539d5903a
push id830
push userraliiev@mozilla.com
push dateFri, 19 Jun 2015 19:24:37 +0000
treeherdermozilla-release@932614382a68 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, mixedpuppy, lizzard
bugs1132301
milestone39.0a2
Bug 1132301 - Part 2: Add navigator.mozLoop methods to allow interaction between Loop and the Social API. r=Standard8, r=mixedpuppy, a=lizzard
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/test/mochitest/browser.ini
browser/components/loop/test/mochitest/browser_mozLoop_sharingListeners.js
browser/components/loop/test/mochitest/browser_mozLoop_socialShare.js
browser/components/loop/test/mochitest/head.js
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -24,16 +24,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "PageMetadata",
                                         "resource://gre/modules/PageMetadata.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                         "resource://gre/modules/PluralForm.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
                                         "resource://gre/modules/UpdateChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "UITour",
                                         "resource:///modules/UITour.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Social",
+                                        "resource:///modules/Social.jsm");
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"]
            .getService(Ci.nsIXULAppInfo)
            .QueryInterface(Ci.nsIXULRuntime);
 });
 XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
                                          "@mozilla.org/widget/clipboardhelper;1",
                                          "nsIClipboardHelper");
@@ -197,16 +199,20 @@ const injectObjectAPI = function(api, ta
 function injectLoopAPI(targetWindow) {
   let ringer;
   let ringerStopper;
   let appVersionInfo;
   let contactsAPI;
   let roomsAPI;
   let callsAPI;
   let savedWindowListeners = new Map();
+  let socialProviders;
+  const kShareWidgetId = "social-share-button";
+  let socialShareButtonListenersAdded = false;
+
 
   let api = {
     /**
      * Gets an object with data that represents the currently
      * authenticated user's identity.
      *
      * @return null if user not logged in; profile object otherwise
      */
@@ -908,30 +914,222 @@ function injectLoopAPI(targetWindow) {
      * @param {Boolean} active  Whether or not screen sharing is now active.
      */
     setScreenShareState: {
       enumerable: true,
       writable: true,
       value: function(windowId, active) {
         MozLoopService.setScreenShareState(windowId, active);
       }
+    },
+
+    /**
+     * Checks if the Social Share widget is available in any of the registered
+     * widget areas (navbar, MenuPanel, etc).
+     *
+     * @return {Boolean} `true` if the widget is available and `false` when it's
+     *                   still in the Customization palette.
+     */
+    isSocialShareButtonAvailable: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (!win || !win.CustomizableUI) {
+          return false;
+        }
+
+        let widget = win.CustomizableUI.getWidget(kShareWidgetId);
+        if (widget) {
+          if (!socialShareButtonListenersAdded) {
+            let eventName = "social:" + kShareWidgetId;
+            Services.obs.addObserver(onShareWidgetChanged, eventName + "-added", false);
+            Services.obs.addObserver(onShareWidgetChanged, eventName + "-removed", false);
+            socialShareButtonListenersAdded = true;
+          }
+          return !!widget.areaType;
+        }
+
+        return false;
+      }
+    },
+
+    /**
+     * Add the Social Share widget to the navbar area, but only when it's not
+     * located anywhere else than the Customization palette.
+     */
+    addSocialShareButton: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        // Don't do anything if the button is already available.
+        if (api.isSocialShareButtonAvailable.value()) {
+          return;
+        }
+
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (!win || !win.CustomizableUI) {
+          return;
+        }
+        win.CustomizableUI.addWidgetToArea(kShareWidgetId, win.CustomizableUI.AREA_NAVBAR);
+      }
+    },
+
+    /**
+     * Activates the Social Share panel with the Social Provider panel opened
+     * when the popup open.
+     */
+    addSocialShareProvider: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        // Don't do anything if the button is _not_ available.
+        if (!api.isSocialShareButtonAvailable.value()) {
+          return;
+        }
+
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (!win || !win.SocialShare) {
+          return;
+        }
+        win.SocialShare.showDirectory();
+      }
+    },
+
+    /**
+     * Returns a sorted list of Social Providers that can share URLs. See
+     * `updateSocialProvidersCache()` for more information.
+     * 
+     * @return {Array} Sorted list of share-capable Social Providers.
+     */
+    getSocialShareProviders: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        if (socialProviders) {
+          return socialProviders;
+        }
+        return updateSocialProvidersCache();
+      }
+    },
+
+    /**
+     * Share a room URL through a Social Provider with the provided title message.
+     * This action will open the share panel, which is anchored to the Social
+     * Share widget.
+     *
+     * @param {String} providerOrigin Identifier of the targeted Social Provider
+     * @param {String} roomURL        URL that points to the standalone client
+     * @param {String} title          Message that augments the URL inside the
+     *                                share message
+     * @param {String} [body]         Optional longer message to be displayed
+     *                                similar to the body of an email 
+     */
+    socialShareRoom: {
+      enumerable: true,
+      writable: true,
+      value: function(providerOrigin, roomURL, title, body = null) {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (!win || !win.SocialShare) {
+          return;
+        }
+
+        let graphData = {
+          url: roomURL,
+          title: title
+        };
+        if (body) {
+          graphData.body = body;
+        }
+        win.SocialShare.sharePage(providerOrigin, graphData);
+      }
     }
   };
 
+  /**
+   * Send an event to the content window to indicate that the state on the chrome
+   * side was updated.
+   *
+   * @param  {name} name Name of the event, defaults to 'LoopStatusChanged'
+   */
+  function sendEvent(name = "LoopStatusChanged") {
+    if (typeof targetWindow.CustomEvent != "function") {
+      MozLoopService.log.debug("Could not send event to content document, " +
+        "because it's being destroyed or we're in a unit test where " +
+        "`targetWindow` is mocked.");
+      return;
+    }
+
+    let event = new targetWindow.CustomEvent(name);
+    targetWindow.dispatchEvent(event);
+  }
+
   function onStatusChanged(aSubject, aTopic, aData) {
-    let event = new targetWindow.CustomEvent("LoopStatusChanged");
-    targetWindow.dispatchEvent(event);
-  };
+    sendEvent();
+  }
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
     if (targetWindow && aSubject != targetWindow)
       return;
     Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed");
     Services.obs.removeObserver(onStatusChanged, "loop-status-changed");
-  };
+    // Stop listening for changes in the social provider list, if necessary.
+    if (socialProviders)
+      Services.obs.removeObserver(updateSocialProvidersCache, "social:providers-changed");
+    if (socialShareButtonListenersAdded) {
+      let eventName = "social:" + kShareWidgetId;
+      Services.obs.removeObserver(onShareWidgetChanged, eventName + "-added");
+      Services.obs.removeObserver(onShareWidgetChanged, eventName + "-removed");
+    }
+  }
+
+  function onShareWidgetChanged(aSubject, aTopic, aData) {
+    sendEvent("LoopShareWidgetChanged");
+  }
+
+  /**
+   * Retrieves a list of Social Providers from the Social API that are explicitly
+   * capable of sharing URLs.
+   * It also adds a listener that is fired whenever a new Provider is added or
+   * removed.
+   *
+   * @return {Array} Sorted list of share-capable Social Providers.
+   */
+  function updateSocialProvidersCache() {
+    let providers = [];
+
+    for (let provider of Social.providers) {
+      if (!provider.shareURL) {
+        continue;
+      }
+
+      // Only pass the relevant data on to content.
+      providers.push({
+        iconURL: provider.iconURL,
+        name: provider.name,
+        origin: provider.origin
+      });
+    }
+
+    let providersWasSet = !!socialProviders;
+    // Replace old with new.
+    socialProviders = cloneValueInto(providers.sort((a, b) =>
+      a.name.toLowerCase().localeCompare(b.name.toLowerCase())), targetWindow);
+
+    // Start listening for changes in the social provider list, if we're not
+    // doing that yet.
+    if (!providersWasSet) {
+      Services.obs.addObserver(updateSocialProvidersCache, "social:providers-changed", false);
+    } else {
+      // Dispatch an event to content to let stores freshen-up.
+      sendEvent("LoopSocialProvidersChanged");
+    }
+
+    return socialProviders;
+  }
 
   let contentObj = Cu.createObjectIn(targetWindow);
   Object.defineProperties(contentObj, api);
   Object.seal(contentObj);
   Cu.makeObjectPropsNormal(contentObj);
   Services.obs.addObserver(onStatusChanged, "loop-status-changed", false);
   Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
 
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -15,13 +15,14 @@ support-files =
 skip-if = e10s
 [browser_loop_fxa_server.js]
 [browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 skip-if = buildapp == 'mulet'
 [browser_mozLoop_pluralStrings.js]
+[browser_mozLoop_socialShare.js]
 [browser_mozLoop_sharingListeners.js]
 skip-if = e10s
 [browser_mozLoop_telemetry.js]
 skip-if = e10s
 [browser_toolbarbutton.js]
--- a/browser/components/loop/test/mochitest/browser_mozLoop_sharingListeners.js
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_sharingListeners.js
@@ -49,19 +49,29 @@ function promiseWindowIdReceivedNewTab(h
   let createdTab = gBrowser.selectedTab = gBrowser.addTab();
   createdTabs.push(createdTab);
 
   promiseHandlers.push(promiseTabLoadEvent(createdTab, "about:mozilla"));
 
   return Promise.all(promiseHandlers);
 };
 
-function removeTabs() {
+function promiseRemoveTab(tab) {
+  return new Promise(resolve => {
+    gBrowser.tabContainer.addEventListener("TabClose", function onTabClose() {
+      gBrowser.tabContainer.removeEventListener("TabClose", onTabClose);
+      resolve();
+    });
+    gBrowser.removeTab(tab);
+  });
+}
+
+function* removeTabs() {
   for (let createdTab of createdTabs) {
-    gBrowser.removeTab(createdTab);
+    yield promiseRemoveTab(createdTab);
   }
 
   createdTabs = [];
 }
 
 add_task(function* test_singleListener() {
   yield promiseWindowIdReceivedOnAdd(handlers[0]);
 
@@ -74,17 +84,17 @@ add_task(function* test_singleListener()
 
   let newWindowId = handlers[0].windowId;
 
   Assert.notEqual(initialWindowId, newWindowId, "Tab contentWindow IDs shouldn't be the same");
 
   // Now remove the listener.
   gMozLoopAPI.removeBrowserSharingListener(handlers[0].listener);
 
-  removeTabs();
+  yield removeTabs();
 });
 
 add_task(function* test_multipleListener() {
   yield promiseWindowIdReceivedOnAdd(handlers[0]);
 
   let initialWindowId0 = handlers[0].windowId;
 
   Assert.notEqual(initialWindowId0, null, "window id should be valid");
@@ -117,17 +127,17 @@ add_task(function* test_multipleListener
 
   Assert.ok(nextWindowId0, "windowId should not be null anymore");
   Assert.equal(newWindowId0, nextWindowId0, "First listener shouldn't have updated");
   Assert.notEqual(newWindowId1, nextWindowId1, "Second listener should have updated");
 
   // Cleanup.
   gMozLoopAPI.removeBrowserSharingListener(handlers[1].listener);
 
-  removeTabs();
+  yield removeTabs();
 });
 
 add_task(function* test_infoBar() {
   const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
   const kBrowserSharingNotificationId = "loop-sharing-notification";
   const kPrefBrowserSharingInfoBar = "loop.browserSharing.showInfoBar";
 
   Services.prefs.setBoolPref(kPrefBrowserSharingInfoBar, true);
@@ -181,11 +191,11 @@ add_task(function* test_infoBar() {
     "The pref should be set to false when the menu item is clicked");
 
   gBrowser.selectedIndex = Array.indexOf(gBrowser.tabs, createdTabs[1]);
 
   Assert.equal(getInfoBar(), null, "The notification should still be hidden");
 
   // Cleanup.
   gMozLoopAPI.removeBrowserSharingListener(handlers[0].listener);
-  removeTabs();
+  yield removeTabs();
   Services.prefs.clearUserPref(kPrefBrowserSharingInfoBar);
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_socialShare.js
@@ -0,0 +1,145 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This is an integration test from navigator.mozLoop through to the end
+ * effects - rather than just testing MozLoopAPI alone.
+ */
+
+Cu.import("resource://gre/modules/Promise.jsm");
+const {SocialService} = Cu.import("resource://gre/modules/SocialService.jsm", {});
+
+add_task(loadLoopPanel);
+
+const kShareWidgetId = "social-share-button";
+const kShareProvider = {
+  name: "provider 1",
+  origin: "https://example.com",
+  iconURL: "https://example.com/browser/browser/base/content/test/general/moz.png",
+  shareURL: "https://example.com/browser/browser/base/content/test/social/social_sidebar_empty.html"
+};
+const kShareProviderInvalid = {
+  name: "provider 1",
+  origin: "https://example2.com"
+};
+
+registerCleanupFunction(function* () {
+  yield new Promise(resolve => SocialService.disableProvider(kShareProvider.origin, resolve));
+  yield new Promise(resolve => SocialService.disableProvider(kShareProviderInvalid.origin, resolve));
+  Assert.strictEqual(Social.providers.length, 0, "all providers should be removed");
+  SocialShare.uninit();
+});
+
+add_task(function* test_mozLoop_isSocialShareButtonAvailable() {
+  Assert.ok(gMozLoopAPI, "mozLoop should exist");
+
+  // First make sure the Social Share button is not available. This is probably
+  // already the case, but make it explicit here.
+  CustomizableUI.removeWidgetFromArea(kShareWidgetId);
+
+  Assert.ok(!gMozLoopAPI.isSocialShareButtonAvailable(),
+    "Social Share button should not be available");
+
+  // Add the widget to the navbar.
+  CustomizableUI.addWidgetToArea(kShareWidgetId, CustomizableUI.AREA_NAVBAR);
+
+  Assert.ok(gMozLoopAPI.isSocialShareButtonAvailable(),
+    "Social Share button should be available");
+
+  // Add the widget to the MenuPanel.
+  CustomizableUI.addWidgetToArea(kShareWidgetId, CustomizableUI.AREA_PANEL);
+
+  Assert.ok(gMozLoopAPI.isSocialShareButtonAvailable(),
+    "Social Share button should still be available");
+
+  // Test button removal during the same session.
+  CustomizableUI.removeWidgetFromArea(kShareWidgetId);
+
+  Assert.ok(!gMozLoopAPI.isSocialShareButtonAvailable(),
+    "Social Share button should not be available");
+});
+
+add_task(function* test_mozLoop_addSocialShareButton() {
+  gMozLoopAPI.addSocialShareButton();
+
+  Assert.ok(gMozLoopAPI.isSocialShareButtonAvailable(),
+    "Social Share button should be available");
+
+  let widget = CustomizableUI.getWidget(kShareWidgetId);
+  Assert.strictEqual(widget.areaType, CustomizableUI.TYPE_TOOLBAR,
+    "Social Share button should be placed in the navbar");
+
+  CustomizableUI.removeWidgetFromArea(kShareWidgetId);
+});
+
+add_task(function* test_mozLoop_addSocialShareProvider() {
+  gMozLoopAPI.addSocialShareButton();
+
+  gMozLoopAPI.addSocialShareProvider();
+
+  yield promiseWaitForCondition(() => SocialShare.panel.state == "open");
+
+  Assert.equal(SocialShare.iframe.getAttribute("src"), "about:providerdirectory",
+    "Provider directory page should be visible");
+
+  SocialShare.panel.hidePopup();
+  CustomizableUI.removeWidgetFromArea(kShareWidgetId);
+});
+
+add_task(function* test_mozLoop_getSocialShareProviders() {
+  Assert.strictEqual(gMozLoopAPI.getSocialShareProviders().length, 0,
+    "Provider list should be empty initially");
+
+  // Add a provider.
+  yield new Promise(resolve => SocialService.addProvider(kShareProvider, resolve));
+
+  let providers = gMozLoopAPI.getSocialShareProviders();
+  Assert.strictEqual(providers.length, 1,
+    "The newly added provider should be part of the list");
+  let provider = providers[0];
+  Assert.strictEqual(provider.iconURL, kShareProvider.iconURL, "Icon URLs should match");
+  Assert.strictEqual(provider.name, kShareProvider.name, "Names should match");
+  Assert.strictEqual(provider.origin, kShareProvider.origin, "Origins should match");
+
+  // Add another provider that should not be picked up by Loop.
+  yield new Promise(resolve => SocialService.addProvider(kShareProviderInvalid, resolve));
+
+  providers = gMozLoopAPI.getSocialShareProviders();
+  Assert.strictEqual(providers.length, 1,
+    "The newly added provider should not be part of the list");
+
+  // Let's add a valid second provider object.
+  let provider2 = Object.create(kShareProvider);
+  provider2.name = "Wildly different name";
+  provider2.origin = "https://example3.com";
+  yield new Promise(resolve => SocialService.addProvider(provider2, resolve));
+
+  providers = gMozLoopAPI.getSocialShareProviders();
+  Assert.strictEqual(providers.length, 2,
+    "The newly added provider should be part of the list");
+  Assert.strictEqual(providers[1].name, provider2.name,
+    "Providers should be ordered alphabetically");
+
+  // Remove the second valid provider.
+  yield new Promise(resolve => SocialService.disableProvider(provider2.origin, resolve));
+  providers = gMozLoopAPI.getSocialShareProviders();
+  Assert.strictEqual(providers.length, 1,
+    "The uninstalled provider should not be part of the list");
+  Assert.strictEqual(providers[0].name, kShareProvider.name, "Names should match");
+});
+
+add_task(function* test_mozLoop_socialShareRoom() {
+  gMozLoopAPI.addSocialShareButton();
+
+  gMozLoopAPI.socialShareRoom(kShareProvider.origin, "https://someroom.com", "Some Title");
+
+  yield promiseWaitForCondition(() => SocialShare.panel.state == "open");
+
+  Assert.equal(SocialShare.iframe.getAttribute("origin"), kShareProvider.origin,
+    "Origins should match");
+  Assert.equal(SocialShare.iframe.getAttribute("src"), kShareProvider.shareURL,
+    "Provider's share page should be displayed");
+
+  SocialShare.panel.hidePopup();
+  CustomizableUI.removeWidgetFromArea(kShareWidgetId);
+});
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -66,16 +66,44 @@ function promiseGetMozLoopAPI() {
       let frame = document.getElementById(frameId);
       if (frame) {
         frame.remove();
       }
     });
   });
 }
 
+function waitForCondition(condition, nextTest, errorMsg) {
+  var tries = 0;
+  var interval = setInterval(function() {
+    if (tries >= 30) {
+      ok(false, errorMsg);
+      moveOn();
+    }
+    var conditionPassed;
+    try {
+      conditionPassed = condition();
+    } catch (e) {
+      ok(false, e + "\n" + e.stack);
+      conditionPassed = false;
+    }
+    if (conditionPassed) {
+      moveOn();
+    }
+    tries++;
+  }, 100);
+  var moveOn = function() { clearInterval(interval); nextTest(); };
+}
+
+function promiseWaitForCondition(aConditionFn) {
+  let deferred = Promise.defer();
+  waitForCondition(aConditionFn, deferred.resolve, "Condition didn't pass.");
+  return deferred.promise;
+}
+
 /**
  * Loads the loop panel by clicking the button and waits for its open to complete.
  * It also registers
  *
  * This assumes that the tests are running in a generatorTest.
  */
 function loadLoopPanel(aOverrideOptions = {}) {
   // Turn off the network for loop tests, so that we don't