/* 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"); Cu.import("resource://gre/modules/Preferences.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"; /** * 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", * // 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; }, }, "search": { id: "onboarding-tour-search", tourNameId: "onboarding.tour-search2", getNotificationStrings(bundle) { return { title: bundle.GetStringFromName("onboarding.notification.onboarding-tour-search.title"), message: bundle.GetStringFromName("onboarding.notification.onboarding-tour-search.message"), button: bundle.GetStringFromName("onboarding.button.learnMore"), }; }, getPage(win) { let div = win.document.createElement("div"); div.innerHTML = `

`; return div; }, }, "default": { id: "onboarding-tour-default-browser", 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 defaultBrowserButtonId = win.matchMedia("(-moz-os-version: windows-win7)").matches ? "onboarding.tour-default-browser.win7.button" : "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 = `

`; return div; }, }, "sync": { id: "onboarding-tour-sync", 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", 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"); // TODO: The content image is a placeholder. It should be replaced upon assets are available. // This is tracking in Bug 1382520. div.innerHTML = `

`; return div; }, }, }; /** * @param {String} action the action to ask the chrome to do * @param {Array} params the parameters for the action */ function sendMessageToChrome(action, params) { sendAsyncMessage("Onboarding:OnContentMessage", { action, params }); } /** * 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; 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 unload. 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()); } _resizeUI() { // Don't show the overlay UI before we get to a better, responsive design. if (this._window.document.body.getBoundingClientRect().width < 960) { this.destroy(); } else { this._initUI(); } } _initUI() { if (this.uiInitialized) { return; } this.uiInitialized = true; this._tourItems = []; this._tourPages = []; this._overlayIcon = this._renderOverlayButton(); this._overlayIcon.addEventListener("click", this); this._window.document.body.appendChild(this._overlayIcon); this._overlay = this._renderOverlay(); this._overlay.addEventListener("click", this); this._window.document.body.appendChild(this._overlay); this._loadJS(TOUR_AGENT_JS_URI); this._initPrefObserver(); // 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 an 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.hidden", prefValue => { if (prefValue) { this.destroy(); } }); this._tours.forEach(tour => { let tourId = tour.id; this._prefsObserved.set(`browser.onboarding.tour.${tourId}.completed`, () => { this.markTourCompletionState(tourId); }); }); for (let [name, callback] of this._prefsObserved) { Preferences.observe(name, callback); } } _clearPrefObserver() { if (this._prefsObserved) { for (let [name, callback] of this._prefsObserved) { Preferences.ignore(name, callback); } this._prefsObserved = null; } } handleEvent(evt) { if (evt.type === "resize") { this._window.cancelIdleCallback(this._resizeTimerId); this._resizeTimerId = this._window.requestIdleCallback(() => this._resizeUI()); return; } switch (evt.target.id) { case "onboarding-overlay-button": 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.toggleOverlay(); let selectedTour = this._tours.find(tour => !this.isTourCompleted(tour.id)) || this._tours[0]; this.gotoPage(selectedTour.id); 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; // These tours are tagged completed instantly upon showing. case "onboarding-tour-default-browser": case "onboarding-tour-sync": case "onboarding-tour-performance": this.setToursCompleted([ evt.target.id ]); 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 ]); } } destroy() { if (!this.uiInitialized) { return; } this.uiInitialized = false; 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; } toggleOverlay() { if (this._tourItems.length == 0) { // Lazy loading until first toggle. this._loadTours(this._tours); } this.hideNotification(); this._overlay.classList.toggle("onboarding-opened"); let hiddenCheckbox = this._window.document.getElementById("onboarding-tour-hidden-checkbox"); if (hiddenCheckbox.checked) { this.hide(); } } 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 li of this._tourItems) { if (li.id == tourId) { li.classList.add("onboarding-active"); } else { li.classList.remove("onboarding-active"); } } } isTourCompleted(tourId) { return Preferences.get(`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 && 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) { 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 }); 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}`; 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; } if (this._muteNotificationOnFirstSession()) { return; } 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(); } if (queue.length == 0) { 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._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 = Preferences.get(PROMPT_COUNT_PREF, 0); params.push({ name: PROMPT_COUNT_PREF, value: promptCount + 1 }); } sendMessageToChrome("set-prefs", params); } hideNotification() { if (this._notificationBar) { this._notificationBar.classList.remove("onboarding-opened"); } } _renderNotificationBar() { let div = this._window.document.createElement("div"); div.id = "onboarding-notification-bar"; // We use `innerHTML` for more friendly reading. // The security should be fine because this is not from an external input. div.innerHTML = `
`; let toolTip = this._bundle.formatStringFromName( this._tourType === "new" ? "onboarding.notification-icon-tool-tip" : "onboarding.notification-icon-tooltip-updated", [BRAND_SHORT_NAME], 1); div.querySelector("#onboarding-notification-icon").setAttribute("data-tooltip", toolTip); let closeBtn = div.querySelector("#onboarding-notification-close-btn"); closeBtn.setAttribute("title", this._bundle.GetStringFromName("onboarding.notification-close-button-tooltip")); return div; } hide() { this.setToursCompleted(this._tours.map(tour => tour.id)); sendMessageToChrome("set-prefs", [ { name: "browser.onboarding.hidden", value: true }, { name: "browser.onboarding.notification.finished", value: true } ]); } _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 = `
`; div.querySelector("label[for='onboarding-tour-hidden-checkbox']").textContent = this._bundle.GetStringFromName("onboarding.hidden-checkbox-label-text"); div.querySelector("#onboarding-header").textContent = this._bundle.GetStringFromName("onboarding.overlay-title2"); 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"); let tooltipStringId = this._tourType === "new" ? "onboarding.overlay-icon-tooltip" : "onboarding.overlay-icon-tooltip-updated"; let tooltip = this._bundle.formatStringFromName(tooltipStringId, [BRAND_SHORT_NAME], 1); button.setAttribute("aria-label", tooltip); button.id = "onboarding-overlay-button"; let img = this._window.document.createElement("img"); img.id = "onboarding-overlay-button-icon"; img.src = "resource://onboarding/img/overlay-icon.svg"; button.appendChild(img); 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"); li.textContent = this._bundle.GetStringFromName(tour.tourNameId); li.id = tour.id; li.className = "onboarding-tour-item"; 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 appears. element.textContent = this._bundle.formatStringFromName( element.dataset.l10nId, [BRAND_SHORT_NAME], 1); } div.id = `${tour.id}-page`; div.classList.add("onboarding-tour-page"); div.style.display = "none"; pagesFrag.appendChild(div); // Cache elements in arrays for later use to avoid cost of querying elements this._tourItems.push(li); this._tourPages.push(div); this.markTourCompletionState(tour.id); } let dialog = this._window.document.getElementById("onboarding-overlay-dialog"); let ul = this._window.document.getElementById("onboarding-tour-list"); ul.appendChild(itemsFrag); let footer = this._window.document.getElementById("onboarding-footer"); 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) && !Services.prefs.getBoolPref("browser.onboarding.hidden", false)) { addEventListener("load", function onLoad(evt) { if (!content || evt.target != content.document) { return; } removeEventListener("load", onLoad); 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 quick 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); }