Bug 1083466 - Add a button to the Loop panel for the Getting Started tour. r=mikedeboer
authorJared Wein <jwein@mozilla.com>
Tue, 18 Nov 2014 12:39:29 -0500
changeset 216372 e15cb9b338278fb232d64f6d688ed59e90e80eec
parent 216371 121a5cc63382816393d515993817925c41c1f2f7
child 216373 c040e198d145b60627b3aaded10e477f03a31869
push id52026
push userkwierso@gmail.com
push dateWed, 19 Nov 2014 02:37:17 +0000
treeherdermozilla-inbound@d197d16c0caa [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1083466
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1083466 - Add a button to the Loop panel for the Getting Started tour. r=mikedeboer
browser/app/profile/firefox.js
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/test/desktop-local/panel_test.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1639,16 +1639,18 @@ pref("shumway.disabled", true);
 // The maximum amount of decoded image data we'll willingly keep around (we
 // might keep around more than this, but we'll try to get down to this value).
 // (This is intentionally on the high side; see bug 746055.)
 pref("image.mem.max_decoded_image_kb", 256000);
 
 pref("loop.enabled", true);
 pref("loop.server", "https://loop.services.mozilla.com");
 pref("loop.seenToS", "unseen");
+pref("loop.gettingStarted.seen", false);
+pref("loop.gettingStarted.url", "https://bugzilla.mozilla.org/show_bug.cgi?id=1099462");
 pref("loop.learnMoreUrl", "https://www.firefox.com/hello/");
 pref("loop.legal.ToS_url", "https://hello.firefox.com/legal/terms/");
 pref("loop.legal.privacy_url", "https://www.mozilla.org/privacy/");
 pref("loop.do_not_disturb", false);
 pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/ringtone.ogg");
 pref("loop.retry_delay.start", 60000);
 pref("loop.retry_delay.limit", 300000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -447,16 +447,33 @@ function injectLoopAPI(targetWindow) {
       enumerable: true,
       writable: true,
       value: function(prefName) {
         return MozLoopService.getLoopCharPref(prefName);
       }
     },
 
     /**
+     * Set any boolean preference under "loop."
+     *
+     * @param {String} prefName The name of the pref without the preceding "loop."
+     * @param {bool} value The value to set.
+     *
+     * Any errors thrown by the Mozilla pref API are logged to the console
+     * and cause false to be returned.
+     */
+    setLoopBoolPref: {
+      enumerable: true,
+      writable: true,
+      value: function(prefName, value) {
+        MozLoopService.setLoopBoolPref(prefName, value);
+      }
+    },
+
+    /**
      * Return any preference under "loop." that's coercible to a boolean
      * preference.
      *
      * @param {String} prefName The name of the pref without the preceding
      * "loop."
      *
      * Any errors thrown by the Mozilla pref API are logged to the console
      * and cause null to be returned. This includes the case of the preference
@@ -592,16 +609,27 @@ function injectLoopAPI(targetWindow) {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.openFxASettings();
       },
     },
 
     /**
+     * Opens the Getting Started tour in the browser.
+     */
+    openGettingStartedTour: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        return MozLoopService.openGettingStartedTour();
+      },
+    },
+
+    /**
      * Copies passed string onto the system clipboard.
      *
      * @param {String} str The string to copy
      */
     copyString: {
       enumerable: true,
       writable: true,
       value: function(str) {
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1255,16 +1255,33 @@ this.MozLoopService = {
     } catch (ex) {
       log.error("getLoopCharPref had trouble getting " + prefName +
         "; exception: " + ex);
       return null;
     }
   },
 
   /**
+   * Set any boolean preference under "loop.".
+   *
+   * @param {String} prefName The name of the pref without the preceding "loop."
+   * @param {boolean} value The value to set.
+   *
+   * Any errors thrown by the Mozilla pref API are logged to the console.
+   */
+  setLoopBoolPref: function(prefName, value) {
+    try {
+      Services.prefs.setBoolPref("loop." + prefName, value);
+    } catch (ex) {
+      log.error("setLoopCharPref had trouble setting " + prefName +
+        "; exception: " + ex);
+    }
+  },
+
+  /**
    * Return any preference under "loop." that's coercible to a character
    * preference.
    *
    * @param {String} prefName The name of the pref without the preceding
    * "loop."
    *
    * Any errors thrown by the Mozilla pref API are logged to the console
    * and cause null to be returned. This includes the case of the preference
@@ -1383,16 +1400,29 @@ this.MozLoopService = {
       let win = Services.wm.getMostRecentWindow("navigator:browser");
       win.switchToTabHavingURI(url.toString(), true);
     } catch (ex) {
       log.error("Error opening FxA settings", ex);
     }
   }),
 
   /**
+   * Opens the Getting Started tour in the browser.
+   */
+  openGettingStartedTour: Task.async(function() {
+    try {
+      let url = Services.prefs.getCharPref("loop.gettingStarted.url");
+      let win = Services.wm.getMostRecentWindow("navigator:browser");
+      win.switchToTabHavingURI(url, true);
+    } catch (ex) {
+      log.error("Error opening Getting Started tour", ex);
+    }
+  }),
+
+  /**
    * Performs a hawk based request to the loop server.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
    *                                        One of the LOOP_SESSION_TYPE members.
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -160,16 +160,44 @@ loop.panel = (function(_, mozL10n) {
               React.DOM.span(null, __("display_name_dnd_status"))
             )
           )
         )
       );
     }
   });
 
+  var GettingStartedView = React.createClass({displayName: 'GettingStartedView',
+    componentDidMount: function() {
+      navigator.mozLoop.setLoopBoolPref("gettingStarted.seen", true);
+    },
+
+    handleButtonClick: function() {
+      navigator.mozLoop.openGettingStartedTour();
+    },
+
+    render: function() {
+      if (navigator.mozLoop.getLoopBoolPref("gettingStarted.seen")) {
+        return null;
+      }
+      return (
+        React.DOM.div({id: "fte-getstarted"}, 
+          React.DOM.header({id: "fte-title"}, 
+            mozL10n.get("first_time_experience_title", {
+              "clientShortname": mozL10n.get("clientShortname2")
+            })
+          ), 
+          Button({htmlId: "fte-button", 
+                  onClick: this.handleButtonClick, 
+                  caption: mozL10n.get("first_time_experience_button_label")})
+        )
+      );
+    }
+  });
+
   var ToSView = React.createClass({displayName: 'ToSView',
     getInitialState: function() {
       return {seenToS: navigator.mozLoop.getLoopCharPref('seenToS')};
     },
 
     render: function() {
       if (this.state.seenToS == "unseen") {
         var terms_of_use_url = navigator.mozLoop.getLoopCharPref('legal.ToS_url');
@@ -402,17 +430,17 @@ loop.panel = (function(_, mozL10n) {
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "generate-url"}, 
-          React.DOM.header(null, __("share_link_header_text")), 
+          React.DOM.header({id: "share-link-header"}, mozL10n.get("share_link_header_text")), 
           React.DOM.div({className: "generate-url-stack"}, 
             React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
                    onCopy: this.handleLinkExfiltration, 
                    className: cx({"generate-url-input": true,
                                   pending: this.state.pending,
                                   // Used in functional testing, signals that
                                   // call url was received from loop server
                                   callUrl: !this.state.pending})}), 
@@ -704,27 +732,29 @@ loop.panel = (function(_, mozL10n) {
      * The rooms feature is hidden by default for now. Once it gets mainstream,
      * this method can be simplified.
      */
     _renderRoomsOrCallTab: function() {
       if (!this._roomsEnabled()) {
         return (
           Tab({name: "call"}, 
             React.DOM.div({className: "content-area"}, 
+              GettingStartedView(null), 
               CallUrlResult({client: this.props.client, 
                              notifications: this.props.notifications, 
                              callUrl: this.props.callUrl}), 
               ToSView(null)
             )
           )
         );
       }
 
       return (
         Tab({name: "rooms"}, 
+          GettingStartedView(null), 
           RoomList({dispatcher: this.props.dispatcher, 
                     store: this.props.roomStore, 
                     userDisplayName: this._getUserDisplayName()}), 
           ToSView(null)
         )
       );
     },
 
@@ -825,21 +855,22 @@ loop.panel = (function(_, mozL10n) {
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
-    UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
+    GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
-    ToSView: ToSView
+    ToSView: ToSView,
+    UserIdentity: UserIdentity,
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -160,16 +160,44 @@ loop.panel = (function(_, mozL10n) {
               <span>{__("display_name_dnd_status")}</span>
             </li>
           </ul>
         </div>
       );
     }
   });
 
+  var GettingStartedView = React.createClass({
+    componentDidMount: function() {
+      navigator.mozLoop.setLoopBoolPref("gettingStarted.seen", true);
+    },
+
+    handleButtonClick: function() {
+      navigator.mozLoop.openGettingStartedTour();
+    },
+
+    render: function() {
+      if (navigator.mozLoop.getLoopBoolPref("gettingStarted.seen")) {
+        return null;
+      }
+      return (
+        <div id="fte-getstarted">
+          <header id="fte-title">
+            {mozL10n.get("first_time_experience_title", {
+              "clientShortname": mozL10n.get("clientShortname2")
+            })}
+          </header>
+          <Button htmlId="fte-button"
+                  onClick={this.handleButtonClick}
+                  caption={mozL10n.get("first_time_experience_button_label")} />
+        </div>
+      );
+    }
+  });
+
   var ToSView = React.createClass({
     getInitialState: function() {
       return {seenToS: navigator.mozLoop.getLoopCharPref('seenToS')};
     },
 
     render: function() {
       if (this.state.seenToS == "unseen") {
         var terms_of_use_url = navigator.mozLoop.getLoopCharPref('legal.ToS_url');
@@ -402,17 +430,17 @@ loop.panel = (function(_, mozL10n) {
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       return (
         <div className="generate-url">
-          <header>{__("share_link_header_text")}</header>
+          <header id="share-link-header">{mozL10n.get("share_link_header_text")}</header>
           <div className="generate-url-stack">
             <input type="url" value={this.state.callUrl} readOnly="true"
                    onCopy={this.handleLinkExfiltration}
                    className={cx({"generate-url-input": true,
                                   pending: this.state.pending,
                                   // Used in functional testing, signals that
                                   // call url was received from loop server
                                   callUrl: !this.state.pending})} />
@@ -704,27 +732,29 @@ loop.panel = (function(_, mozL10n) {
      * The rooms feature is hidden by default for now. Once it gets mainstream,
      * this method can be simplified.
      */
     _renderRoomsOrCallTab: function() {
       if (!this._roomsEnabled()) {
         return (
           <Tab name="call">
             <div className="content-area">
+              <GettingStartedView />
               <CallUrlResult client={this.props.client}
                              notifications={this.props.notifications}
                              callUrl={this.props.callUrl} />
               <ToSView />
             </div>
           </Tab>
         );
       }
 
       return (
         <Tab name="rooms">
+          <GettingStartedView />
           <RoomList dispatcher={this.props.dispatcher}
                     store={this.props.roomStore}
                     userDisplayName={this._getUserDisplayName()}/>
           <ToSView />
         </Tab>
       );
     },
 
@@ -825,21 +855,22 @@ loop.panel = (function(_, mozL10n) {
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
-    UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
+    GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
-    ToSView: ToSView
+    ToSView: ToSView,
+    UserIdentity: UserIdentity,
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -97,20 +97,46 @@ body {
 /* Content area and input fields */
 
 .content-area {
   padding: 14px;
 }
 
 .content-area header {
   font-weight: 700;
+}
+
+#fte-getstarted {
+  padding-top: 1em;
+  padding-bottom: 1em;
+  border-bottom: 1px solid #ccc;
+  margin-bottom: 1em;
+}
+
+#fte-title {
+  text-align: center;
+  margin-bottom: .5em;
+}
+
+#fte-button {
+  width: 50%;
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+  font-size: 1rem;
+  padding: .5rem 1rem;
+  height: auto; /* Needed to override .button's height:26px; */
+}
+
+/* Need to remove when these rules when the Beta tag is removed */
+#share-link-header {
   -moz-padding-start: 20px;
 }
-
-.tab-view + .tab .content-area header {
+#fte-getstarted + .generate-url > #share-link-header,
+.tab-view + .tab .content-area > .generate-url > #share-link-header {
   /* The header shouldn't be indented if the tabs are present. */
   -moz-padding-start: 0;
 }
 
 .content-area label {
   display: block;
   width: 100%;
   margin-top: 10px;
@@ -429,16 +455,17 @@ body[dir=rtl] .dropdown-menu-item {
   right: 4px;
 }
 
 body[dir=rtl] .generate-url-spinner {
   left: 4px;
   right: auto;
 }
 
+#fte-button,
 .generate-url .button {
   background-color: #0096dd;
   border-color: #0096dd;
   color: #fff;
 }
 
 .generate-url .button:hover {
   background-color: #008acb;
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -713,34 +713,37 @@ loop.shared.views = (function(_, OT, l10
   });
 
   var Button = React.createClass({displayName: 'Button',
     propTypes: {
       caption: React.PropTypes.string.isRequired,
       onClick: React.PropTypes.func.isRequired,
       disabled: React.PropTypes.bool,
       additionalClass: React.PropTypes.string,
+      htmlId: React.PropTypes.string,
     },
 
     getDefaultProps: function() {
       return {
         disabled: false,
         additionalClass: "",
+        htmlId: "",
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         React.DOM.button({onClick: this.props.onClick, 
                 disabled: this.props.disabled, 
+                id: this.props.htmlId, 
                 className: cx(classObject)}, 
           React.DOM.span({className: "button-caption"}, this.props.caption), 
           this.props.children
         )
       )
     }
   });
 
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -713,34 +713,37 @@ loop.shared.views = (function(_, OT, l10
   });
 
   var Button = React.createClass({
     propTypes: {
       caption: React.PropTypes.string.isRequired,
       onClick: React.PropTypes.func.isRequired,
       disabled: React.PropTypes.bool,
       additionalClass: React.PropTypes.string,
+      htmlId: React.PropTypes.string,
     },
 
     getDefaultProps: function() {
       return {
         disabled: false,
         additionalClass: "",
+        htmlId: "",
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         <button onClick={this.props.onClick}
                 disabled={this.props.disabled}
+                id={this.props.htmlId}
                 className={cx(classObject)}>
           <span className="button-caption">{this.props.caption}</span>
           {this.props.children}
         </button>
       )
     }
   });
 
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -29,19 +29,20 @@ describe("loop.panel", function() {
       doNotDisturb: true,
       fxAEnabled: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
       get locale() {
         return "en-US";
       },
-      getLoopBoolPref: sandbox.stub(),
       setLoopCharPref: sandbox.stub(),
       getLoopCharPref: sandbox.stub().returns("unseen"),
+      getLoopBoolPref: sandbox.stub(),
+      setLoopBoolPref: sandbox.stub(),
       getPluralForm: function() {
         return "fakeText";
       },
       copyString: sandbox.stub(),
       noteCallUrlExpiry: sinon.spy(),
       composeEmail: sinon.spy(),
       telemetryAdd: sinon.spy(),
       contacts: {
@@ -356,16 +357,47 @@ describe("loop.panel", function() {
     });
 
     describe("#render", function() {
       it("should render a ToSView", function() {
         var view = createTestPanelView();
 
         TestUtils.findRenderedComponentWithType(view, loop.panel.ToSView);
       });
+
+      it("should not render a ToSView when the view has been 'seen'", function() {
+        navigator.mozLoop.getLoopCharPref = function() {
+          return "seen";
+        };
+        var view = createTestPanelView();
+
+        try {
+          TestUtils.findRenderedComponentWithType(view, loop.panel.ToSView);
+          sinon.assert.fail("Should not find the ToSView if it has been 'seen'");
+        } catch (ex) {}
+      });
+
+      it("should render a GettingStarted view", function() {
+        var view = createTestPanelView();
+
+        TestUtils.findRenderedComponentWithType(view, loop.panel.GettingStartedView);
+      });
+
+      it("should not render a GettingStartedView when the view has been seen", function() {
+        navigator.mozLoop.getLoopBoolPref = function() {
+          return true;
+        };
+        var view = createTestPanelView();
+
+        try {
+          TestUtils.findRenderedComponentWithType(view, loop.panel.GettingStartedView);
+          sinon.assert.fail("Should not find the GettingStartedView if it has been seen");
+        } catch (ex) {}
+      });
+
     });
   });
 
   describe("loop.panel.CallUrlResult", function() {
     var fakeClient, callUrlData, view;
 
     beforeEach(function() {
       callUrlData = {