diff --git a/browser/extensions/onboarding/bootstrap.js b/browser/extensions/onboarding/bootstrap.js index faf720ca0a12..cf6a26d667fb 100644 --- a/browser/extensions/onboarding/bootstrap.js +++ b/browser/extensions/onboarding/bootstrap.js @@ -11,7 +11,8 @@ Cu.import("resource://gre/modules/Preferences.jsm"); const PREF_WHITELIST = [ "browser.onboarding.enabled", "browser.onboarding.hidden", - "browser.onboarding.notification.finished" + "browser.onboarding.notification.finished", + "browser.onboarding.notification.lastPrompted" ]; /** diff --git a/browser/extensions/onboarding/content/onboarding.css b/browser/extensions/onboarding/content/onboarding.css index 9d8c6442bb1b..e50072a39fd9 100644 --- a/browser/extensions/onboarding/content/onboarding.css +++ b/browser/extensions/onboarding/content/onboarding.css @@ -37,7 +37,8 @@ display: none; } -#onboarding-overlay-close-btn { +#onboarding-overlay-close-btn, +#onboarding-notification-close-btn { position: absolute; top: 15px; offset-inline-end: 15px; @@ -50,7 +51,8 @@ padding: 12px; } -#onboarding-overlay-close-btn:hover { +#onboarding-overlay-close-btn:hover, +#onboarding-notification-close-btn:hover { background-color: rgba(204, 204, 204, 0.6); } @@ -236,7 +238,8 @@ } #onboarding-tour-search.onboarding-active, -#onboarding-tour-search:hover { +#onboarding-tour-search:hover, +#onboarding-notification-bar[data-target-tour-id=onboarding-tour-search] #onboarding-notification-tour-icon { background-image: url("img/icons_search-colored.svg"); } @@ -245,7 +248,8 @@ } #onboarding-tour-private-browsing.onboarding-active, -#onboarding-tour-private-browsing:hover { +#onboarding-tour-private-browsing:hover, +#onboarding-notification-bar[data-target-tour-id=onboarding-tour-private-browsing] #onboarding-notification-tour-icon { background-image: url("img/icons_private-colored.svg"); } @@ -254,7 +258,8 @@ } #onboarding-tour-addons.onboarding-active, -#onboarding-tour-addons:hover { +#onboarding-tour-addons:hover, +#onboarding-notification-bar[data-target-tour-id=onboarding-tour-addons] #onboarding-notification-tour-icon { background-image: url("img/icons_addons-colored.svg"); } @@ -263,7 +268,8 @@ } #onboarding-tour-customize.onboarding-active, -#onboarding-tour-customize:hover { +#onboarding-tour-customize:hover, +#onboarding-notification-bar[data-target-tour-id=onboarding-tour-customize] #onboarding-notification-tour-icon { background-image: url("img/icons_customize-colored.svg"); } @@ -272,6 +278,105 @@ } #onboarding-tour-default-browser.onboarding-active, -#onboarding-tour-default-browser:hover { +#onboarding-tour-default-browser:hover, +#onboarding-notification-bar[data-target-tour-id=onboarding-tour-default-browser] #onboarding-notification-tour-icon { background-image: url("img/icons_default-colored.svg"); } + + +/* Tour Notifications */ +#onboarding-notification-bar { + position: fixed; + z-index: 998; /* We want this always under #onboarding-overlay */ + left: 0; + bottom: 0; + width: 100%; + height: 122px; + min-width: 1060px; + background: rgba(255, 255, 255, 0.97); + border-top: 2px solid #e9e9e9; + transition: transform 0.8s; + transform: translateY(122px); +} + +#onboarding-notification-bar.onboarding-opened { + transform: translateY(0px); +} + +#onboarding-notification-icon { + height: 36px; + background: url("img/overlay-icon.svg") no-repeat; + background-size: 36px; + background-position: 34px; + padding-inline-start: 190px; + position: absolute; + offset-block-start: 50%; + transform: translateY(-50%); +} + +#onboarding-notification-icon::after { + --height: 22px; + content: attr(data-tooltip); + background: #5ce6e6; + position: absolute; + top: 0; + offset-inline-start: 68px; + color: #10404a; + font-size: 12px; + min-height: var(--height); + line-height: var(--height); + border-radius: calc(var(--height) / 2); + border: 1px solid #fff; + padding: 0 10px; + text-align: center; +} + +#onboarding-notification-close-btn { + background-color: rgba(255, 255, 255, 0.97); + border: none; + position: absolute; + offset-block-start: 50%; + offset-inline-end: 34px; + transform: translateY(-50%); +} + +#onboarding-notification-message-section { + height: 100%; + display: flex; + align-items: center; + position: absolute; + offset-block-start: 50%; + offset-inline-start: 50%; + transform: translate(-50%, -50%); +} + +#onboarding-notification-body { + width: 420px; + margin: 0 15px; + color: #0c0c0d;; + display: inline-block; +} + +#onboarding-notification-body * { + font-size: 13px +} + +#onboarding-notification-tour-title { + margin: 0; +} + +#onboarding-notification-tour-icon { + width: 64px; + height: 64px; + background-repeat: no-repeat; +} + +#onboarding-notification-action-btn { + background: #0d96ff; + border: none; + border-radius: 3px; + padding: 10px 20px; + font-size: 14px; + color: #fff; + box-shadow: 0 1px 0 rgba(0,0,0,0.23); +} diff --git a/browser/extensions/onboarding/content/onboarding.js b/browser/extensions/onboarding/content/onboarding.js index 980be7a2e9f5..10c5daf9fe2b 100644 --- a/browser/extensions/onboarding/content/onboarding.js +++ b/browser/extensions/onboarding/content/onboarding.js @@ -27,6 +27,11 @@ const BRAND_SHORT_NAME = Services.strings * 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. @@ -39,6 +44,13 @@ var onboardingTours = [ { 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.message"), + button: bundle.GetStringFromName("onboarding.button.learnMore"), + }; + }, getPage(win) { let div = win.document.createElement("div"); div.innerHTML = ` @@ -59,6 +71,13 @@ var onboardingTours = [ { 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 = ` @@ -79,6 +98,13 @@ var onboardingTours = [ { 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 = ` @@ -99,6 +125,13 @@ var onboardingTours = [ { id: "onboarding-tour-search", tourNameId: "onboarding.tour-search", + 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 = ` @@ -119,6 +152,13 @@ var onboardingTours = [ { 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) { let div = win.document.createElement("div"); let defaultBrowserButtonId = win.matchMedia("(-moz-os-version: windows-win7)").matches ? @@ -147,7 +187,6 @@ var onboardingTours = [ class Onboarding { constructor(contentWindow) { this.init(contentWindow); - this._bundle = Services.strings.createBundle(BUNDLE_URI); } async init(contentWindow) { @@ -157,6 +196,8 @@ class Onboarding { // 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._overlayIcon = this._renderOverlayIcon(); this._overlay = this._renderOverlay(); this._window.document.body.appendChild(this._overlayIcon); @@ -172,6 +213,25 @@ class Onboarding { this._window.addEventListener("unload", () => this.destroy()); this._initPrefObserver(); + 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. + let onVisible = () => { + if (!doc.hidden) { + doc.removeEventListener("visibilitychange", onVisible); + this.showNotification(); + } + }; + doc.addEventListener("visibilitychange", onVisible); + } else { + this.showNotification(); + } } _initPrefObserver() { @@ -219,6 +279,16 @@ class Onboarding { case "onboarding-overlay": this.toggleOverlay(); break; + + case "onboarding-notification-close-btn": + this.hideNotification(); + break; + + case "onboarding-notification-action-btn": + let tourId = this._notificationBar.dataset.targetTourId; + this.toggleOverlay(); + this.gotoPage(tourId); + break; } if (evt.target.classList.contains("onboarding-tour-item")) { this.gotoPage(evt.target.id); @@ -229,6 +299,9 @@ class Onboarding { this._clearPrefObserver(); this._overlayIcon.remove(); this._overlay.remove(); + if (this._notificationBar) { + this._notificationBar.remove(); + } } toggleOverlay() { @@ -237,14 +310,13 @@ class Onboarding { this._loadTours(onboardingTours); } - this._overlay.classList.toggle("opened"); + this.hideNotification(); + this._overlay.classList.toggle("onboarding-opened"); + let hiddenCheckbox = this._window.document.getElementById("onboarding-tour-hidden-checkbox"); if (hiddenCheckbox.checked) { this.hide(); - return; } - - this._overlay.classList.toggle("onboarding-opened"); } gotoPage(tourId) { @@ -261,6 +333,108 @@ class Onboarding { } } + isTourCompleted(tourId) { + return Preferences.get(`browser.onboarding.tour.${tourId}.completed`, false); + } + + 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; + } + + // 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)); + + + if (!targetTour) { + this.sendMessageToChrome("set-prefs", [{ + name: "browser.onboarding.notification.finished", + value: true + }]); + return; + } + + // 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.addEventListener("transitionend", () => { + this._notificationBar.dataset.cssTransition = "end"; + }, { once: true }); + this._window.requestAnimationFrame(() => { + // Request the 2nd animation frame. + // This is to make sure the appending operation above and the css operation happen + // in the different layout tick so as to make sure the transition happens. + this._window.requestAnimationFrame(() => this._notificationBar.classList.add("onboarding-opened")); + }); + + this.sendMessageToChrome("set-prefs", [{ + name: "browser.onboarding.notification.lastPrompted", + value: targetTour.id + }]); + } + + hideNotification() { + if (this._notificationBar) { + this._notificationBar.classList.remove("onboarding-opened"); + delete this._notificationBar.dataset.cssTransition; + } + } + + _renderNotificationBar() { + let div = this._window.document.createElement("div"); + div.id = "onboarding-notification-bar"; + // Here we use `innerHTML` is for more friendly reading. + // The security should be fine because this is not from an external input. + div.innerHTML = ` +
+