219 lines
6.9 KiB
JavaScript
219 lines
6.9 KiB
JavaScript
/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
|
|
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.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",
|
|
});
|
|
|
|
ChromeUtils.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(),
|
|
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: "icecat",
|
|
};
|
|
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());
|
|
}
|
|
}
|