/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-env mozilla/frame-script */
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
const ONBOARDING_CSS_URL = "resource://onboarding/onboarding.css";
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";
const ONBOARDING_DIALOG_ID = "onboarding-overlay-dialog";
const ONBOARDING_MIN_WIDTH_PX = 960;
const SPEECH_BUBBLE_MIN_WIDTH_PX = 1130;
const SPEECH_BUBBLE_NEWTOUR_STRING_ID = "onboarding.overlay-icon-tooltip2";
const SPEECH_BUBBLE_UPDATETOUR_STRING_ID = "onboarding.overlay-icon-tooltip-updated2";
const ICON_STATE_WATERMARK = "watermark";
const ICON_STATE_DEFAULT = "default";
/**
* Add any number of tours, key is the tourId, value should follow the format below
* "tourId": { // The short tour id which could be saved in pref
* // The unique tour id
* id: "onboarding-tour-addons",
* // (optional) mark tour as complete instantly when the user enters the tour
* instantComplete: false,
* // The string id of tour name which would be displayed on the navigation bar
* tourNameId: "onboarding.tour-addon",
* // The method returing strings used on tour notification
* getNotificationStrings(bundle):
* - title: // The string of tour notification title
* - message: // The string of tour notification message
* - button: // The string of tour notification action button title
* // Return a div appended with elements for this tours.
* // Each tour should contain the following 3 sections in the div:
* // .onboarding-tour-description, .onboarding-tour-content, .onboarding-tour-button-container.
* // Add onboarding-no-button css class in the div if this tour does not need a button container.
* // If there was a .onboarding-tour-action-button present and was clicked, tour would be marked as completed.
* getPage() {},
* },
**/
var onboardingTourset = {
"private": {
id: "onboarding-tour-private-browsing",
tourNameId: "onboarding.tour-private-browsing",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-private-browsing.title"),
message: bundle.GetStringFromName("onboarding.notification.onboarding-tour-private-browsing.message2"),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"addons": {
id: "onboarding-tour-addons",
tourNameId: "onboarding.tour-addons",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-addons.title"),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-addons.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"customize": {
id: "onboarding-tour-customize",
tourNameId: "onboarding.tour-customize",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-customize.title"),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-customize.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"default": {
id: "onboarding-tour-default-browser",
instantComplete: true,
tourNameId: "onboarding.tour-default-browser",
getNotificationStrings(bundle) {
return {
title: bundle.formatStringFromName("onboarding.notification.onboarding-tour-default-browser.title", [BRAND_SHORT_NAME], 1),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-default-browser.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win, bundle) {
let div = win.document.createElement("div");
let setFromBackGround = bundle.formatStringFromName("onboarding.tour-default-browser.win7.button", [BRAND_SHORT_NAME], 1);
let setFromPanel = bundle.GetStringFromName("onboarding.tour-default-browser.button");
let isDefaultMessage = bundle.GetStringFromName("onboarding.tour-default-browser.is-default.message");
let isDefault2ndMessage = bundle.formatStringFromName("onboarding.tour-default-browser.is-default.2nd-message", [BRAND_SHORT_NAME], 1);
// eslint-disable-next-line no-unsanitized/property
div.innerHTML = `
`;
div.addEventListener("beforeshow", () => {
win.document.dispatchEvent(new Event("Agent:CanSetDefaultBrowserInBackground"));
});
return div;
},
},
"sync": {
id: "onboarding-tour-sync",
instantComplete: true,
tourNameId: "onboarding.tour-sync2",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-sync.title"),
message: bundle.GetStringFromName("onboarding.notification.onboarding-tour-sync.message"),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win, bundle) {
const STATE_LOGOUT = "logged-out";
const STATE_LOGIN = "logged-in";
let div = win.document.createElement("div");
div.classList.add("onboarding-no-button");
div.dataset.loginState = STATE_LOGOUT;
// The email validation pattern used in the form comes from IETF rfc5321,
// which is identical to server-side checker of Firefox Account. See
// discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1378770#c6
// for detail.
let emailRegex = "^[\\w.!#$%&’*+\\/=?^`{|}~-]{1,64}@[a-z\\d](?:[a-z\\d-]{0,253}[a-z\\d])?(?:\\.[a-z\\d](?:[a-z\\d-]{0,253}[a-z\\d])?)+$";
div.innerHTML = `
`;
let emailInput = div.querySelector("#onboarding-tour-sync-email-input");
emailInput.placeholder =
bundle.GetStringFromName("onboarding.tour-sync.email-input.placeholder");
emailInput.pattern = emailRegex;
div.addEventListener("beforeshow", () => {
function loginStatusListener(msg) {
removeMessageListener("Onboarding:ResponseLoginStatus", loginStatusListener);
div.dataset.loginState = msg.data.isLoggedIn ? STATE_LOGIN : STATE_LOGOUT;
}
sendMessageToChrome("get-login-status");
addMessageListener("Onboarding:ResponseLoginStatus", loginStatusListener);
});
return div;
},
},
"library": {
id: "onboarding-tour-library",
tourNameId: "onboarding.tour-library",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-library.title"),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-library.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"singlesearch": {
id: "onboarding-tour-singlesearch",
tourNameId: "onboarding.tour-singlesearch",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-singlesearch.title"),
message: bundle.GetStringFromName("onboarding.notification.onboarding-tour-singlesearch.message"),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win, bundle) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"performance": {
id: "onboarding-tour-performance",
instantComplete: true,
tourNameId: "onboarding.tour-performance",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-performance.title"),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-performance.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win, bundle) {
let div = win.document.createElement("div");
div.innerHTML = `
`;
return div;
},
},
"screenshots": {
id: "onboarding-tour-screenshots",
tourNameId: "onboarding.tour-screenshots",
getNotificationStrings(bundle) {
return {
title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-screenshots.title"),
message: bundle.formatStringFromName("onboarding.notification.onboarding-tour-screenshots.message", [BRAND_SHORT_NAME], 1),
button: bundle.GetStringFromName("onboarding.button.learnMore"),
};
},
getPage(win, bundle) {
let div = win.document.createElement("div");
// Screenshot tour opens the screenshot page directly, see below a#onboarding-tour-screenshots-button.
// The screenshots page should be responsible for highlighting the Screenshots button
div.innerHTML = `
`;
return div;
},
},
};
/**
* @param {String} action the action to ask the chrome to do
* @param {Array | Object} params the parameters for the action
*/
function sendMessageToChrome(action, params) {
sendAsyncMessage("Onboarding:OnContentMessage", {
action, params
});
}
/**
* Template code for talking to `PingCentre`
* @param {Object} data the payload for the telemetry
*/
function telemetry(data) {
sendMessageToChrome("ping-centre", {data});
}
function registerNewTelemetrySession(data) {
telemetry(Object.assign(data, {
event: "onboarding-register-session",
}));
}
/**
* The script won't be initialized if we turned off onboarding by
* setting "browser.onboarding.enabled" to false.
*/
class Onboarding {
constructor(contentWindow) {
this.init(contentWindow);
}
async init(contentWindow) {
this._window = contentWindow;
// session_key is used for telemetry to track the current tab.
// The number will renew after reloading the page.
this._session_key = Date.now();
this._tours = [];
this._tourType = Services.prefs.getStringPref("browser.onboarding.tour-type", "update");
let tourIds = this._getTourIDList();
tourIds.forEach(tourId => {
if (onboardingTourset[tourId]) {
this._tours.push(onboardingTourset[tourId]);
}
});
if (this._tours.length === 0) {
return;
}
// We want to create and append elements after CSS is loaded so
// no flash of style changes and no additional reflow.
await this._loadCSS();
this._bundle = Services.strings.createBundle(BUNDLE_URI);
this._loadJS(UITOUR_JS_URI);
this._window.addEventListener("resize", this);
// Destroy on unloading. This is to ensure we remove all the stuff we left.
// No any leak out there.
this._window.addEventListener("unload", () => this.destroy());
this.uiInitialized = false;
this._resizeTimerId =
this._window.requestIdleCallback(() => this._resizeUI());
registerNewTelemetrySession({
page: this._window.location.href,
session_key: this._session_key,
tour_type: this._tourType,
});
telemetry({
event: "onboarding-session-begin",
session_key: this._session_key,
});
}
_resizeUI() {
let width = this._window.document.body.getBoundingClientRect().width;
if (width < ONBOARDING_MIN_WIDTH_PX) {
// Don't show the overlay UI before we get to a better, responsive design.
this.destroy();
return;
}
this._initUI();
if (this._isFirstSession && width >= SPEECH_BUBBLE_MIN_WIDTH_PX) {
this._overlayIcon.classList.add("onboarding-speech-bubble");
} else {
this._overlayIcon.classList.remove("onboarding-speech-bubble");
}
}
_initUI() {
if (this.uiInitialized) {
return;
}
this.uiInitialized = true;
this._tourItems = [];
this._tourPages = [];
let { body } = this._window.document;
this._overlayIcon = this._renderOverlayButton();
this._overlayIcon.addEventListener("click", this);
this._overlayIcon.addEventListener("keypress", this);
body.insertBefore(this._overlayIcon, body.firstChild);
this._overlay = this._renderOverlay();
this._overlay.addEventListener("click", this);
this._overlay.addEventListener("keypress", this);
body.appendChild(this._overlay);
this._loadJS(TOUR_AGENT_JS_URI);
this._initPrefObserver();
this._onIconStateChange(Services.prefs.getStringPref("browser.onboarding.state", ICON_STATE_DEFAULT));
// Doing tour notification takes some effort. Let's do it on idle.
this._window.requestIdleCallback(() => this._initNotification());
}
_getTourIDList() {
let tours = Services.prefs.getStringPref(`browser.onboarding.${this._tourType}tour`, "");
return tours.split(",").filter(tourId => tourId !== "").map(tourId => tourId.trim());
}
_initNotification() {
let doc = this._window.document;
if (doc.hidden) {
// When the preloaded-browser feature is on,
// it would preload a hidden about:newtab in the background.
// 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 {
this.showNotification();
}
}
_initPrefObserver() {
if (this._prefsObserved) {
return;
}
this._prefsObserved = new Map();
this._prefsObserved.set("browser.onboarding.state", () => {
this._onIconStateChange(Services.prefs.getStringPref("browser.onboarding.state", ICON_STATE_DEFAULT));
});
this._tours.forEach(tour => {
let tourId = tour.id;
this._prefsObserved.set(`browser.onboarding.tour.${tourId}.completed`, () => {
this.markTourCompletionState(tourId);
this._checkWatermarkByTours();
});
});
for (let [name, callback] of this._prefsObserved) {
Services.prefs.addObserver(name, callback);
}
}
_checkWatermarkByTours() {
let tourDone = this._tours.every(tour => this.isTourCompleted(tour.id));
if (tourDone) {
sendMessageToChrome("set-prefs", [{
name: "browser.onboarding.state",
value: ICON_STATE_WATERMARK
}]);
}
}
_clearPrefObserver() {
if (this._prefsObserved) {
for (let [name, callback] of this._prefsObserved) {
Services.prefs.removeObserver(name, callback);
}
this._prefsObserved = null;
}
}
/**
* Find a tour that should be selected. It is either a first tour that was not
* yet complete or the first one in the tab list.
*/
get _firstUncompleteTour() {
return this._tours.find(tour => !this.isTourCompleted(tour.id)) ||
this._tours[0];
}
handleClick(target) {
let { id, classList } = target;
// Only containers receive pointer events in onboarding tour tab list,
// actual semantic tab is their first child.
if (classList.contains("onboarding-tour-item-container")) {
({ id, classList } = target.firstChild);
}
switch (id) {
case "onboarding-overlay-button":
this.showOverlay();
this.gotoPage(this._firstUncompleteTour.id);
break;
case "onboarding-skip-tour-button":
this.hideNotification();
this.hideOverlay();
this.skipTour();
break;
case "onboarding-overlay-close-btn":
// 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.hideOverlay();
break;
case "onboarding-notification-close-btn":
let tour_id = this._notificationBar.dataset.targetTourId;
this.hideNotification();
this._removeTourFromNotificationQueue(tour_id);
telemetry({
event: "notification-close-button-click",
tour_id,
session_key: this._session_key,
});
break;
case "onboarding-notification-action-btn":
let tourId = this._notificationBar.dataset.targetTourId;
this.showOverlay();
this.gotoPage(tourId);
telemetry({
event: "notification-cta-click",
tour_id: tourId,
session_key: this._session_key,
});
this._removeTourFromNotificationQueue(tourId);
break;
}
if (classList.contains("onboarding-tour-item")) {
this.gotoPage(id);
// Keep focus (not visible) on current item for potential keyboard
// navigation.
target.focus();
} else if (classList.contains("onboarding-tour-action-button")) {
let activeItem = this._tourItems.find(item => item.classList.contains("onboarding-active"));
this.setToursCompleted([ activeItem.id ]);
telemetry({
event: "overlay-cta-click",
tour_id: activeItem.id,
session_key: this._session_key,
});
}
}
/**
* Wrap keyboard focus within the dialog.
* When moving forward, focus on the first element when the current focused
* element is the last one.
* When moving backward, focus on the last element when the current focused
* element is the first one.
* Do nothing if focus is moving in the middle of the list of dialog's focusable
* elements.
*
* @param {DOMNode} current currently focused element
* @param {Boolean} back direction
* @return {DOMNode} newly focused element if any
*/
wrapMoveFocus(current, back) {
let elms = [...this._dialog.querySelectorAll(
`button, input[type="checkbox"], input[type="email"], [tabindex="0"]`)];
let next;
if (back) {
if (elms.indexOf(current) === 0) {
next = elms[elms.length - 1];
next.focus();
}
} else if (elms.indexOf(current) === elms.length - 1) {
next = elms[0];
next.focus();
}
return next;
}
handleKeypress(event) {
let { target, key, shiftKey } = event;
if (target === this._overlayIcon) {
if ([" ", "Enter"].includes(key)) {
// Remember that the dialog was opened with a keyboard.
this._overlayIcon.dataset.keyboardFocus = true;
this.handleClick(target);
event.preventDefault();
}
return;
}
// Currently focused item could be tab container if previous navigation was done
// via mouse.
if (target.classList.contains("onboarding-tour-item-container")) {
target = target.firstChild;
}
let targetIndex;
switch (key) {
case " ":
case "Enter":
// Assume that the handle function should be identical for keyboard
// activation if there is a click handler for the target.
if (target.classList.contains("onboarding-tour-item")) {
this.handleClick(target);
target.focus();
}
break;
case "ArrowUp":
// Go to and focus on the previous tab if it's available.
targetIndex = this._tourItems.indexOf(target);
if (targetIndex > 0) {
let previous = this._tourItems[targetIndex - 1];
this.handleClick(previous);
previous.focus();
}
event.preventDefault();
break;
case "ArrowDown":
// Go to and focus on the next tab if it's available.
targetIndex = this._tourItems.indexOf(target);
if (targetIndex > -1 && targetIndex < this._tourItems.length - 1) {
let next = this._tourItems[targetIndex + 1];
this.handleClick(next);
next.focus();
}
event.preventDefault();
break;
case "Escape":
this.hideOverlay();
break;
case "Tab":
let next = this.wrapMoveFocus(target, shiftKey);
// If focus was wrapped, prevent Tab key default action.
if (next) {
event.preventDefault();
}
break;
default:
break;
}
event.stopPropagation();
}
handleEvent(evt) {
switch (evt.type) {
case "resize":
this._window.cancelIdleCallback(this._resizeTimerId);
this._resizeTimerId =
this._window.requestIdleCallback(() => this._resizeUI());
break;
case "keypress":
this.handleKeypress(evt);
break;
case "click":
this.handleClick(evt.target);
break;
default:
break;
}
}
destroy() {
if (!this.uiInitialized) {
return;
}
this.uiInitialized = false;
this._overlayIcon.dispatchEvent(new this._window.CustomEvent("Agent:Destroy"));
this._clearPrefObserver();
this._overlayIcon.remove();
this._overlay.remove();
if (this._notificationBar) {
this._notificationBar.remove();
}
this._tourItems = this._tourPages =
this._overlayIcon = this._overlay = this._notificationBar = null;
telemetry({
event: "onboarding-session-end",
session_key: this._session_key,
});
}
_onIconStateChange(state) {
switch (state) {
case ICON_STATE_DEFAULT:
this._overlayIcon.classList.remove("onboarding-watermark");
break;
case ICON_STATE_WATERMARK:
this._overlayIcon.classList.add("onboarding-watermark");
break;
}
return true;
}
showOverlay() {
if (this._tourItems.length == 0) {
// Lazy loading until first toggle.
this._loadTours(this._tours);
}
this.hideNotification();
this.toggleModal(this._overlay.classList.toggle("onboarding-opened"));
telemetry({
event: "overlay-session-begin",
session_key: this._session_key
});
}
hideOverlay() {
this.toggleModal(this._overlay.classList.toggle("onboarding-opened"));
telemetry({
event: "overlay-session-end",
session_key: this._session_key,
});
}
/**
* Set modal dialog state and properties for accessibility purposes.
* @param {Boolean} opened whether the dialog is opened or closed.
*/
toggleModal(opened) {
let { document: doc } = this._window;
if (opened) {
// Set aria-hidden to true for the rest of the document.
[...doc.body.children].forEach(
child => child.id !== "onboarding-overlay" &&
child.setAttribute("aria-hidden", true));
// When dialog is opened with the keyboard, focus on the first
// uncomplete tour because it will be the selected tour.
if (this._overlayIcon.dataset.keyboardFocus) {
doc.getElementById(this._firstUncompleteTour.id).focus();
} else {
// When the dialog is opened with the mouse, focus on the dialog
// itself to avoid visible keyboard focus styling.
this._dialog.focus();
}
} else {
// Remove all set aria-hidden attributes.
[...doc.body.children].forEach(
child => child.removeAttribute("aria-hidden"));
// If dialog was opened with a keyboard, set the focus back to the overlay
// button.
if (this._overlayIcon.dataset.keyboardFocus) {
delete this._overlayIcon.dataset.keyboardFocus;
this._overlayIcon.focus();
} else {
this._window.document.activeElement.blur();
}
}
}
gotoPage(tourId) {
let targetPageId = `${tourId}-page`;
for (let page of this._tourPages) {
if (page.id === targetPageId) {
page.style.display = "";
page.dispatchEvent(new this._window.CustomEvent("beforeshow"));
} else {
page.style.display = "none";
}
}
for (let tab of this._tourItems) {
if (tab.id == tourId) {
tab.classList.add("onboarding-active");
tab.setAttribute("aria-selected", true);
telemetry({
event: "overlay-nav-click",
tour_id: tourId,
session_key: this._session_key,
});
// Some tours should complete instantly upon showing.
if (tab.getAttribute("data-instant-complete")) {
this.setToursCompleted([tourId]);
}
} else {
tab.classList.remove("onboarding-active");
tab.setAttribute("aria-selected", false);
}
}
}
isTourCompleted(tourId) {
return Services.prefs.getBoolPref(`browser.onboarding.tour.${tourId}.completed`, false);
}
setToursCompleted(tourIds) {
let params = [];
tourIds.forEach(id => {
if (!this.isTourCompleted(id)) {
params.push({
name: `browser.onboarding.tour.${id}.completed`,
value: true
});
}
});
if (params.length > 0) {
sendMessageToChrome("set-prefs", params);
}
}
markTourCompletionState(tourId) {
// We are doing lazy load so there might be no items.
if (!this._tourItems || this._tourItems.length === 0) {
return;
}
let completed = this.isTourCompleted(tourId);
let targetItem = this._tourItems.find(item => item.id == tourId);
let completedTextId = `onboarding-complete-${tourId}-text`;
// Accessibility: Text version of the auxiliary information about the tour
// item completion is provided via an invisible node with an aria-label that
// the tab is pointing to via aria-described by.
let completedText = targetItem.querySelector(`#${completedTextId}`);
if (completed) {
targetItem.classList.add("onboarding-complete");
if (!completedText) {
completedText = this._window.document.createElement("span");
completedText.id = completedTextId;
completedText.setAttribute("aria-label",
this._bundle.GetStringFromName("onboarding.complete"));
targetItem.appendChild(completedText);
targetItem.setAttribute("aria-describedby", completedTextId);
}
} else {
targetItem.classList.remove("onboarding-complete");
targetItem.removeAttribute("aria-describedby");
if (completedText) {
completedText.remove();
}
}
}
get _isFirstSession() {
// Should only directly return on the "false" case. Consider:
// 1. On the 1st session, `_firstSession` is true
// 2. During the 1st session, user resizes window so that the UI is destroyed
// 3. After the 1st mute session, user resizes window so that the UI is re-init
if (this._firstSession === false) {
return false;
}
this._firstSession = true;
// There is a queue, which means we had prompted tour notifications before. Therefore this is not the 1st session.
if (Services.prefs.prefHasUserValue("browser.onboarding.notification.tour-ids-queue")) {
this._firstSession = false;
}
// When this is set to 0 on purpose, always judge as not the 1st session
if (Services.prefs.getIntPref("browser.onboarding.notification.mute-duration-on-first-session-ms") === 0) {
this._firstSession = false;
}
return this._firstSession;
}
_getLastTourChangeTime() {
return 1000 * Services.prefs.getIntPref("browser.onboarding.notification.last-time-of-changing-tour-sec", 0);
}
_muteNotificationOnFirstSession(lastTourChangeTime) {
if (!this._isFirstSession) {
return false;
}
if (lastTourChangeTime <= 0) {
sendMessageToChrome("set-prefs", [{
name: "browser.onboarding.notification.last-time-of-changing-tour-sec",
value: Math.floor(Date.now() / 1000)
}]);
return true;
}
let muteDuration = Services.prefs.getIntPref("browser.onboarding.notification.mute-duration-on-first-session-ms");
return Date.now() - lastTourChangeTime <= muteDuration;
}
_isTimeForNextTourNotification(lastTourChangeTime) {
let promptCount = Services.prefs.getIntPref("browser.onboarding.notification.prompt-count", 0);
let maxCount = Services.prefs.getIntPref("browser.onboarding.notification.max-prompt-count-per-tour");
if (promptCount >= maxCount) {
return true;
}
let maxTime = Services.prefs.getIntPref("browser.onboarding.notification.max-life-time-per-tour-ms");
if (lastTourChangeTime && Date.now() - lastTourChangeTime >= 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
});
sendMessageToChrome("set-prefs", params);
}
_getNotificationQueue() {
let queue = "";
if (Services.prefs.prefHasUserValue("browser.onboarding.notification.tour-ids-queue")) {
queue = Services.prefs.getStringPref("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}`;
sendMessageToChrome("set-prefs", [{
name: "browser.onboarding.notification.tour-ids-queue",
value: queue
}]);
}
return queue ? queue.split(",") : [];
}
showNotification() {
if (Services.prefs.getBoolPref("browser.onboarding.notification.finished", false)) {
return;
}
let lastTime = this._getLastTourChangeTime();
if (this._muteNotificationOnFirstSession(lastTime)) {
return;
}
// After the notification mute on the 1st session,
// we don't want to show the speech bubble by default
this._overlayIcon.classList.remove("onboarding-speech-bubble");
let queue = this._getNotificationQueue();
let totalMaxTime = Services.prefs.getIntPref("browser.onboarding.notification.max-life-time-all-tours-ms");
if (lastTime && Date.now() - lastTime >= totalMaxTime) {
// Reach total max life time for all tour notifications.
// Clear the queue so that we would finish tour notifications below
queue = [];
}
let startQueueLength = queue.length;
// See if need to move on to the next tour
if (queue.length > 0 && this._isTimeForNextTourNotification(lastTime)) {
queue.shift();
}
// We don't want to prompt the completed tour.
while (queue.length > 0 && this.isTourCompleted(queue[0])) {
queue.shift();
}
if (queue.length == 0) {
sendMessageToChrome("set-prefs", [
{
name: "browser.onboarding.notification.finished",
value: true
},
{
name: "browser.onboarding.notification.tour-ids-queue",
value: ""
},
{
name: "browser.onboarding.state",
value: ICON_STATE_WATERMARK
}
]);
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._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._window.document.body.appendChild(this._notificationBar);
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 = Services.prefs.getIntPref(PROMPT_COUNT_PREF, 0);
params.push({
name: PROMPT_COUNT_PREF,
value: promptCount + 1
});
}
sendMessageToChrome("set-prefs", params);
telemetry({
event: "notification-session-begin",
session_key: this._session_key
});
}
hideNotification() {
if (this._notificationBar) {
if (this._notificationBar.classList.contains("onboarding-opened")) {
this._notificationBar.classList.remove("onboarding-opened");
telemetry({
event: "notification-session-end",
tour_id: this._notificationBar.dataset.targetTourId,
session_key: this._session_key,
});
}
}
}
_renderNotificationBar() {
let footer = this._window.document.createElement("footer");
footer.id = "onboarding-notification-bar";
footer.setAttribute("aria-live", "polite");
footer.setAttribute("aria-labelledby", "onboarding-notification-tour-title");
// We use `innerHTML` for more friendly reading.
// The security should be fine because this is not from an external input.
footer.innerHTML = `
`;
let closeBtn = footer.querySelector("#onboarding-notification-close-btn");
closeBtn.setAttribute("title",
this._bundle.GetStringFromName("onboarding.notification-close-button-tooltip"));
return footer;
}
skipTour() {
this.setToursCompleted(this._tours.map(tour => tour.id));
sendMessageToChrome("set-prefs", [
{
name: "browser.onboarding.notification.finished",
value: true
},
{
name: "browser.onboarding.state",
value: ICON_STATE_WATERMARK
}
]);
telemetry({
event: "overlay-skip-tour",
session_key: this._session_key
});
}
_renderOverlay() {
let div = this._window.document.createElement("div");
div.id = "onboarding-overlay";
// We use `innerHTML` for more friendly reading.
// The security should be fine because this is not from an external input.
div.innerHTML = `
`;
this._dialog = div.querySelector(`[role="dialog"]`);
this._dialog.id = ONBOARDING_DIALOG_ID;
div.querySelector("#onboarding-header").textContent =
this._bundle.GetStringFromName("onboarding.overlay-title2");
// support show/hide skip tour button via pref
if (!Services.prefs.getBoolPref("browser.onboarding.skip-tour-button.hide", false)) {
let footer = div.querySelector("#onboarding-footer");
let skipButton = this._window.document.createElement("button");
skipButton.id = "onboarding-skip-tour-button";
skipButton.classList.add("onboarding-action-button");
skipButton.textContent = this._bundle.GetStringFromName("onboarding.skip-tour-button-label");
footer.appendChild(skipButton);
}
let closeBtn = div.querySelector("#onboarding-overlay-close-btn");
closeBtn.setAttribute("title",
this._bundle.GetStringFromName("onboarding.overlay-close-button-tooltip"));
return div;
}
_renderOverlayButton() {
let button = this._window.document.createElement("button");
// support customize speech bubble string via pref
let tooltipStringPrefId = "";
let defaultTourStringId = "";
if (this._tourType === "new") {
tooltipStringPrefId = "browser.onboarding.newtour.tooltip";
defaultTourStringId = SPEECH_BUBBLE_NEWTOUR_STRING_ID;
} else {
tooltipStringPrefId = "browser.onboarding.updatetour.tooltip";
defaultTourStringId = SPEECH_BUBBLE_UPDATETOUR_STRING_ID;
}
let tooltip = "";
try {
let tooltipStringId = Services.prefs.getStringPref(tooltipStringPrefId, defaultTourStringId);
tooltip = this._bundle.formatStringFromName(tooltipStringId, [BRAND_SHORT_NAME], 1);
} catch (e) {
Cu.reportError(`the provided ${tooltipStringPrefId} string is in wrong format `, e);
// fallback to defaultTourStringId to proceed
tooltip = this._bundle.formatStringFromName(defaultTourStringId, [BRAND_SHORT_NAME], 1);
}
button.setAttribute("aria-label", tooltip);
button.id = "onboarding-overlay-button";
button.setAttribute("aria-haspopup", true);
button.setAttribute("aria-controls", `${ONBOARDING_DIALOG_ID}`);
let defaultImg = this._window.document.createElement("img");
defaultImg.id = "onboarding-overlay-button-icon";
defaultImg.setAttribute("role", "presentation");
defaultImg.src = Services.prefs.getStringPref("browser.onboarding.default-icon-src",
"chrome://branding/content/icon64.png");
button.appendChild(defaultImg);
let watermarkImg = this._window.document.createElement("img");
watermarkImg.id = "onboarding-overlay-button-watermark-icon";
watermarkImg.setAttribute("role", "presentation");
watermarkImg.src = Services.prefs.getStringPref("browser.onboarding.watermark-icon-src",
"resource://onboarding/img/watermark.svg");
button.appendChild(watermarkImg);
return button;
}
_loadTours(tours) {
let itemsFrag = this._window.document.createDocumentFragment();
let pagesFrag = this._window.document.createDocumentFragment();
for (let tour of tours) {
// Create tour navigation items dynamically
let li = this._window.document.createElement("li");
// List item should have no semantics. It is just a container for an
// actual tab.
li.setAttribute("role", "presentation");
li.className = "onboarding-tour-item-container";
// Focusable but not tabbable.
li.tabIndex = -1;
let tab = this._window.document.createElement("span");
tab.id = tour.id;
tab.textContent = this._bundle.GetStringFromName(tour.tourNameId);
tab.className = "onboarding-tour-item";
if (tour.instantComplete) {
tab.dataset.instantComplete = true;
}
tab.tabIndex = 0;
tab.setAttribute("role", "tab");
let tourPanelId = `${tour.id}-page`;
tab.setAttribute("aria-controls", tourPanelId);
li.appendChild(tab);
itemsFrag.appendChild(li);
// Dynamically create tour pages
let div = tour.getPage(this._window, this._bundle);
// Do a traverse for elements in the page that need to be localized.
let l10nElements = div.querySelectorAll("[data-l10n-id]");
for (let i = 0; i < l10nElements.length; i++) {
let element = l10nElements[i];
// We always put brand short name as the first argument for it's the
// only and frequently used arguments in our l10n case. Rewrite it if
// other arguments appear.
element.textContent = this._bundle.formatStringFromName(
element.dataset.l10nId, [BRAND_SHORT_NAME], 1);
}
div.id = tourPanelId;
div.classList.add("onboarding-tour-page");
div.setAttribute("role", "tabpanel");
div.setAttribute("aria-labelledby", tour.id);
div.style.display = "none";
pagesFrag.appendChild(div);
// Cache elements in arrays for later use to avoid cost of querying elements
this._tourItems.push(tab);
this._tourPages.push(div);
this.markTourCompletionState(tour.id);
}
let ul = this._window.document.getElementById("onboarding-tour-list");
ul.appendChild(itemsFrag);
let footer = this._window.document.getElementById("onboarding-footer");
this._dialog.insertBefore(pagesFrag, footer);
}
_loadCSS() {
// Returning a Promise so we can inform caller of loading complete
// by resolving it.
return new Promise(resolve => {
let doc = this._window.document;
let link = doc.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = ONBOARDING_CSS_URL;
link.addEventListener("load", resolve);
doc.head.appendChild(link);
});
}
_loadJS(uri) {
let doc = this._window.document;
let script = doc.createElement("script");
script.type = "text/javascript";
script.src = uri;
doc.head.appendChild(script);
}
}
// Load onboarding module only when we enable it.
if (Services.prefs.getBoolPref("browser.onboarding.enabled", false)) {
addEventListener("load", function onLoad(evt) {
if (!content || evt.target != content.document) {
return;
}
let window = evt.target.defaultView;
let location = window.location.href;
if (location == ABOUT_NEWTAB_URL || location == ABOUT_HOME_URL) {
// We just want to run tests as quickly as possible
// so in the automation test, we don't do `requestIdleCallback`.
if (Cu.isInAutomation) {
new Onboarding(window);
return;
}
window.requestIdleCallback(() => {
new Onboarding(window);
});
}
}, true);
}