Bug 1943650 - Command-line --help output misformatted after --dbus-service. r=emilio
[gecko.git] / toolkit / components / normandy / actions / ShowHeartbeatAction.sys.mjs
blob7e6af632665df1fa31e9cd921741a8850c3d5928
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
11   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
12   ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
13   Heartbeat: "resource://normandy/lib/Heartbeat.sys.mjs",
14   NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
15   ShellService: "resource:///modules/ShellService.sys.mjs",
16   Storage: "resource://normandy/lib/Storage.sys.mjs",
17   UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
18 });
20 ChromeUtils.defineLazyGetter(lazy, "gAllRecipeStorage", function () {
21   return new lazy.Storage("normandy-heartbeat");
22 });
24 const DAY_IN_MS = 24 * 60 * 60 * 1000;
25 const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS;
27 export class ShowHeartbeatAction extends BaseAction {
28   static Heartbeat = lazy.Heartbeat;
30   static overrideHeartbeatForTests(newHeartbeat) {
31     if (newHeartbeat) {
32       this.Heartbeat = newHeartbeat;
33     } else {
34       this.Heartbeat = lazy.Heartbeat;
35     }
36   }
38   get schema() {
39     return lazy.ActionSchemas["show-heartbeat"];
40   }
42   async _run(recipe) {
43     const {
44       message,
45       engagementButtonLabel,
46       thanksMessage,
47       learnMoreMessage,
48       learnMoreUrl,
49     } = recipe.arguments;
51     const recipeStorage = new lazy.Storage(recipe.id);
53     if (!(await this.shouldShow(recipeStorage, recipe))) {
54       return;
55     }
57     this.log.debug(
58       `Heartbeat for recipe ${recipe.id} showing prompt "${message}"`
59     );
60     const targetWindow = lazy.BrowserWindowTracker.getTopWindow();
62     if (!targetWindow) {
63       throw new Error("No window to show heartbeat in");
64     }
66     const heartbeat = new ShowHeartbeatAction.Heartbeat(targetWindow, {
67       surveyId: this.generateSurveyId(recipe),
68       message,
69       engagementButtonLabel,
70       thanksMessage,
71       learnMoreMessage,
72       learnMoreUrl,
73       postAnswerUrl: await this.generatePostAnswerURL(recipe),
74       flowId: lazy.NormandyUtils.generateUuid(),
75       surveyVersion: recipe.revision_id,
76     });
78     heartbeat.eventEmitter.once(
79       "Voted",
80       this.updateLastInteraction.bind(this, recipeStorage)
81     );
82     heartbeat.eventEmitter.once(
83       "Engaged",
84       this.updateLastInteraction.bind(this, recipeStorage)
85     );
87     let now = Date.now();
88     await Promise.all([
89       lazy.gAllRecipeStorage.setItem("lastShown", now),
90       recipeStorage.setItem("lastShown", now),
91     ]);
92   }
94   async shouldShow(recipeStorage, recipe) {
95     const { repeatOption, repeatEvery } = recipe.arguments;
96     // Don't show any heartbeats to a user more than once per throttle period
97     let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
98     if (lastShown) {
99       const duration = new Date() - lastShown;
100       if (duration < HEARTBEAT_THROTTLE) {
101         // show the number of hours since the last heartbeat, with at most 1 decimal point.
102         const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
103         this.log.debug(
104           `A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.`
105         );
106         return false;
107       }
108     }
110     switch (repeatOption) {
111       case "once": {
112         // Don't show if we've ever shown before
113         if (await recipeStorage.getItem("lastShown")) {
114           this.log.debug(
115             `Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.`
116           );
117           return false;
118         }
119         break;
120       }
122       case "nag": {
123         // Show a heartbeat again only if the user has not interacted with it before
124         if (await recipeStorage.getItem("lastInteraction")) {
125           this.log.debug(
126             `Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.`
127           );
128           return false;
129         }
130         break;
131       }
133       case "xdays": {
134         // Show this heartbeat again if it  has been at least `repeatEvery` days since the last time it was shown.
135         let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
136         if (lastShown) {
137           lastShown = new Date(lastShown);
138           const duration = new Date() - lastShown;
139           if (duration < repeatEvery * DAY_IN_MS) {
140             // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point.
141             const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
142             this.log.debug(
143               `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)`
144             );
145             return false;
146           }
147         }
148       }
149     }
151     return true;
152   }
154   /**
155    * Returns a surveyId value. If recipe calls to include the Normandy client
156    * ID, then the client ID is attached to the surveyId in the format
157    * `${surveyId}::${userId}`.
158    *
159    * @return {String} Survey ID, possibly with user UUID
160    */
161   generateSurveyId(recipe) {
162     const { includeTelemetryUUID, surveyId } = recipe.arguments;
163     if (includeTelemetryUUID) {
164       return `${surveyId}::${lazy.ClientEnvironment.userId}`;
165     }
166     return surveyId;
167   }
169   /**
170    * Generate the appropriate post-answer URL for a recipe.
171    * @param  recipe
172    * @return {String} URL with post-answer query params
173    */
174   async generatePostAnswerURL(recipe) {
175     const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
177     // Don`t bother with empty URLs.
178     if (!postAnswerUrl) {
179       return postAnswerUrl;
180     }
182     const userId = lazy.ClientEnvironment.userId;
183     const searchEngine = (await Services.search.getDefault()).identifier;
184     const args = {
185       fxVersion: Services.appinfo.version,
186       isDefaultBrowser: lazy.ShellService.isDefaultBrowser() ? 1 : 0,
187       searchEngine,
188       source: "heartbeat",
189       // `surveyversion` used to be the version of the heartbeat action when it
190       // was hosted on a server. Keeping it around for compatibility.
191       surveyversion: Services.appinfo.version,
192       syncSetup: Services.prefs.prefHasUserValue("services.sync.username")
193         ? 1
194         : 0,
195       updateChannel: lazy.UpdateUtils.getUpdateChannel(false),
196       utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")),
197       utm_medium: recipe.action,
198       utm_source: "firefox",
199     };
200     if (includeTelemetryUUID) {
201       args.userId = userId;
202     }
204     let url = new URL(postAnswerUrl);
205     // create a URL object to append arguments to
206     for (const [key, val] of Object.entries(args)) {
207       if (!url.searchParams.has(key)) {
208         url.searchParams.set(key, val);
209       }
210     }
212     // return the address with encoded queries
213     return url.toString();
214   }
216   updateLastInteraction(recipeStorage) {
217     recipeStorage.setItem("lastInteraction", Date.now());
218   }