diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs | 226 |
1 files changed, 226 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs new file mode 100644 index 0000000000..d571e46167 --- /dev/null +++ b/toolkit/components/normandy/actions/ShowHeartbeatAction.sys.mjs @@ -0,0 +1,226 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + Heartbeat: "resource://normandy/lib/Heartbeat.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + Storage: "resource://normandy/lib/Storage.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gAllRecipeStorage", function () { + return new lazy.Storage("normandy-heartbeat"); +}); + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS; + +export class ShowHeartbeatAction extends BaseAction { + static Heartbeat = lazy.Heartbeat; + + static overrideHeartbeatForTests(newHeartbeat) { + if (newHeartbeat) { + this.Heartbeat = newHeartbeat; + } else { + this.Heartbeat = lazy.Heartbeat; + } + } + + get schema() { + return lazy.ActionSchemas["show-heartbeat"]; + } + + async _run(recipe) { + const { + message, + engagementButtonLabel, + thanksMessage, + learnMoreMessage, + learnMoreUrl, + } = recipe.arguments; + + const recipeStorage = new lazy.Storage(recipe.id); + + if (!(await this.shouldShow(recipeStorage, recipe))) { + return; + } + + this.log.debug( + `Heartbeat for recipe ${recipe.id} showing prompt "${message}"` + ); + const targetWindow = lazy.BrowserWindowTracker.getTopWindow(); + + if (!targetWindow) { + throw new Error("No window to show heartbeat in"); + } + + const heartbeat = new ShowHeartbeatAction.Heartbeat(targetWindow, { + surveyId: this.generateSurveyId(recipe), + message, + engagementButtonLabel, + thanksMessage, + learnMoreMessage, + learnMoreUrl, + postAnswerUrl: await this.generatePostAnswerURL(recipe), + flowId: lazy.NormandyUtils.generateUuid(), + // Recipes coming from Nimbus won't have a revision_id. + ...(Object.hasOwn(recipe, "revision_id") + ? { surveyVersion: recipe.revision_id } + : {}), + }); + + heartbeat.eventEmitter.once( + "Voted", + this.updateLastInteraction.bind(this, recipeStorage) + ); + heartbeat.eventEmitter.once( + "Engaged", + this.updateLastInteraction.bind(this, recipeStorage) + ); + + let now = Date.now(); + await Promise.all([ + lazy.gAllRecipeStorage.setItem("lastShown", now), + recipeStorage.setItem("lastShown", now), + ]); + } + + async shouldShow(recipeStorage, recipe) { + const { repeatOption, repeatEvery } = recipe.arguments; + // Don't show any heartbeats to a user more than once per throttle period + let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown"); + if (lastShown) { + const duration = new Date() - lastShown; + if (duration < HEARTBEAT_THROTTLE) { + // show the number of hours since the last heartbeat, with at most 1 decimal point. + const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10; + this.log.debug( + `A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.` + ); + return false; + } + } + + switch (repeatOption) { + case "once": { + // Don't show if we've ever shown before + if (await recipeStorage.getItem("lastShown")) { + this.log.debug( + `Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.` + ); + return false; + } + break; + } + + case "nag": { + // Show a heartbeat again only if the user has not interacted with it before + if (await recipeStorage.getItem("lastInteraction")) { + this.log.debug( + `Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.` + ); + return false; + } + break; + } + + case "xdays": { + // Show this heartbeat again if it has been at least `repeatEvery` days since the last time it was shown. + let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown"); + if (lastShown) { + lastShown = new Date(lastShown); + const duration = new Date() - lastShown; + if (duration < repeatEvery * DAY_IN_MS) { + // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point. + const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10; + this.log.debug( + `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)` + ); + return false; + } + } + } + } + + return true; + } + + /** + * Returns a surveyId value. If recipe calls to include the Normandy client + * ID, then the client ID is attached to the surveyId in the format + * `${surveyId}::${userId}`. + * + * @return {String} Survey ID, possibly with user UUID + */ + generateSurveyId(recipe) { + const { includeTelemetryUUID, surveyId } = recipe.arguments; + if (includeTelemetryUUID) { + return `${surveyId}::${lazy.ClientEnvironment.userId}`; + } + return surveyId; + } + + /** + * Generate the appropriate post-answer URL for a recipe. + * @param recipe + * @return {String} URL with post-answer query params + */ + async generatePostAnswerURL(recipe) { + const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments; + + // Don`t bother with empty URLs. + if (!postAnswerUrl) { + return postAnswerUrl; + } + + const userId = lazy.ClientEnvironment.userId; + const searchEngine = (await Services.search.getDefault()).identifier; + const args = { + fxVersion: Services.appinfo.version, + isDefaultBrowser: lazy.ShellService.isDefaultBrowser() ? 1 : 0, + searchEngine, + source: "heartbeat", + // `surveyversion` used to be the version of the heartbeat action when it + // was hosted on a server. Keeping it around for compatibility. + surveyversion: Services.appinfo.version, + syncSetup: Services.prefs.prefHasUserValue("services.sync.username") + ? 1 + : 0, + updateChannel: lazy.UpdateUtils.getUpdateChannel(false), + utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")), + utm_medium: recipe.action, + utm_source: "firefox", + }; + if (includeTelemetryUUID) { + args.userId = userId; + } + + let url = new URL(postAnswerUrl); + // create a URL object to append arguments to + for (const [key, val] of Object.entries(args)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, val); + } + } + + // return the address with encoded queries + return url.toString(); + } + + updateLastInteraction(recipeStorage) { + recipeStorage.setItem("lastInteraction", Date.now()); + } +} |