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 = ` +
+
+
+
+
+ +
+ +
+ + `; + let toolTip = this._bundle.formatStringFromName("onboarding.notification-icon-tool-tip", [BRAND_SHORT_NAME], 1); + div.querySelector("#onboarding-notification-icon").setAttribute("data-tooltip", toolTip); + return div; + } + hide() { this.sendMessageToChrome("set-prefs", [ { @@ -279,7 +453,6 @@ class Onboarding { div.id = "onboarding-overlay"; // Here we use `innerHTML` is for more friendly reading. // The security should be fine because this is not from an external input. - // We're not shipping yet so l10n strings is going to be closed for now. div.innerHTML = `
diff --git a/browser/extensions/onboarding/locales/en-US/onboarding.properties b/browser/extensions/onboarding/locales/en-US/onboarding.properties index cee8e28204ad..c7513b5c1a29 100644 --- a/browser/extensions/onboarding/locales/en-US/onboarding.properties +++ b/browser/extensions/onboarding/locales/en-US/onboarding.properties @@ -1,11 +1,11 @@ # 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/. -# LOCALIZATION NOTE(onboarding.tour-title): This string will be used in the overlay title. %S is brandShortName +# LOCALIZATION NOTE(onboarding.overlay-title): This string will be used in the overlay title. %S is brandShortName onboarding.overlay-title=Getting started with %S + onboarding.tour-search=One-Click Search onboarding.tour-search.title=Find the needle or the haystack. - # LOCALIZATION NOTE (onboarding.tour-search.description): If Amazon is not part # of the default searchplugins for your locale, you can replace it with another # ecommerce website (if you're shipping one), but not with a general purpose @@ -13,33 +13,52 @@ onboarding.tour-search.title=Find the needle or the haystack. # Wikipedia and drop Amazon from the text. onboarding.tour-search.description=Having a default search engine doesn’t mean it’s the only one you use. Pick a search engine or a site, like Amazon or Wikipedia, to search on the fly. onboarding.tour-search.button=Open One-Click Search +onboarding.notification.onboarding-tour-search.title=Find it faster. +onboarding.notification.onboarding-tour-search.message=Access all of your favorite search engines with a click. Search the whole Web or just one website right from the search box. + onboarding.tour-private-browsing=Private Browsing onboarding.tour-private-browsing.title=A little privacy goes a long way. - # LOCALIZATION NOTE(onboarding.tour-private-browsing.description): %S is brandShortName. onboarding.tour-private-browsing.description=Browse the internet without saving your searches or the sites you visited. When your session ends, the cookies disappear from %S like they were never there. onboarding.tour-private-browsing.button=Show Private Browsing in Menu -onboarding.hidden-checkbox-label=Hide the tour +onboarding.notification.onboarding-tour-private-browsing.title=Browse by yourself. +onboarding.notification.onboarding-tour-private-browsing.message=There’s no reason to share your online life with trackers every time you browse. Want to keep something to yourself? Use Private Browsing with Tracking Protection. + onboarding.tour-addons=Add-ons onboarding.tour-addons.title=Add more functionality. +onboarding.notification.onboarding-tour-addons.title=Get more done. +# LOCALIZATION NOTE(onboarding.notification.onboarding-tour-addons.message): %S is brandShortName. +onboarding.notification.onboarding-tour-addons.message=Add-ons are small apps you can add to %S that do lots of things — from managing to-do lists, to downloading videos, to changing the look of your browser. # LOCALIZATION NOTE(onboarding.tour-addons.description): This string will be used in the add-on tour description. %1$S is brandShortName onboarding.tour-addons.description=Add-ons expand %1$S’s built-in features, so %1$S works the way you do. Compare prices, check the weather or express your personality with a custom theme. onboarding.tour-addons.button=Show Add-ons in Menu onboarding.tour-customize=Customize onboarding.tour-customize.title=Do things your way. - # LOCALIZATION NOTE(onboarding.tour-customize.description): This string will be used in the customize tour description. %S is brandShortName onboarding.tour-customize.description=Drag, drop, and reorder %S’s toolbar and menu to fit your needs. You can even select a compact theme to give websites more room. onboarding.tour-customize.button=Show Customize in Menu +onboarding.notification.onboarding-tour-customize.title=Rearrange your toolbar. +# LOCALIZATION NOTE(onboarding.notification.onboarding-tour-customize.message): %S is brandShortName. +onboarding.notification.onboarding-tour-customize.message=Put the tools you use most right at your fingertips. Add more options to your toolbar. Or select a theme to make %S reflect your personality. + onboarding.tour-default-browser=Default Browser onboarding.tour-default-browser.title=We’re there for you. - # LOCALIZATION NOTE(onboarding.tour-default-browser.description): This string will be used in the default browser tour description. %1$S is brandShortName onboarding.tour-default-browser.description=Love %1$S? Set it as your default browser. Then when you open a link from another application, %1$S has you covered. - # LOCALIZATION NOTE(onboarding.tour-default-browser.button): Label for a button to open the OS default browser settings where it's not possible to set the default browser directly. (OSX, Linux, Windows 8 and higher) onboarding.tour-default-browser.button=Open Default Browser Settings - # LOCALIZATION NOTE(onboarding.tour-default-browser.win7.button): Label for a button to directly set the default browser (Windows 7). %S is brandShortName onboarding.tour-default-browser.win7.button=Make %S Your Default Browser +# LOCALIZATION NOTE(onboarding.notification.onboarding-tour-default-browser.title): %S is brandShortName. +onboarding.notification.onboarding-tour-default-browser.title=Make %S your go-to browser. +# LOCALIZATION NOTE(onboarding.notification.onboarding-tour-default-browser.message): %1$S is brandShortName +onboarding.notification.onboarding-tour-default-browser.message=It doesn’t take much to get the most from %1$S. Just set %1$S as your default browser and put control, customization, and protection on autopilot. + +onboarding.hidden-checkbox-label=Hide the tour + +#LOCALIZATION NOTE(onboarding.button.learnMore): this string is used as a button label, displayed near the message, and shared across all the onboarding notifications. +onboarding.button.learnMore=Learn More + +# LOCALIZATION NOTE(onboarding.notification-icon-tool-tip): %S is brandShortName. +onboarding.notification-icon-tool-tip=New to %S? diff --git a/browser/extensions/onboarding/test/browser/head.js b/browser/extensions/onboarding/test/browser/head.js index a7912839a847..500c9be10284 100644 --- a/browser/extensions/onboarding/test/browser/head.js +++ b/browser/extensions/onboarding/test/browser/head.js @@ -31,7 +31,7 @@ function promiseOnboardingOverlayOpened(browser) { return ContentTask.spawn(browser, {}, function() { return new Promise(resolve => { let overlay = content.document.querySelector("#onboarding-overlay"); - if (overlay.classList.contains("opened")) { + if (overlay.classList.contains("onboarding-opened")) { resolve(true); return; }