Bug 1372067 - Part 1: Implement the prompt timing policy of the tour notification bar, r=mossop draft
authorFischer.json <fischer.json@gmail.com>
Tue, 27 Jun 2017 07:34:31 -0700
changeset 605299 77a26d46eb11d623e6e8b726e6aab5e55c1494cf
parent 602720 c4b653bfb45e72426b9ad42893a89d09c2afdbae
child 605300 5112629973bac21f8f5018f9eb915876bfcf1f0b
push id67359
push userbmo:fliu@mozilla.com
push dateFri, 07 Jul 2017 10:53:29 +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
@@ -1704,16 +1704,20 @@ 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);
+
 
 // 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);
 #else
 pref("extensions.screenshots.system-disabled", true);
--- 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.setBoolPref("browser.onboarding.notification.prompt-count", 0);
+      Services.prefs.setStringPref("browser.onboarding.notification.last-time-of-changing-tour-sec", 0);
+      Services.prefs.setStringPref("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 NOTIFICATION_QUEUE_END_MARK = "NOTIFICATION_QUEUE_END_MARK";
 
 /**
  * Add any number of tours, following the format
  * {
  *   // 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",
@@ -247,25 +248,26 @@ class Onboarding {
 
     this._overlayIcon.addEventListener("click", this);
     this._overlay.addEventListener("click", this);
     // Destroy on unload. This is to ensure we remove all the stuff we left.
     // No any leak out there.
     this._window.addEventListener("unload", () => this.destroy());
 
     this._initPrefObserver();
-    this._initNotification();
+    // Doing tour notification takes some effort. Let's do it on idle.
+    this._window.requestIdleCallback(() => this._initNotification());
   }
 
   _initNotification() {
     let doc = this._window.document;
     if (doc.hidden) {
       // When the preloaded-browser feature is on,
       // it would preload an hidden about:newtab in the background.
-      // We don't wnat to show notification in that hidden state.
+      // We don't want to show notification in that hidden state.
       let onVisible = () => {
         if (!doc.hidden) {
           doc.removeEventListener("visibilitychange", onVisible);
           this.showNotification();
         }
       };
       doc.addEventListener("visibilitychange", onVisible);
     } else {
@@ -322,22 +324,24 @@ class Onboarding {
       // 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 ]);
@@ -404,74 +408,167 @@ 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.get("browser.onboarding.notification.tour-ids-queue", "")) {
+      // There is a queue. We had prompted before, this must not be the 1st session.
+      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;
+    }
+    let muteDuration = Preferences.get("browser.onboarding.notification.mute-duration-on-first-session-ms");
+    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 = Preferences.get("browser.onboarding.notification.tour-ids-queue", "");
+    if (!queue) {
+      // 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,NOTIFICATION_QUEUE_END_MARK".
+      // Then we would loop through this queue and remove prompted tour from the queue
+      // until the queue is empty.
+      let ids = onboardingTours.map(tour => tour.id).join(",");
+      queue = `${ids},${ids},${NOTIFICATION_QUEUE_END_MARK}`;
+      this.sendMessageToChrome("set-prefs", [{
+        name: "browser.onboarding.notification.tour-ids-queue",
+        value: queue
+      }]);
+    }
+    return 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 = onboardingTours[onboardingTours.length - 1].id;
-    lastPromptedId = Preferences.get("browser.onboarding.notification.lastPrompted", lastPromptedId);
-
-    let lastTourIndex = onboardingTours.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 = onboardingTours.length - 1;
+    if (this._muteNotificationOnFirstSession()) {
+      return;
     }
 
-    // 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 = [ ...onboardingTours.slice(lastTourIndex + 1), ...onboardingTours.slice(0, lastTourIndex + 1) ];
-    targetTour = toursToNotify.find(tour => !this.isTourCompleted(tour.id));
+    let queue = this._getNotificationQueue();
+    let targetTourId = queue[0];
+    let lastPromptedId = targetTourId;
+    // See if need to move on to the next tour
+    if (targetTourId != NOTIFICATION_QUEUE_END_MARK && this._isTimeForNextTourNotification()) {
+      queue.shift();
+      targetTourId = queue[0];
+    }
+    // We don't want to prompt completed tour.
+    while (targetTourId != NOTIFICATION_QUEUE_END_MARK && this.isTourCompleted(targetTourId)) {
+      queue.shift();
+      targetTourId = queue[0];
+    }
 
-
-    if (!targetTour) {
-      this.sendMessageToChrome("set-prefs", [{
-        name: "browser.onboarding.notification.finished",
-        value: true
-      }]);
+    if (targetTourId == NOTIFICATION_QUEUE_END_MARK) {
+      this.sendMessageToChrome("set-prefs", [
+        {
+          name: "browser.onboarding.notification.finished",
+          value: true
+        },
+        {
+          name: "browser.onboarding.notification.tour-ids-queue",
+          value: NOTIFICATION_QUEUE_END_MARK
+        }
+      ]);
       return;
     }
+    let targetTour = onboardingTours.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 = [];
+    let promptCountPref = "browser.onboarding.notification.prompt-count";
+    if (lastPromptedId != targetTour.id) {
+      // We just change tour so update the time and the count
+      params.push({
+        name: "browser.onboarding.notification.last-time-of-changing-tour-sec",
+        value: Math.floor(Date.now() / 1000)
+      });
+      params.push({
+        name: promptCountPref,
+        value: 1
+      });
+    } else {
+      let promptCount = Preferences.get(promptCountPref, 0);
+      params.push({
+        name: promptCountPref,
+        value: promptCount + 1
+      });
+    }
+    params.push({
+      name: "browser.onboarding.notification.tour-ids-queue",
+      value: queue.join(",")
+    });
+    this.sendMessageToChrome("set-prefs", params);
   }
 
   hideNotification() {
     if (this._notificationBar) {
       this._notificationBar.classList.remove("onboarding-opened");
     }
   }