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