diff options
Diffstat (limited to 'toolkit/components/normandy/lib/Heartbeat.jsm')
-rw-r--r-- | toolkit/components/normandy/lib/Heartbeat.jsm | 399 |
1 files changed, 399 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/Heartbeat.jsm b/toolkit/components/normandy/lib/Heartbeat.jsm new file mode 100644 index 0000000000..56f959c24e --- /dev/null +++ b/toolkit/components/normandy/lib/Heartbeat.jsm @@ -0,0 +1,399 @@ +/* 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/. */ + +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { clearTimeout, setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +const { CleanupManager } = ChromeUtils.import( + "resource://normandy/lib/CleanupManager.jsm" +); +const { EventEmitter } = ChromeUtils.import( + "resource://normandy/lib/EventEmitter.jsm" +); +const { LogManager } = ChromeUtils.import( + "resource://normandy/lib/LogManager.jsm" +); + +var EXPORTED_SYMBOLS = ["Heartbeat"]; + +const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration"; +const NOTIFICATION_TIME = 3000; +const HEARTBEAT_CSS_URI = Services.io.newURI( + "resource://normandy/skin/shared/Heartbeat.css" +); +const log = LogManager.getLogger("heartbeat"); +const windowsWithInjectedCss = new WeakSet(); +let anyWindowsWithInjectedCss = false; + +// Add cleanup handler for CSS injected into windows by Heartbeat +CleanupManager.addCleanupHandler(() => { + if (anyWindowsWithInjectedCss) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (windowsWithInjectedCss.has(window)) { + const utils = window.windowUtils; + utils.removeSheet(HEARTBEAT_CSS_URI, window.AGENT_SHEET); + windowsWithInjectedCss.delete(window); + } + } + } +}); + +/** + * Show the Heartbeat UI to request user feedback. + * + * @param chromeWindow + * The chrome window that the heartbeat notification is displayed in. + * @param {Object} options Options object. + * @param {String} options.message + * The message, or question, to display on the notification. + * @param {String} options.thanksMessage + * The thank you message to display after user votes. + * @param {String} options.flowId + * An identifier for this rating flow. Please note that this is only used to + * identify the notification box. + * @param {String} [options.engagementButtonLabel=null] + * The text of the engagement button to use instead of stars. If this is null + * or invalid, rating stars are used. + * @param {String} [options.learnMoreMessage=null] + * The label of the learn more link. No link will be shown if this is null. + * @param {String} [options.learnMoreUrl=null] + * The learn more URL to open when clicking on the learn more link. No learn more + * will be shown if this is an invalid URL. + * @param {String} [options.surveyId] + * An ID for the survey, reflected in the Telemetry ping. + * @param {Number} [options.surveyVersion] + * Survey's version number, reflected in the Telemetry ping. + * @param {boolean} [options.testing] + * Whether this is a test survey, reflected in the Telemetry ping. + * @param {String} [options.postAnswerURL=null] + * The url to visit after the user answers the question. + */ +var Heartbeat = class { + constructor(chromeWindow, options) { + if (typeof options.flowId !== "string") { + throw new Error( + `flowId must be a string, but got ${JSON.stringify( + options.flowId + )}, a ${typeof options.flowId}` + ); + } + + if (!options.flowId) { + throw new Error("flowId must not be an empty string"); + } + + if (typeof options.message !== "string") { + throw new Error( + `message must be a string, but got ${JSON.stringify( + options.message + )}, a ${typeof options.message}` + ); + } + + if (!options.message) { + throw new Error("message must not be an empty string"); + } + + if (options.postAnswerUrl) { + options.postAnswerUrl = new URL(options.postAnswerUrl); + } else { + options.postAnswerUrl = null; + } + + if (options.learnMoreUrl) { + try { + options.learnMoreUrl = new URL(options.learnMoreUrl); + } catch (e) { + options.learnMoreUrl = null; + } + } + + this.chromeWindow = chromeWindow; + this.eventEmitter = new EventEmitter(); + this.options = options; + this.surveyResults = {}; + this.buttons = []; + + if (!windowsWithInjectedCss.has(chromeWindow)) { + windowsWithInjectedCss.add(chromeWindow); + const utils = chromeWindow.windowUtils; + utils.loadSheet(HEARTBEAT_CSS_URI, chromeWindow.AGENT_SHEET); + anyWindowsWithInjectedCss = true; + } + + // so event handlers are consistent + this.handleWindowClosed = this.handleWindowClosed.bind(this); + this.close = this.close.bind(this); + + // Add Learn More Link + if (this.options.learnMoreMessage && this.options.learnMoreUrl) { + this.buttons.push({ + link: this.options.learnMoreUrl.toString(), + label: this.options.learnMoreMessage, + callback: () => { + this.maybeNotifyHeartbeat("LearnMore"); + return true; + }, + }); + } + + if (this.options.engagementButtonLabel) { + this.buttons.push({ + label: this.options.engagementButtonLabel, + callback: () => { + // Let the consumer know user engaged. + this.maybeNotifyHeartbeat("Engaged"); + + this.userEngaged({ + type: "button", + flowId: this.options.flowId, + }); + + // Return true so that the notification bar doesn't close itself since + // we have a thank you message to show. + return true; + }, + }); + } + + this.notificationBox = this.chromeWindow.gNotificationBox; + this.notice = this.notificationBox.appendNotification( + "heartbeat-" + this.options.flowId, + { + label: this.options.message, + image: "resource://normandy/skin/shared/heartbeat-icon.svg", + priority: this.notificationBox.PRIORITY_SYSTEM, + eventCallback: eventType => { + if (eventType !== "removed") { + return; + } + this.maybeNotifyHeartbeat("NotificationClosed"); + }, + }, + this.buttons + ); + this.notice.classList.add("heartbeat"); + this.notice.messageText.classList.add("heartbeat"); + + // Build the heartbeat stars + if (!this.options.engagementButtonLabel) { + const numStars = this.options.engagementButtonLabel ? 0 : 5; + this.ratingContainer = this.chromeWindow.document.createElement("span"); + this.ratingContainer.id = "star-rating-container"; + + for (let i = 0; i < numStars; i++) { + // create a star rating element + const ratingElement = this.chromeWindow.document.createXULElement( + "toolbarbutton" + ); + + // style it + const starIndex = numStars - i; + ratingElement.className = "plain star-x"; + ratingElement.id = "star" + starIndex; + ratingElement.setAttribute("data-score", starIndex); + + // Add the click handler + ratingElement.addEventListener("click", ev => { + const rating = parseInt(ev.target.getAttribute("data-score")); + this.maybeNotifyHeartbeat("Voted", { score: rating }); + this.userEngaged({ + type: "stars", + score: rating, + flowId: this.options.flowId, + }); + }); + + this.ratingContainer.appendChild(ratingElement); + } + + this.notice.buttonContainer.append(this.ratingContainer); + } + + // Let the consumer know the notification was shown. + this.maybeNotifyHeartbeat("NotificationOffered"); + this.chromeWindow.addEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + + const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000; + this.surveyEndTimer = setTimeout(() => { + this.maybeNotifyHeartbeat("SurveyExpired"); + this.close(); + }, surveyDuration); + + CleanupManager.addCleanupHandler(this.close); + } + + maybeNotifyHeartbeat(name, data = {}) { + if (this.pingSent) { + log.warn( + "Heartbeat event received after Telemetry ping sent. name:", + name, + "data:", + data + ); + return; + } + + const timestamp = Date.now(); + let sendPing = false; + let cleanup = false; + + const phases = { + NotificationOffered: () => { + this.surveyResults.flowId = this.options.flowId; + this.surveyResults.offeredTS = timestamp; + }, + LearnMore: () => { + if (!this.surveyResults.learnMoreTS) { + this.surveyResults.learnMoreTS = timestamp; + } + }, + Engaged: () => { + this.surveyResults.engagedTS = timestamp; + }, + Voted: () => { + this.surveyResults.votedTS = timestamp; + this.surveyResults.score = data.score; + }, + SurveyExpired: () => { + this.surveyResults.expiredTS = timestamp; + }, + NotificationClosed: () => { + this.surveyResults.closedTS = timestamp; + cleanup = true; + sendPing = true; + }, + WindowClosed: () => { + this.surveyResults.windowClosedTS = timestamp; + cleanup = true; + sendPing = true; + }, + default: () => { + log.error("Unrecognized Heartbeat event:", name); + }, + }; + + (phases[name] || phases.default)(); + + data.timestamp = timestamp; + data.flowId = this.options.flowId; + this.eventEmitter.emit(name, data); + + if (sendPing) { + // Send the ping to Telemetry + const payload = Object.assign({ version: 1 }, this.surveyResults); + for (const meta of ["surveyId", "surveyVersion", "testing"]) { + if (this.options.hasOwnProperty(meta)) { + payload[meta] = this.options[meta]; + } + } + + log.debug("Sending telemetry"); + TelemetryController.submitExternalPing("heartbeat", payload, { + addClientId: true, + addEnvironment: true, + }); + + // only for testing + this.eventEmitter.emit("TelemetrySent", payload); + + // Survey is complete, clear out the expiry timer & survey configuration + this.endTimerIfPresent("surveyEndTimer"); + + this.pingSent = true; + this.surveyResults = null; + } + + if (cleanup) { + this.cleanup(); + } + } + + userEngaged(engagementParams) { + // Make the heartbeat icon pulse twice + this.notice.label = this.options.thanksMessage; + this.notice.messageImage.classList.remove("pulse-onshow"); + this.notice.messageImage.classList.add("pulse-twice"); + + // Remove the custom contents of the notice and the buttons + if (this.ratingContainer) { + this.ratingContainer.remove(); + } + for (let button of this.notice.buttonContainer.querySelectorAll("button")) { + button.remove(); + } + + // Open the engagement tab if we have a valid engagement URL. + if (this.options.postAnswerUrl) { + for (const key in engagementParams) { + this.options.postAnswerUrl.searchParams.append( + key, + engagementParams[key] + ); + } + // Open the engagement URL in a new tab. + let { gBrowser } = this.chromeWindow; + gBrowser.selectedTab = gBrowser.addWebTab( + this.options.postAnswerUrl.toString(), + { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + } + ); + } + + this.endTimerIfPresent("surveyEndTimer"); + + this.engagementCloseTimer = setTimeout( + () => this.close(), + NOTIFICATION_TIME + ); + } + + endTimerIfPresent(timerName) { + if (this[timerName]) { + clearTimeout(this[timerName]); + this[timerName] = null; + } + } + + handleWindowClosed() { + this.maybeNotifyHeartbeat("WindowClosed"); + } + + close() { + this.notificationBox.removeNotification(this.notice); + } + + cleanup() { + // Kill the timers which might call things after we've cleaned up: + this.endTimerIfPresent("surveyEndTimer"); + this.endTimerIfPresent("engagementCloseTimer"); + // remove listeners + this.chromeWindow.removeEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + // remove references for garbage collection + this.chromeWindow = null; + this.notificationBox = null; + this.notice = null; + this.ratingContainer = null; + this.eventEmitter = null; + // Ensure we don't re-enter and release the CleanupManager's reference to us: + CleanupManager.removeCleanupHandler(this.close); + } +}; |