Bug 1372067 - Part 1: Implement the prompt timing policy of the tour notification bar, r=mossop draft
authorFischer.json <fischer.json@gmail.com>
Wed, 12 Jul 2017 12:33:45 +0800
changeset 607272 8128c717f157f4a05ef1b89821aff528efb250ec
parent 607271 885f2a94bc5bb2fe79de919b6b3d8f2072468551
child 607273 35451b18459103fbeb128e2101bd34fb4183b016
child 607274 f360d5de61689066960fd9536f7304fd2c0a2bf6
push id67953
push userbmo:fliu@mozilla.com
push dateWed, 12 Jul 2017 06:23:33 +0000
reviewersmossop
bugs1372067
milestone56.0a1
Bug 1372067 - Part 1: Implement the prompt timing policy of the tour notification bar, r=mossop This commit - mutes tour notification for the 1st 5 mins on the 1st session - moves on to next tour notification when a. previous tour has been prompted 8 times(8 impressions) or b. the last time of changing previous tour is 5 days ago - removes tour from the notification queue forever after user clicked the close or the action button on notification bar to interact with that tour notification. - makes each tour only has 2 chances to prompt with notification. Each chance includes 8 impressions and 5-days life time. After these 2 chances, no notification would be prompted for tour. MozReview-Commit-ID: 8fFxohgEkWm
browser/app/profile/firefox.js
browser/extensions/onboarding/OnboardingTourType.jsm
browser/extensions/onboarding/bootstrap.js
browser/extensions/onboarding/content/onboarding.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1699,16 +1699,19 @@ pref("browser.suppress_first_window_anim
 pref("browser.onboarding.enabled", true);
 // Mark this as an upgraded profile so we don't offer the initial new user onboarding tour.
 pref("browser.onboarding.tourset-version", 1);
 pref("browser.onboarding.hidden", false);
 // On the Activity-Stream page, the snippet's position overlaps with our notification.
 // So use `browser.onboarding.notification.finished` to let the AS page know
 // if our notification is finished and safe to show their snippet.
 pref("browser.onboarding.notification.finished", false);
+pref("browser.onboarding.notification.mute-duration-on-first-session-ms", 300000); // 5 mins
+pref("browser.onboarding.notification.max-life-time-per-tour-ms", 432000000); // 5 days
+pref("browser.onboarding.notification.max-prompt-count-per-tour", 8);
 pref("browser.onboarding.newtour", "private,addons,customize,search,default,sync");
 pref("browser.onboarding.updatetour", "");
 
 // Preferences for the Screenshots feature:
 // Temporarily disable Screenshots in Beta & Release, so that we can gradually
 // roll out the feature using SHIELD pref flipping.
 #ifdef NIGHTLY_BUILD
 pref("extensions.screenshots.system-disabled", false);
--- a/browser/extensions/onboarding/OnboardingTourType.jsm
+++ b/browser/extensions/onboarding/OnboardingTourType.jsm
@@ -28,12 +28,17 @@ var OnboardingTourType = {
 
     if (!Services.prefs.prefHasUserValue(PREF_SEEN_TOURSET_VERSION)) {
       // User has never seen an onboarding tour, present the user with the new user tour.
       Services.prefs.setStringPref(PREF_TOUR_TYPE, "new");
     } else if (Services.prefs.getIntPref(PREF_SEEN_TOURSET_VERSION) < TOURSET_VERSION) {
       // show the update user tour when tour set version is larger than the seen tourset version
       Services.prefs.setStringPref(PREF_TOUR_TYPE, "update");
       Services.prefs.setBoolPref("browser.onboarding.hidden", false);
+      // Reset all the notification-related prefs because tours update.
+      Services.prefs.setBoolPref("browser.onboarding.notification.finished", false);
+      Services.prefs.clearUserPref("browser.onboarding.notification.prompt-count");
+      Services.prefs.clearUserPref("browser.onboarding.notification.last-time-of-changing-tour-sec");
+      Services.prefs.clearUserPref("browser.onboarding.notification.tour-ids-queue");
     }
     Services.prefs.setIntPref(PREF_SEEN_TOURSET_VERSION, TOURSET_VERSION);
   },
 };
--- a/browser/extensions/onboarding/bootstrap.js
+++ b/browser/extensions/onboarding/bootstrap.js
@@ -12,17 +12,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
   "resource://gre/modules/Services.jsm");
 
 const PREF_WHITELIST = [
   "browser.onboarding.enabled",
   "browser.onboarding.hidden",
   "browser.onboarding.notification.finished",
-  "browser.onboarding.notification.lastPrompted"
+  "browser.onboarding.notification.prompt-count",
+  "browser.onboarding.notification.last-time-of-changing-tour-sec",
+  "browser.onboarding.notification.tour-ids-queue"
 ];
 
 [
   "onboarding-tour-private-browsing",
   "onboarding-tour-addons",
   "onboarding-tour-customize",
   "onboarding-tour-search",
   "onboarding-tour-default-browser",
--- a/browser/extensions/onboarding/content/onboarding.js
+++ b/browser/extensions/onboarding/content/onboarding.js
@@ -14,16 +14,17 @@ const ONBOARDING_CSS_URL = "resource://o
 const ABOUT_HOME_URL = "about:home";
 const ABOUT_NEWTAB_URL = "about:newtab";
 const BUNDLE_URI = "chrome://onboarding/locale/onboarding.properties";
 const UITOUR_JS_URI = "resource://onboarding/lib/UITour-lib.js";
 const TOUR_AGENT_JS_URI = "resource://onboarding/onboarding-tour-agent.js";
 const BRAND_SHORT_NAME = Services.strings
                      .createBundle("chrome://branding/locale/brand.properties")
                      .GetStringFromName("brandShortName");
+const PROMPT_COUNT_PREF = "browser.onboarding.notification.prompt-count";
 
 /**
  * Add any number of tours, following the format
  * "tourId": { // The short tour id which could be saved in pref
  *   // The unique tour id
  *   id: "onboarding-tour-addons",
  *   // The string id of tour name which would be displayed on the navigation bar
  *   tourNameId: "onboarding.tour-addon",
@@ -278,17 +279,18 @@ class Onboarding {
 
     this._overlay = this._renderOverlay();
     this._overlay.addEventListener("click", this);
     this._window.document.body.appendChild(this._overlay);
 
     this._loadJS(TOUR_AGENT_JS_URI);
 
     this._initPrefObserver();
-    this._initNotification();
+    // Doing tour notification takes some effort. Let's do it on idle.
+    this._window.requestIdleCallback(() => this._initNotification());
   }
 
   _getTourIDList(tourType) {
     let tours = Services.prefs.getStringPref(`browser.onboarding.${tourType}tour`, "");
     return tours.split(",").filter(tourId => tourId !== "").map(tourId => tourId.trim());
   }
 
   _initNotification() {
@@ -365,21 +367,23 @@ class Onboarding {
       // If the clicking target is directly on the outer-most overlay,
       // that means clicking outside the tour content area.
       // Let's toggle the overlay.
       case "onboarding-overlay":
         this.toggleOverlay();
         break;
       case "onboarding-notification-close-btn":
         this.hideNotification();
+        this._removeTourFromNotificationQueue(this._notificationBar.dataset.targetTourId);
         break;
       case "onboarding-notification-action-btn":
         let tourId = this._notificationBar.dataset.targetTourId;
         this.toggleOverlay();
         this.gotoPage(tourId);
+        this._removeTourFromNotificationQueue(tourId);
         break;
     }
     let classList = evt.target.classList;
     if (classList.contains("onboarding-tour-item")) {
       this.gotoPage(evt.target.id);
     } else if (classList.contains("onboarding-tour-action-button")) {
       let activeItem = this._tourItems.find(item => item.classList.contains("onboarding-active"));
       this.setToursCompleted([ activeItem.id ]);
@@ -453,74 +457,171 @@ class Onboarding {
   markTourCompletionState(tourId) {
     // We are doing lazy load so there might be no items.
     if (this._tourItems.length > 0 && this.isTourCompleted(tourId)) {
       let targetItem = this._tourItems.find(item => item.id == tourId);
       targetItem.classList.add("onboarding-complete");
     }
   }
 
+  _muteNotificationOnFirstSession() {
+    if (Preferences.isSet("browser.onboarding.notification.tour-ids-queue")) {
+      // There is a queue. We had prompted before, this must not be the 1st session.
+      return false;
+    }
+
+    let muteDuration = Preferences.get("browser.onboarding.notification.mute-duration-on-first-session-ms");
+    if (muteDuration == 0) {
+      // Don't mute when this is set to 0 on purpose.
+      return false;
+    }
+
+    // Reuse the `last-time-of-changing-tour-sec` to save the time that
+    // we try to prompt on the 1st session.
+    let lastTime = 1000 * Preferences.get("browser.onboarding.notification.last-time-of-changing-tour-sec", 0);
+    if (lastTime <= 0) {
+      this.sendMessageToChrome("set-prefs", [{
+        name: "browser.onboarding.notification.last-time-of-changing-tour-sec",
+        value: Math.floor(Date.now() / 1000)
+      }]);
+      return true;
+    }
+    return Date.now() - lastTime <= muteDuration;
+  }
+
+  _isTimeForNextTourNotification() {
+    let promptCount = Preferences.get("browser.onboarding.notification.prompt-count", 0);
+    let maxCount = Preferences.get("browser.onboarding.notification.max-prompt-count-per-tour");
+    if (promptCount >= maxCount) {
+      return true;
+    }
+
+    let lastTime = 1000 * Preferences.get("browser.onboarding.notification.last-time-of-changing-tour-sec", 0);
+    let maxTime = Preferences.get("browser.onboarding.notification.max-life-time-per-tour-ms");
+    if (lastTime && Date.now() - lastTime >= maxTime) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _removeTourFromNotificationQueue(tourId) {
+    let params = [];
+    let queue = this._getNotificationQueue();
+    params.push({
+      name: "browser.onboarding.notification.tour-ids-queue",
+      value: queue.filter(id => id != tourId).join(",")
+    });
+    params.push({
+      name: "browser.onboarding.notification.last-time-of-changing-tour-sec",
+      value: 0
+    });
+    params.push({
+      name: "browser.onboarding.notification.prompt-count",
+      value: 0
+    });
+    this.sendMessageToChrome("set-prefs", params);
+  }
+
+  _getNotificationQueue() {
+    let queue = "";
+    if (Preferences.isSet("browser.onboarding.notification.tour-ids-queue")) {
+      queue = Preferences.get("browser.onboarding.notification.tour-ids-queue");
+    } else {
+      // For each tour, it only gets 2 chances to prompt with notification
+      // (each chance includes 8 impressions or 5-days max life time)
+      // if user never interact with it.
+      // Assume there are tour #0 ~ #5. Here would form the queue as
+      // "#0,#1,#2,#3,#4,#5,#0,#1,#2,#3,#4,#5".
+      // Then we would loop through this queue and remove prompted tour from the queue
+      // until the queue is empty.
+      let ids = this._tours.map(tour => tour.id).join(",");
+      queue = `${ids},${ids}`;
+      this.sendMessageToChrome("set-prefs", [{
+        name: "browser.onboarding.notification.tour-ids-queue",
+        value: queue
+      }]);
+    }
+    return queue ? queue.split(",") : [];
+  }
+
   showNotification() {
     if (Preferences.get("browser.onboarding.notification.finished", false)) {
       return;
     }
 
-    // Pick out the next target tour to show
-    let targetTour = null;
-
-    // Take the last tour as the default last prompted
-    // so below would start from the 1st one if found no the last prompted from the pref.
-    let lastPromptedId = this._tours[this._tours.length - 1].id;
-    lastPromptedId = Preferences.get("browser.onboarding.notification.lastPrompted", lastPromptedId);
+    if (this._muteNotificationOnFirstSession()) {
+      return;
+    }
 
-    let lastTourIndex = this._tours.findIndex(tour => tour.id == lastPromptedId);
-    if (lastTourIndex < 0) {
-      // Couldn't find the tour.
-      // This could be because the pref was manually modified into unknown value
-      // or the tour version has been updated so have an new tours set.
-      // Take the last tour as the last prompted so would start from the 1st one below.
-      lastTourIndex = this._tours.length - 1;
+    let queue = this._getNotificationQueue();
+    let startQueueLength = queue.length;
+    // See if need to move on to the next tour
+    if (queue.length > 0 && this._isTimeForNextTourNotification()) {
+      queue.shift();
+    }
+    // We don't want to prompt completed tour.
+    while (queue.length > 0 && this.isTourCompleted(queue[0])) {
+      queue.shift();
     }
 
-    // Form tours to notify into the order we want.
-    // For example, There are tour #0 ~ #5 and the #3 is the last prompted.
-    // This would form [#4, #5, #0, #1, #2, #3].
-    // So the 1st met incomplete tour in #4 ~ #2 would be the one to show.
-    // Or #3 would be the one to show if #4 ~ #2 are all completed.
-    let toursToNotify = [ ...this._tours.slice(lastTourIndex + 1), ...this._tours.slice(0, lastTourIndex + 1) ];
-    targetTour = toursToNotify.find(tour => !this.isTourCompleted(tour.id));
-
-
-    if (!targetTour) {
-      this.sendMessageToChrome("set-prefs", [{
-        name: "browser.onboarding.notification.finished",
-        value: true
-      }]);
+    if (queue.length == 0) {
+      this.sendMessageToChrome("set-prefs", [
+        {
+          name: "browser.onboarding.notification.finished",
+          value: true
+        },
+        {
+          name: "browser.onboarding.notification.tour-ids-queue",
+          value: ""
+        }
+      ]);
       return;
     }
+    let targetTourId = queue[0];
+    let targetTour = this._tours.find(tour => tour.id == targetTourId);
 
     // Show the target tour notification
     this._notificationBar = this._renderNotificationBar();
     this._notificationBar.addEventListener("click", this);
     this._window.document.body.appendChild(this._notificationBar);
 
     this._notificationBar.dataset.targetTourId = targetTour.id;
     let notificationStrings = targetTour.getNotificationStrings(this._bundle);
     let actionBtn = this._notificationBar.querySelector("#onboarding-notification-action-btn");
     actionBtn.textContent = notificationStrings.button;
     let tourTitle = this._notificationBar.querySelector("#onboarding-notification-tour-title");
     tourTitle.textContent = notificationStrings.title;
     let tourMessage = this._notificationBar.querySelector("#onboarding-notification-tour-message");
     tourMessage.textContent = notificationStrings.message;
     this._notificationBar.classList.add("onboarding-opened");
 
-    this.sendMessageToChrome("set-prefs", [{
-      name: "browser.onboarding.notification.lastPrompted",
-      value: targetTour.id
-    }]);
+    let params = [];
+    if (startQueueLength != queue.length) {
+      // We just change tour so update the time, the count and the queue
+      params.push({
+        name: "browser.onboarding.notification.last-time-of-changing-tour-sec",
+        value: Math.floor(Date.now() / 1000)
+      });
+      params.push({
+        name: PROMPT_COUNT_PREF,
+        value: 1
+      });
+      params.push({
+        name: "browser.onboarding.notification.tour-ids-queue",
+        value: queue.join(",")
+      });
+    } else {
+      let promptCount = Preferences.get(PROMPT_COUNT_PREF, 0);
+      params.push({
+        name: PROMPT_COUNT_PREF,
+        value: promptCount + 1
+      });
+    }
+    this.sendMessageToChrome("set-prefs", params);
   }
 
   hideNotification() {
     if (this._notificationBar) {
       this._notificationBar.classList.remove("onboarding-opened");
     }
   }