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/. */
4"use strict";
5
6const { XPCOMUtils } = ChromeUtils.import(
7  "resource://gre/modules/XPCOMUtils.jsm"
8);
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10
11XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
12
13XPCOMUtils.defineLazyModuleGetters(this, {
14  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
15  RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm",
16});
17
18XPCOMUtils.defineLazyServiceGetter(
19  this,
20  "TrackingDBService",
21  "@mozilla.org/tracking-db-service;1",
22  "nsITrackingDBService"
23);
24XPCOMUtils.defineLazyPreferenceGetter(
25  this,
26  "milestones",
27  "browser.contentblocking.cfr-milestone.milestones",
28  "[]",
29  null,
30  JSON.parse
31);
32
33const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
34const SUMO_BASE_URL = Services.urlFormatter.formatURLPref(
35  "app.support.baseURL"
36);
37const ADDONS_API_URL =
38  "https://services.addons.mozilla.org/api/v4/addons/addon";
39
40const DELAY_BEFORE_EXPAND_MS = 1000;
41const CATEGORY_ICONS = {
42  cfrAddons: "webextensions-icon",
43  cfrFeatures: "recommendations-icon",
44  cfrHeartbeat: "highlights-icon",
45};
46
47/**
48 * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
49 * defined in the ExtensionDoorhanger.schema.json.
50 *
51 * A recommendation is specific to a browser and host and is active until the
52 * given browser is closed or the user navigates (within that browser) away from
53 * the host.
54 */
55let RecommendationMap = new WeakMap();
56
57/**
58 * A WeakMap from windows to their CFR PageAction.
59 */
60let PageActionMap = new WeakMap();
61
62/**
63 * We need one PageAction for each window
64 */
65class PageAction {
66  constructor(win, dispatchCFRAction) {
67    this.window = win;
68
69    this.urlbar = win.gURLBar; // The global URLBar object
70    this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node
71
72    this.container = win.document.getElementById(
73      "contextual-feature-recommendation"
74    );
75    this.button = win.document.getElementById("cfr-button");
76    this.label = win.document.getElementById("cfr-label");
77
78    // This should NOT be use directly to dispatch message-defined actions attached to buttons.
79    // Please use dispatchUserAction instead.
80    this._dispatchCFRAction = dispatchCFRAction;
81
82    this._popupStateChange = this._popupStateChange.bind(this);
83    this._collapse = this._collapse.bind(this);
84    this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this);
85    this._executeNotifierAction = this._executeNotifierAction.bind(this);
86    this.dispatchUserAction = this.dispatchUserAction.bind(this);
87
88    // Saved timeout IDs for scheduled state changes, so they can be cancelled
89    this.stateTransitionTimeoutIDs = [];
90
91    XPCOMUtils.defineLazyGetter(this, "isDarkTheme", () => {
92      try {
93        return this.window.document.documentElement.hasAttribute(
94          "lwt-toolbar-field-brighttext"
95        );
96      } catch (e) {
97        return false;
98      }
99    });
100  }
101
102  addImpression(recommendation) {
103    this._dispatchImpression(recommendation);
104    // Only send an impression ping upon the first expansion.
105    // Note that when the user clicks on the "show" button on the asrouter admin
106    // page (both `bucket_id` and `id` will be set as null), we don't want to send
107    // the impression ping in that case.
108    if (!!recommendation.id && !!recommendation.content.bucket_id) {
109      this._sendTelemetry({
110        message_id: recommendation.id,
111        bucket_id: recommendation.content.bucket_id,
112        event: "IMPRESSION",
113      });
114    }
115  }
116
117  reloadL10n() {
118    RemoteL10n.reloadL10n();
119  }
120
121  async showAddressBarNotifier(recommendation, shouldExpand = false) {
122    this.container.hidden = false;
123
124    let notificationText = await this.getStrings(
125      recommendation.content.notification_text
126    );
127    this.label.value = notificationText;
128    if (notificationText.attributes) {
129      this.button.setAttribute(
130        "tooltiptext",
131        notificationText.attributes.tooltiptext
132      );
133      // For a11y, we want the more descriptive text.
134      this.container.setAttribute(
135        "aria-label",
136        notificationText.attributes.tooltiptext
137      );
138    }
139    this.container.setAttribute(
140      "data-cfr-icon",
141      CATEGORY_ICONS[recommendation.content.category]
142    );
143    if (recommendation.content.active_color) {
144      this.container.style.setProperty(
145        "--cfr-active-color",
146        recommendation.content.active_color
147      );
148    }
149
150    // Wait for layout to flush to avoid a synchronous reflow then calculate the
151    // label width. We can safely get the width even though the recommendation is
152    // collapsed; the label itself remains full width (with its overflow hidden)
153    let [{ width }] = await this.window.promiseDocumentFlushed(() =>
154      this.label.getClientRects()
155    );
156    this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`);
157
158    this.container.addEventListener("click", this._cfrUrlbarButtonClick);
159    // Collapse the recommendation on url bar focus in order to free up more
160    // space to display and edit the url
161    this.urlbar.addEventListener("focus", this._collapse);
162
163    if (shouldExpand) {
164      this._clearScheduledStateChanges();
165
166      // After one second, expand
167      this._expand(DELAY_BEFORE_EXPAND_MS);
168
169      this.addImpression(recommendation);
170    }
171
172    if (notificationText.attributes) {
173      this.window.A11yUtils.announce({
174        raw: notificationText.attributes["a11y-announcement"],
175        source: this.container,
176      });
177    }
178  }
179
180  hideAddressBarNotifier() {
181    this.container.hidden = true;
182    this._clearScheduledStateChanges();
183    this.urlbarinput.removeAttribute("cfr-recommendation-state");
184    this.container.removeEventListener("click", this._cfrUrlbarButtonClick);
185    this.urlbar.removeEventListener("focus", this._collapse);
186    if (this.currentNotification) {
187      this.window.PopupNotifications.remove(this.currentNotification);
188      this.currentNotification = null;
189    }
190  }
191
192  _expand(delay) {
193    if (delay > 0) {
194      this.stateTransitionTimeoutIDs.push(
195        this.window.setTimeout(() => {
196          this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
197        }, delay)
198      );
199    } else {
200      // Non-delayed state change overrides any scheduled state changes
201      this._clearScheduledStateChanges();
202      this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
203    }
204  }
205
206  _collapse(delay) {
207    if (delay > 0) {
208      this.stateTransitionTimeoutIDs.push(
209        this.window.setTimeout(() => {
210          if (
211            this.urlbarinput.getAttribute("cfr-recommendation-state") ===
212            "expanded"
213          ) {
214            this.urlbarinput.setAttribute(
215              "cfr-recommendation-state",
216              "collapsed"
217            );
218          }
219        }, delay)
220      );
221    } else {
222      // Non-delayed state change overrides any scheduled state changes
223      this._clearScheduledStateChanges();
224      if (
225        this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded"
226      ) {
227        this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed");
228      }
229    }
230  }
231
232  _clearScheduledStateChanges() {
233    while (this.stateTransitionTimeoutIDs.length) {
234      // clearTimeout is safe even with invalid/expired IDs
235      this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
236    }
237  }
238
239  // This is called when the popup closes as a result of interaction _outside_
240  // the popup, e.g. by hitting <esc>
241  _popupStateChange(state) {
242    if (state === "shown") {
243      if (this._autoFocus) {
244        this.window.document.commandDispatcher.advanceFocusIntoSubtree(
245          this.currentNotification.owner.panel
246        );
247        this._autoFocus = false;
248      }
249    } else if (state === "removed") {
250      if (this.currentNotification) {
251        this.window.PopupNotifications.remove(this.currentNotification);
252        this.currentNotification = null;
253      }
254    } else if (state === "dismissed") {
255      this._collapse();
256    }
257  }
258
259  shouldShowDoorhanger(recommendation) {
260    if (recommendation.content.layout === "chiclet_open_url") {
261      return false;
262    }
263
264    return true;
265  }
266
267  dispatchUserAction(action) {
268    this._dispatchCFRAction(
269      { type: "USER_ACTION", data: action },
270      this.window.gBrowser.selectedBrowser
271    );
272  }
273
274  _dispatchImpression(message) {
275    this._dispatchCFRAction({ type: "IMPRESSION", data: message });
276  }
277
278  _sendTelemetry(ping) {
279    this._dispatchCFRAction({
280      type: "DOORHANGER_TELEMETRY",
281      data: { action: "cfr_user_event", source: "CFR", ...ping },
282    });
283  }
284
285  _blockMessage(messageID) {
286    this._dispatchCFRAction({
287      type: "BLOCK_MESSAGE_BY_ID",
288      data: { id: messageID },
289    });
290  }
291
292  maybeLoadCustomElement(win) {
293    if (!win.customElements.get("remote-text")) {
294      Services.scriptloader.loadSubScript(
295        "resource://activity-stream/data/custom-elements/paragraph.js",
296        win
297      );
298    }
299  }
300
301  /**
302   * getStrings - Handles getting the localized strings vs message overrides.
303   *              If string_id is not defined it assumes you passed in an override
304   *              message and it just returns it.
305   *              If subAttribute is provided, the string for it is returned.
306   * @return A string. One of 1) passed in string 2) a String object with
307   *         attributes property if there are attributes 3) the sub attribute.
308   */
309  async getStrings(string, subAttribute = "") {
310    if (!string.string_id) {
311      if (subAttribute) {
312        if (string.attributes) {
313          return string.attributes[subAttribute];
314        }
315
316        Cu.reportError(
317          `String ${string.value} does not contain any attributes`
318        );
319        return subAttribute;
320      }
321
322      if (typeof string.value === "string") {
323        const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
324        stringWithAttributes.attributes = string.attributes;
325        return stringWithAttributes;
326      }
327
328      return string;
329    }
330
331    const [localeStrings] = await RemoteL10n.l10n.formatMessages([
332      {
333        id: string.string_id,
334        args: string.args,
335      },
336    ]);
337
338    const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
339    if (localeStrings.attributes) {
340      const attributes = localeStrings.attributes.reduce((acc, attribute) => {
341        acc[attribute.name] = attribute.value;
342        return acc;
343      }, {});
344      mainString.attributes = attributes;
345    }
346
347    return subAttribute ? mainString.attributes[subAttribute] : mainString;
348  }
349
350  async _setAddonAuthorAndRating(document, content) {
351    const author = this.window.document.getElementById(
352      "cfr-notification-author"
353    );
354    const footerFilledStars = this.window.document.getElementById(
355      "cfr-notification-footer-filled-stars"
356    );
357    const footerEmptyStars = this.window.document.getElementById(
358      "cfr-notification-footer-empty-stars"
359    );
360    const footerUsers = this.window.document.getElementById(
361      "cfr-notification-footer-users"
362    );
363    const footerSpacer = this.window.document.getElementById(
364      "cfr-notification-footer-spacer"
365    );
366
367    author.textContent = await this.getStrings({
368      string_id: "cfr-doorhanger-extension-author",
369      args: { name: content.addon.author },
370    });
371
372    const { rating } = content.addon;
373    if (rating) {
374      const MAX_RATING = 5;
375      const STARS_WIDTH = 17 * MAX_RATING;
376      const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`;
377      footerFilledStars.style.width = calcWidth(rating);
378      footerEmptyStars.style.width = calcWidth(MAX_RATING - rating);
379
380      const ratingString = await this.getStrings(
381        {
382          string_id: "cfr-doorhanger-extension-rating",
383          args: { total: rating },
384        },
385        "tooltiptext"
386      );
387      footerFilledStars.setAttribute("tooltiptext", ratingString);
388      footerEmptyStars.setAttribute("tooltiptext", ratingString);
389    } else {
390      footerFilledStars.style.width = "";
391      footerEmptyStars.style.width = "";
392      footerFilledStars.removeAttribute("tooltiptext");
393      footerEmptyStars.removeAttribute("tooltiptext");
394    }
395
396    const { users } = content.addon;
397    if (users) {
398      footerUsers.setAttribute(
399        "value",
400        await this.getStrings({
401          string_id: "cfr-doorhanger-extension-total-users",
402          args: { total: users },
403        })
404      );
405      footerUsers.hidden = false;
406    } else {
407      // Prevent whitespace around empty label from affecting other spacing
408      footerUsers.hidden = true;
409      footerUsers.removeAttribute("value");
410    }
411
412    // Spacer pushes the link to the opposite end when there's other content
413
414    footerSpacer.hidden = !rating && !users;
415  }
416
417  _createElementAndAppend({ type, id }, parent) {
418    let element = this.window.document.createXULElement(type);
419    if (id) {
420      element.setAttribute("id", id);
421    }
422    parent.appendChild(element);
423    return element;
424  }
425
426  async _renderMilestonePopup(message, browser) {
427    this.maybeLoadCustomElement(this.window);
428
429    let { content, id } = message;
430    let { primary, secondary } = content.buttons;
431    let earliestDate = await TrackingDBService.getEarliestRecordedDate();
432    let timestamp = new Date().getTime(earliestDate);
433    let panelTitle = "";
434    let headerLabel = this.window.document.getElementById(
435      "cfr-notification-header-label"
436    );
437    let reachedMilestone = 0;
438    let totalSaved = await TrackingDBService.sumAllEvents();
439    for (let milestone of milestones) {
440      if (totalSaved >= milestone) {
441        reachedMilestone = milestone;
442      }
443    }
444    if (headerLabel.firstChild) {
445      headerLabel.firstChild.remove();
446    }
447    headerLabel.appendChild(
448      RemoteL10n.createElement(this.window.document, "span", {
449        content: message.content.heading_text,
450        attributes: {
451          blockedCount: reachedMilestone,
452          date: timestamp,
453        },
454      })
455    );
456
457    // Use the message layout as a CSS selector to hide different parts of the
458    // notification template markup
459    this.window.document
460      .getElementById("contextual-feature-recommendation-notification")
461      .setAttribute("data-notification-category", content.layout);
462    this.window.document
463      .getElementById("contextual-feature-recommendation-notification")
464      .setAttribute("data-notification-bucket", content.bucket_id);
465
466    let primaryBtnString = await this.getStrings(primary.label);
467    let primaryActionCallback = () => {
468      this.dispatchUserAction(primary.action);
469      this._sendTelemetry({
470        message_id: id,
471        bucket_id: content.bucket_id,
472        event: "CLICK_BUTTON",
473      });
474
475      RecommendationMap.delete(browser);
476      // Invalidate the pref after the user interacts with the button.
477      // We don't need to show the illustration in the privacy panel.
478      Services.prefs.clearUserPref(
479        "browser.contentblocking.cfr-milestone.milestone-shown-time"
480      );
481    };
482
483    let secondaryBtnString = await this.getStrings(secondary[0].label);
484    let secondaryActionsCallback = () => {
485      this.dispatchUserAction(secondary[0].action);
486      this._sendTelemetry({
487        message_id: id,
488        bucket_id: content.bucket_id,
489        event: "DISMISS",
490      });
491      RecommendationMap.delete(browser);
492    };
493
494    let mainAction = {
495      label: primaryBtnString,
496      accessKey: primaryBtnString.attributes.accesskey,
497      callback: primaryActionCallback,
498    };
499
500    let secondaryActions = [
501      {
502        label: secondaryBtnString,
503        accessKey: secondaryBtnString.attributes.accesskey,
504        callback: secondaryActionsCallback,
505      },
506    ];
507
508    // Actually show the notification
509    this.currentNotification = this.window.PopupNotifications.show(
510      browser,
511      POPUP_NOTIFICATION_ID,
512      panelTitle,
513      "cfr",
514      mainAction,
515      secondaryActions,
516      {
517        hideClose: true,
518        persistWhileVisible: true,
519      }
520    );
521    Services.prefs.setIntPref(
522      "browser.contentblocking.cfr-milestone.milestone-achieved",
523      reachedMilestone
524    );
525    Services.prefs.setStringPref(
526      "browser.contentblocking.cfr-milestone.milestone-shown-time",
527      Date.now().toString()
528    );
529  }
530
531  // eslint-disable-next-line max-statements
532  async _renderPopup(message, browser) {
533    this.maybeLoadCustomElement(this.window);
534
535    const { id, content } = message;
536
537    const headerLabel = this.window.document.getElementById(
538      "cfr-notification-header-label"
539    );
540    const headerLink = this.window.document.getElementById(
541      "cfr-notification-header-link"
542    );
543    const headerImage = this.window.document.getElementById(
544      "cfr-notification-header-image"
545    );
546    const footerText = this.window.document.getElementById(
547      "cfr-notification-footer-text"
548    );
549    const footerLink = this.window.document.getElementById(
550      "cfr-notification-footer-learn-more-link"
551    );
552    const { primary, secondary } = content.buttons;
553    let primaryActionCallback;
554    let persistent = !!content.persistent_doorhanger;
555    let options = { persistent, persistWhileVisible: persistent };
556    let panelTitle;
557
558    headerLabel.value = await this.getStrings(content.heading_text);
559    headerLink.setAttribute(
560      "href",
561      SUMO_BASE_URL + content.info_icon.sumo_path
562    );
563    headerImage.setAttribute(
564      "tooltiptext",
565      await this.getStrings(content.info_icon.label, "tooltiptext")
566    );
567    headerLink.onclick = () =>
568      this._sendTelemetry({
569        message_id: id,
570        bucket_id: content.bucket_id,
571        event: "RATIONALE",
572      });
573    // Use the message layout as a CSS selector to hide different parts of the
574    // notification template markup
575    this.window.document
576      .getElementById("contextual-feature-recommendation-notification")
577      .setAttribute("data-notification-category", content.layout);
578    this.window.document
579      .getElementById("contextual-feature-recommendation-notification")
580      .setAttribute("data-notification-bucket", content.bucket_id);
581
582    switch (content.layout) {
583      case "icon_and_message":
584        const author = this.window.document.getElementById(
585          "cfr-notification-author"
586        );
587        if (author.firstChild) {
588          author.firstChild.remove();
589        }
590        author.appendChild(
591          RemoteL10n.createElement(this.window.document, "span", {
592            content: content.text,
593          })
594        );
595        primaryActionCallback = () => {
596          this._blockMessage(id);
597          this.dispatchUserAction(primary.action);
598          this.hideAddressBarNotifier();
599          this._sendTelemetry({
600            message_id: id,
601            bucket_id: content.bucket_id,
602            event: "ENABLE",
603          });
604          RecommendationMap.delete(browser);
605        };
606
607        let getIcon = () => {
608          if (content.icon_dark_theme && this.isDarkTheme) {
609            return content.icon_dark_theme;
610          }
611          return content.icon;
612        };
613
614        let learnMoreURL = content.learn_more
615          ? SUMO_BASE_URL + content.learn_more
616          : null;
617
618        panelTitle = await this.getStrings(content.heading_text);
619        options = {
620          popupIconURL: getIcon(),
621          popupIconClass: content.icon_class,
622          learnMoreURL,
623          ...options,
624        };
625        break;
626      default:
627        panelTitle = await this.getStrings(content.addon.title);
628        await this._setAddonAuthorAndRating(this.window.document, content);
629        if (footerText.firstChild) {
630          footerText.firstChild.remove();
631        }
632        // Main body content of the dropdown
633        footerText.appendChild(
634          RemoteL10n.createElement(this.window.document, "span", {
635            content: content.text,
636          })
637        );
638        options = { popupIconURL: content.addon.icon, ...options };
639
640        footerLink.value = await this.getStrings({
641          string_id: "cfr-doorhanger-extension-learn-more-link",
642        });
643        footerLink.setAttribute("href", content.addon.amo_url);
644        footerLink.onclick = () =>
645          this._sendTelemetry({
646            message_id: id,
647            bucket_id: content.bucket_id,
648            event: "LEARN_MORE",
649          });
650
651        primaryActionCallback = async () => {
652          // eslint-disable-next-line no-use-before-define
653          primary.action.data.url = await CFRPageActions._fetchLatestAddonVersion(
654            content.addon.id
655          );
656          this._blockMessage(id);
657          this.dispatchUserAction(primary.action);
658          this.hideAddressBarNotifier();
659          this._sendTelemetry({
660            message_id: id,
661            bucket_id: content.bucket_id,
662            event: "INSTALL",
663          });
664          RecommendationMap.delete(browser);
665        };
666    }
667
668    const primaryBtnStrings = await this.getStrings(primary.label);
669    const mainAction = {
670      label: primaryBtnStrings,
671      accessKey: primaryBtnStrings.attributes.accesskey,
672      callback: primaryActionCallback,
673    };
674
675    let _renderSecondaryButtonAction = async (event, button) => {
676      let label = await this.getStrings(button.label);
677      let { attributes } = label;
678
679      return {
680        label,
681        accessKey: attributes.accesskey,
682        callback: () => {
683          if (button.action) {
684            this.dispatchUserAction(button.action);
685          } else {
686            this._blockMessage(id);
687            this.hideAddressBarNotifier();
688            RecommendationMap.delete(browser);
689          }
690
691          this._sendTelemetry({
692            message_id: id,
693            bucket_id: content.bucket_id,
694            event,
695          });
696          // We want to collapse if needed when we dismiss
697          this._collapse();
698        },
699      };
700    };
701
702    // For each secondary action, define default telemetry event
703    const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"];
704    const secondaryActions = await Promise.all(
705      secondary.map((button, i) => {
706        return _renderSecondaryButtonAction(
707          button.event || defaultSecondaryEvent[i],
708          button
709        );
710      })
711    );
712
713    // If the recommendation button is focused, it was probably activated via
714    // the keyboard. Therefore, focus the first element in the notification when
715    // it appears.
716    // We don't use the autofocus option provided by PopupNotifications.show
717    // because it doesn't focus the first element; i.e. the user still has to
718    // press tab once. That's not good enough, especially for screen reader
719    // users. Instead, we handle this ourselves in _popupStateChange.
720    this._autoFocus = this.window.document.activeElement === this.container;
721
722    // Actually show the notification
723    this.currentNotification = this.window.PopupNotifications.show(
724      browser,
725      POPUP_NOTIFICATION_ID,
726      panelTitle,
727      "cfr",
728      mainAction,
729      secondaryActions,
730      {
731        ...options,
732        hideClose: true,
733        eventCallback: this._popupStateChange,
734      }
735    );
736  }
737
738  _executeNotifierAction(browser, message) {
739    switch (message.content.layout) {
740      case "chiclet_open_url":
741        this._dispatchCFRAction(
742          {
743            type: "USER_ACTION",
744            data: {
745              type: "OPEN_URL",
746              data: {
747                args: message.content.action.url,
748                where: message.content.action.where,
749              },
750            },
751          },
752          this.window
753        );
754        break;
755    }
756
757    this._blockMessage(message.id);
758    this.hideAddressBarNotifier();
759    RecommendationMap.delete(browser);
760  }
761
762  /**
763   * Respond to a user click on the recommendation by showing a doorhanger/
764   * popup notification or running the action defined in the message
765   */
766  async _cfrUrlbarButtonClick(event) {
767    const browser = this.window.gBrowser.selectedBrowser;
768    if (!RecommendationMap.has(browser)) {
769      // There's no recommendation for this browser, so the user shouldn't have
770      // been able to click
771      this.hideAddressBarNotifier();
772      return;
773    }
774    const message = RecommendationMap.get(browser);
775    const { id, content } = message;
776
777    this._sendTelemetry({
778      message_id: id,
779      bucket_id: content.bucket_id,
780      event: "CLICK_DOORHANGER",
781    });
782
783    if (this.shouldShowDoorhanger(message)) {
784      // The recommendation should remain either collapsed or expanded while the
785      // doorhanger is showing
786      this._clearScheduledStateChanges(browser, message);
787      await this.showPopup();
788    } else {
789      await this._executeNotifierAction(browser, message);
790    }
791  }
792
793  async showPopup() {
794    const browser = this.window.gBrowser.selectedBrowser;
795    const message = RecommendationMap.get(browser);
796    const { content } = message;
797
798    // A hacky way of setting the popup anchor outside the usual url bar icon box
799    // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
800    browser.cfrpopupnotificationanchor =
801      this.window.document.getElementById(content.anchor_id) || this.container;
802
803    await this._renderPopup(message, browser);
804  }
805
806  async showMilestonePopup() {
807    const browser = this.window.gBrowser.selectedBrowser;
808    const message = RecommendationMap.get(browser);
809    const { content } = message;
810
811    // A hacky way of setting the popup anchor outside the usual url bar icon box
812    // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
813    browser.cfrpopupnotificationanchor =
814      this.window.document.getElementById(content.anchor_id) || this.container;
815
816    await this._renderMilestonePopup(message, browser);
817    return true;
818  }
819}
820
821function isHostMatch(browser, host) {
822  return (
823    browser.documentURI.scheme.startsWith("http") &&
824    browser.documentURI.host === host
825  );
826}
827
828const CFRPageActions = {
829  // For testing purposes
830  RecommendationMap,
831  PageActionMap,
832
833  /**
834   * To be called from browser.js on a location change, passing in the browser
835   * that's been updated
836   */
837  updatePageActions(browser) {
838    const win = browser.ownerGlobal;
839    const pageAction = PageActionMap.get(win);
840    if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
841      return;
842    }
843    if (RecommendationMap.has(browser)) {
844      const recommendation = RecommendationMap.get(browser);
845      if (
846        !recommendation.content.skip_address_bar_notifier &&
847        (isHostMatch(browser, recommendation.host) ||
848          // If there is no host associated we assume we're back on a tab
849          // that had a CFR message so we should show it again
850          !recommendation.host)
851      ) {
852        // The browser has a recommendation specified with this host, so show
853        // the page action
854        pageAction.showAddressBarNotifier(recommendation);
855      } else if (!recommendation.content.persistent_doorhanger) {
856        if (recommendation.retain) {
857          // Keep the recommendation first time the user navigates away just in
858          // case they will go back to the previous page
859          pageAction.hideAddressBarNotifier();
860          recommendation.retain = false;
861        } else {
862          // The user has navigated away from the specified host in the given
863          // browser, so the recommendation is no longer valid and should be removed
864          RecommendationMap.delete(browser);
865          pageAction.hideAddressBarNotifier();
866        }
867      }
868    } else {
869      // There's no recommendation specified for this browser, so hide the page action
870      pageAction.hideAddressBarNotifier();
871    }
872  },
873
874  /**
875   * Fetch the URL to the latest add-on xpi so the recommendation can download it.
876   * @param id          The add-on ID
877   * @return            A string for the URL that was fetched
878   */
879  async _fetchLatestAddonVersion(id) {
880    let url = null;
881    try {
882      const response = await fetch(`${ADDONS_API_URL}/${id}/`, {
883        credentials: "omit",
884      });
885      if (response.status !== 204 && response.ok) {
886        const json = await response.json();
887        url = json.current_version.files[0].url;
888      }
889    } catch (e) {
890      Cu.reportError(
891        "Failed to get the latest add-on version for this recommendation"
892      );
893    }
894    return url;
895  },
896
897  /**
898   * Force a recommendation to be shown. Should only happen via the Admin page.
899   * @param browser                 The browser for the recommendation
900   * @param recommendation  The recommendation to show
901   * @param dispatchCFRAction      A function to dispatch resulting actions to
902   * @return                        Did adding the recommendation succeed?
903   */
904  async forceRecommendation(browser, recommendation, dispatchCFRAction) {
905    // If we are forcing via the Admin page, the browser comes in a different format
906    const win = browser.ownerGlobal;
907    const { id, content } = recommendation;
908    RecommendationMap.set(browser, {
909      id,
910      content,
911      retain: true,
912    });
913    if (!PageActionMap.has(win)) {
914      PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
915    }
916
917    if (content.skip_address_bar_notifier) {
918      if (recommendation.template === "milestone_message") {
919        await PageActionMap.get(win).showMilestonePopup();
920        PageActionMap.get(win).addImpression(recommendation);
921      } else {
922        await PageActionMap.get(win).showPopup();
923        PageActionMap.get(win).addImpression(recommendation);
924      }
925    } else {
926      await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
927    }
928    return true;
929  },
930
931  /**
932   * Add a recommendation specific to the given browser and host.
933   * @param browser                 The browser for the recommendation
934   * @param host                    The host for the recommendation
935   * @param recommendation  The recommendation to show
936   * @param dispatchCFRAction      A function to dispatch resulting actions to
937   * @return                        Did adding the recommendation succeed?
938   */
939  async addRecommendation(browser, host, recommendation, dispatchCFRAction) {
940    const win = browser.ownerGlobal;
941    if (PrivateBrowsingUtils.isWindowPrivate(win)) {
942      return false;
943    }
944    if (
945      browser !== win.gBrowser.selectedBrowser ||
946      // We can have recommendations without URL restrictions
947      (host && !isHostMatch(browser, host))
948    ) {
949      return false;
950    }
951    if (RecommendationMap.has(browser)) {
952      // Don't replace an existing message
953      return false;
954    }
955    const { id, content } = recommendation;
956    RecommendationMap.set(browser, {
957      id,
958      host,
959      content,
960      retain: true,
961    });
962    if (!PageActionMap.has(win)) {
963      PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
964    }
965
966    if (content.skip_address_bar_notifier) {
967      if (recommendation.template === "milestone_message") {
968        await PageActionMap.get(win).showMilestonePopup();
969        PageActionMap.get(win).addImpression(recommendation);
970      } else {
971        // Tracking protection messages
972        await PageActionMap.get(win).showPopup();
973        PageActionMap.get(win).addImpression(recommendation);
974      }
975    } else {
976      // Doorhanger messages
977      await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
978    }
979    return true;
980  },
981
982  /**
983   * Clear all recommendations and hide all PageActions
984   */
985  clearRecommendations() {
986    // WeakMaps aren't iterable so we have to test all existing windows
987    for (const win of Services.wm.getEnumerator("navigator:browser")) {
988      if (win.closed || !PageActionMap.has(win)) {
989        continue;
990      }
991      PageActionMap.get(win).hideAddressBarNotifier();
992    }
993    // WeakMaps don't have a `clear` method
994    PageActionMap = new WeakMap();
995    RecommendationMap = new WeakMap();
996    this.PageActionMap = PageActionMap;
997    this.RecommendationMap = RecommendationMap;
998  },
999
1000  /**
1001   * Reload the l10n Fluent files for all PageActions
1002   */
1003  reloadL10n() {
1004    for (const win of Services.wm.getEnumerator("navigator:browser")) {
1005      if (win.closed || !PageActionMap.has(win)) {
1006        continue;
1007      }
1008      PageActionMap.get(win).reloadL10n();
1009    }
1010  },
1011};
1012
1013this.PageAction = PageAction;
1014this.CFRPageActions = CFRPageActions;
1015
1016const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"];
1017