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
--- 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");
}
}