summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/Heartbeat.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/lib/Heartbeat.sys.mjs')
-rw-r--r--toolkit/components/normandy/lib/Heartbeat.sys.mjs381
1 files changed, 381 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/Heartbeat.sys.mjs b/toolkit/components/normandy/lib/Heartbeat.sys.mjs
new file mode 100644
index 0000000000..fa66844acb
--- /dev/null
+++ b/toolkit/components/normandy/lib/Heartbeat.sys.mjs
@@ -0,0 +1,381 @@
+/* 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/. */
+
+import { Preferences } from "resource://gre/modules/Preferences.sys.mjs";
+import { TelemetryController } from "resource://gre/modules/TelemetryController.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs";
+import { EventEmitter } from "resource://normandy/lib/EventEmitter.sys.mjs";
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+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.
+ */
+export 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);
+ }
+};