CFRPageActions.jsm 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
  6. const {DOMLocalization} = ChromeUtils.import("resource://gre/modules/DOMLocalization.jsm");
  7. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  8. XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
  9. ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
  10. "resource://gre/modules/PrivateBrowsingUtils.jsm");
  11. const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
  12. const ANIMATION_BUTTON_ID = "cfr-notification-footer-animation-button";
  13. const ANIMATION_LABEL_ID = "cfr-notification-footer-animation-label";
  14. const SUMO_BASE_URL = Services.urlFormatter.formatURLPref("app.support.baseURL");
  15. const ADDONS_API_URL = "https://services.addons.mozilla.org/api/v3/addons/addon";
  16. const ANIMATIONS_ENABLED_PREF = "toolkit.cosmeticAnimations.enabled";
  17. const DELAY_BEFORE_EXPAND_MS = 1000;
  18. const CATEGORY_ICONS = {
  19. "cfrAddons": "webextensions-icon",
  20. "cfrFeatures": "recommendations-icon",
  21. };
  22. /**
  23. * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
  24. * defined in the ExtensionDoorhanger.schema.json.
  25. *
  26. * A recommendation is specific to a browser and host and is active until the
  27. * given browser is closed or the user navigates (within that browser) away from
  28. * the host.
  29. */
  30. let RecommendationMap = new WeakMap();
  31. /**
  32. * A WeakMap from windows to their CFR PageAction.
  33. */
  34. let PageActionMap = new WeakMap();
  35. /**
  36. * We need one PageAction for each window
  37. */
  38. class PageAction {
  39. constructor(win, dispatchToASRouter) {
  40. this.window = win;
  41. this.urlbar = win.document.getElementById("urlbar");
  42. this.container = win.document.getElementById("contextual-feature-recommendation");
  43. this.button = win.document.getElementById("cfr-button");
  44. this.label = win.document.getElementById("cfr-label");
  45. // This should NOT be use directly to dispatch message-defined actions attached to buttons.
  46. // Please use dispatchUserAction instead.
  47. this._dispatchToASRouter = dispatchToASRouter;
  48. this._popupStateChange = this._popupStateChange.bind(this);
  49. this._collapse = this._collapse.bind(this);
  50. this._showPopupOnClick = this._showPopupOnClick.bind(this);
  51. this.dispatchUserAction = this.dispatchUserAction.bind(this);
  52. this._l10n = new DOMLocalization([
  53. "browser/newtab/asrouter.ftl",
  54. ]);
  55. // Saved timeout IDs for scheduled state changes, so they can be cancelled
  56. this.stateTransitionTimeoutIDs = [];
  57. }
  58. async showAddressBarNotifier(recommendation, shouldExpand = false) {
  59. this.container.hidden = false;
  60. this.label.value = await this.getStrings(recommendation.content.notification_text);
  61. this.button.setAttribute("data-cfr-icon", CATEGORY_ICONS[recommendation.content.category]);
  62. // Wait for layout to flush to avoid a synchronous reflow then calculate the
  63. // label width. We can safely get the width even though the recommendation is
  64. // collapsed; the label itself remains full width (with its overflow hidden)
  65. let [{width}] = await this.window.promiseDocumentFlushed(() => this.label.getClientRects());
  66. this.urlbar.style.setProperty("--cfr-label-width", `${width}px`);
  67. this.container.addEventListener("click", this._showPopupOnClick);
  68. // Collapse the recommendation on url bar focus in order to free up more
  69. // space to display and edit the url
  70. this.urlbar.addEventListener("focus", this._collapse);
  71. if (shouldExpand) {
  72. this._clearScheduledStateChanges();
  73. // After one second, expand
  74. this._expand(DELAY_BEFORE_EXPAND_MS);
  75. this._dispatchImpression(recommendation);
  76. // Only send an impression ping upon the first expansion.
  77. // Note that when the user clicks on the "show" button on the asrouter admin
  78. // page (both `bucket_id` and `id` will be set as null), we don't want to send
  79. // the impression ping in that case.
  80. if (!!recommendation.id && !!recommendation.content.bucket_id) {
  81. this._sendTelemetry({message_id: recommendation.id, bucket_id: recommendation.content.bucket_id, event: "IMPRESSION"});
  82. }
  83. }
  84. }
  85. hideAddressBarNotifier() {
  86. this.container.hidden = true;
  87. this._clearScheduledStateChanges();
  88. this.urlbar.removeAttribute("cfr-recommendation-state");
  89. this.container.removeEventListener("click", this._showPopupOnClick);
  90. this.urlbar.removeEventListener("focus", this._collapse);
  91. if (this.currentNotification) {
  92. this.window.PopupNotifications.remove(this.currentNotification);
  93. this.currentNotification = null;
  94. }
  95. }
  96. _expand(delay) {
  97. if (delay > 0) {
  98. this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
  99. this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
  100. }, delay));
  101. } else {
  102. // Non-delayed state change overrides any scheduled state changes
  103. this._clearScheduledStateChanges();
  104. this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
  105. }
  106. }
  107. _collapse(delay) {
  108. if (delay > 0) {
  109. this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
  110. if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
  111. this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
  112. }
  113. }, delay));
  114. } else {
  115. // Non-delayed state change overrides any scheduled state changes
  116. this._clearScheduledStateChanges();
  117. if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
  118. this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
  119. }
  120. }
  121. // TODO: FIXME: find a nicer way of cleaning this up. Maybe listening to "popuphidden"?
  122. // Remove click listener on pause button;
  123. if (this.onAnimationButtonClick) {
  124. this.window.document.getElementById(ANIMATION_BUTTON_ID).removeEventListener("click", this.onAnimationButtonClick);
  125. delete this.onAnimationButtonClick;
  126. }
  127. }
  128. _clearScheduledStateChanges() {
  129. while (this.stateTransitionTimeoutIDs.length > 0) {
  130. // clearTimeout is safe even with invalid/expired IDs
  131. this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
  132. }
  133. }
  134. // This is called when the popup closes as a result of interaction _outside_
  135. // the popup, e.g. by hitting <esc>
  136. _popupStateChange(state) {
  137. if (["dismissed", "removed"].includes(state)) {
  138. this._collapse();
  139. if (this.currentNotification) {
  140. this.window.PopupNotifications.remove(this.currentNotification);
  141. this.currentNotification = null;
  142. }
  143. }
  144. }
  145. dispatchUserAction(action) {
  146. this._dispatchToASRouter(
  147. {type: "USER_ACTION", data: action},
  148. {browser: this.window.gBrowser.selectedBrowser}
  149. );
  150. }
  151. _dispatchImpression(message) {
  152. this._dispatchToASRouter({type: "IMPRESSION", data: message});
  153. }
  154. _sendTelemetry(ping) {
  155. this._dispatchToASRouter({
  156. type: "DOORHANGER_TELEMETRY",
  157. data: {action: "cfr_user_event", source: "CFR", ...ping},
  158. });
  159. }
  160. _blockMessage(messageID) {
  161. this._dispatchToASRouter(
  162. {type: "BLOCK_MESSAGE_BY_ID", data: {id: messageID}}
  163. );
  164. }
  165. /**
  166. * getStrings - Handles getting the localized strings vs message overrides.
  167. * If string_id is not defined it assumes you passed in an override
  168. * message and it just returns it.
  169. * If subAttribute is provided, the string for it is returned.
  170. * @return A string. One of 1) passed in string 2) a String object with
  171. * attributes property if there are attributes 3) the sub attribute.
  172. */
  173. async getStrings(string, subAttribute = "") {
  174. if (!string.string_id) {
  175. if (subAttribute) {
  176. if (string.attributes) {
  177. return string.attributes[subAttribute];
  178. }
  179. Cu.reportError(`String ${string.value} does not contain any attributes`);
  180. return subAttribute;
  181. }
  182. if (typeof string.value === "string") {
  183. const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
  184. stringWithAttributes.attributes = string.attributes;
  185. return stringWithAttributes;
  186. }
  187. return string;
  188. }
  189. const [localeStrings] = await this._l10n.formatMessages([{
  190. id: string.string_id,
  191. args: string.args,
  192. }]);
  193. const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
  194. if (localeStrings.attributes) {
  195. const attributes = localeStrings.attributes.reduce((acc, attribute) => {
  196. acc[attribute.name] = attribute.value;
  197. return acc;
  198. }, {});
  199. mainString.attributes = attributes;
  200. }
  201. return subAttribute ? mainString.attributes[subAttribute] : mainString;
  202. }
  203. async _setAddonAuthorAndRating(document, content) {
  204. const author = this.window.document.getElementById("cfr-notification-author");
  205. const footerFilledStars = this.window.document.getElementById("cfr-notification-footer-filled-stars");
  206. const footerEmptyStars = this.window.document.getElementById("cfr-notification-footer-empty-stars");
  207. const footerUsers = this.window.document.getElementById("cfr-notification-footer-users");
  208. const footerSpacer = this.window.document.getElementById("cfr-notification-footer-spacer");
  209. author.textContent = await this.getStrings({
  210. string_id: "cfr-doorhanger-extension-author",
  211. args: {name: content.addon.author},
  212. });
  213. const {rating} = content.addon;
  214. if (rating) {
  215. const MAX_RATING = 5;
  216. const STARS_WIDTH = 17 * MAX_RATING;
  217. const calcWidth = stars => `${stars / MAX_RATING * STARS_WIDTH}px`;
  218. footerFilledStars.style.width = calcWidth(rating);
  219. footerEmptyStars.style.width = calcWidth(MAX_RATING - rating);
  220. const ratingString = await this.getStrings({
  221. string_id: "cfr-doorhanger-extension-rating",
  222. args: {total: rating},
  223. }, "tooltiptext");
  224. footerFilledStars.setAttribute("tooltiptext", ratingString);
  225. footerEmptyStars.setAttribute("tooltiptext", ratingString);
  226. } else {
  227. footerFilledStars.style.width = "";
  228. footerEmptyStars.style.width = "";
  229. footerFilledStars.removeAttribute("tooltiptext");
  230. footerEmptyStars.removeAttribute("tooltiptext");
  231. }
  232. const {users} = content.addon;
  233. if (users) {
  234. footerUsers.setAttribute("value", await this.getStrings({
  235. string_id: "cfr-doorhanger-extension-total-users",
  236. args: {total: users},
  237. }));
  238. footerUsers.removeAttribute("hidden");
  239. } else {
  240. // Prevent whitespace around empty label from affecting other spacing
  241. footerUsers.setAttribute("hidden", true);
  242. footerUsers.removeAttribute("value");
  243. }
  244. // Spacer pushes the link to the opposite end when there's other content
  245. if (rating || users) {
  246. footerSpacer.removeAttribute("hidden");
  247. } else {
  248. footerSpacer.setAttribute("hidden", true);
  249. }
  250. }
  251. _createElementAndAppend({type, id}, parent) {
  252. let element = this.window.document.createXULElement(type);
  253. if (id) {
  254. element.setAttribute("id", id);
  255. }
  256. parent.appendChild(element);
  257. return element;
  258. }
  259. async _renderPinTabAnimation() {
  260. const ANIMATION_CONTAINER_ID = "cfr-notification-footer-pintab-animation-container";
  261. const footer = this.window.document.getElementById("cfr-notification-footer");
  262. let animationContainer = this.window.document.getElementById(ANIMATION_CONTAINER_ID);
  263. if (!animationContainer) {
  264. animationContainer = this._createElementAndAppend({type: "vbox", id: ANIMATION_CONTAINER_ID}, footer);
  265. let controlsContainer = this._createElementAndAppend(
  266. {type: "hbox", id: "cfr-notification-footer-animation-controls"}, animationContainer);
  267. // spacer
  268. this._createElementAndAppend({type: "vbox"}, controlsContainer).setAttribute("flex", 1);
  269. let animationButton = this._createElementAndAppend({type: "hbox", id: ANIMATION_BUTTON_ID}, controlsContainer);
  270. // animation button label
  271. this._createElementAndAppend({type: "label", id: ANIMATION_LABEL_ID}, animationButton);
  272. }
  273. animationContainer.toggleAttribute("animate", Services.prefs.getBoolPref(ANIMATIONS_ENABLED_PREF, true));
  274. animationContainer.removeAttribute("paused");
  275. this.window.document.getElementById(ANIMATION_LABEL_ID).textContent = await this.getStrings(
  276. {"string_id": "cfr-doorhanger-pintab-animation-pause"});
  277. if (!this.onAnimationButtonClick) {
  278. let animationButton = this.window.document.getElementById(ANIMATION_BUTTON_ID);
  279. this.onAnimationButtonClick = async () => {
  280. let animationLabel = this.window.document.getElementById(ANIMATION_LABEL_ID);
  281. if (animationContainer.toggleAttribute("paused")) {
  282. animationLabel.textContent = await this.getStrings({"string_id": "cfr-doorhanger-pintab-animation-resume"});
  283. } else {
  284. animationLabel.textContent = await this.getStrings({"string_id": "cfr-doorhanger-pintab-animation-pause"});
  285. }
  286. };
  287. animationButton.addEventListener("click", this.onAnimationButtonClick);
  288. }
  289. }
  290. async _renderPopup(message, browser) {
  291. const {id, content} = message;
  292. const headerLabel = this.window.document.getElementById("cfr-notification-header-label");
  293. const headerLink = this.window.document.getElementById("cfr-notification-header-link");
  294. const headerImage = this.window.document.getElementById("cfr-notification-header-image");
  295. const footerText = this.window.document.getElementById("cfr-notification-footer-text");
  296. const footerLink = this.window.document.getElementById("cfr-notification-footer-learn-more-link");
  297. const {primary, secondary} = content.buttons;
  298. let primaryActionCallback;
  299. let options = {};
  300. let panelTitle;
  301. // Use the message category as a CSS selector to hide different parts of the
  302. // notification template markup
  303. this.window.document.getElementById("contextual-feature-recommendation-notification")
  304. .setAttribute("data-notification-category", message.content.category);
  305. headerLabel.value = await this.getStrings(content.heading_text);
  306. headerLink.setAttribute("href", SUMO_BASE_URL + content.info_icon.sumo_path);
  307. headerLink.setAttribute(this.window.RTL_UI ? "left" : "right", 0);
  308. headerImage.setAttribute("tooltiptext", await this.getStrings(content.info_icon.label, "tooltiptext"));
  309. headerLink.onclick = () => this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "RATIONALE"});
  310. footerText.textContent = await this.getStrings(content.text);
  311. if (content.addon) {
  312. await this._setAddonAuthorAndRating(this.window.document, content);
  313. panelTitle = await this.getStrings(content.addon.title);
  314. options = {popupIconURL: content.addon.icon};
  315. footerLink.value = await this.getStrings({string_id: "cfr-doorhanger-extension-learn-more-link"});
  316. footerLink.setAttribute("href", content.addon.amo_url);
  317. footerLink.onclick = () => this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "LEARN_MORE"});
  318. primaryActionCallback = async () => {
  319. primary.action.data.url = await CFRPageActions._fetchLatestAddonVersion(content.addon.id); // eslint-disable-line no-use-before-define
  320. this._blockMessage(id);
  321. this.dispatchUserAction(primary.action);
  322. this.hideAddressBarNotifier();
  323. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "INSTALL"});
  324. RecommendationMap.delete(browser);
  325. };
  326. } else {
  327. const stepsContainerId = "cfr-notification-feature-steps";
  328. let stepsContainer = this.window.document.getElementById(stepsContainerId);
  329. primaryActionCallback = () => {
  330. this._blockMessage(id);
  331. this.dispatchUserAction(primary.action);
  332. this.hideAddressBarNotifier();
  333. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "PIN"});
  334. RecommendationMap.delete(browser);
  335. };
  336. panelTitle = await this.getStrings(content.heading_text);
  337. if (stepsContainer) { // If it exists we need to empty it
  338. stepsContainer.remove();
  339. stepsContainer = stepsContainer.cloneNode(false);
  340. } else {
  341. stepsContainer = this.window.document.createXULElement("vbox");
  342. stepsContainer.setAttribute("id", stepsContainerId);
  343. }
  344. footerText.parentNode.appendChild(stepsContainer);
  345. for (let step of content.descriptionDetails.steps) {
  346. // This li is a generic xul element with custom styling
  347. const li = this.window.document.createXULElement("li");
  348. this._l10n.setAttributes(li, step.string_id);
  349. stepsContainer.appendChild(li);
  350. }
  351. await this._l10n.translateElements([...stepsContainer.children]);
  352. await this._renderPinTabAnimation();
  353. }
  354. const primaryBtnStrings = await this.getStrings(primary.label);
  355. const mainAction = {
  356. label: primaryBtnStrings,
  357. accessKey: primaryBtnStrings.attributes.accesskey,
  358. callback: primaryActionCallback,
  359. };
  360. // For each secondary action, get the strings and attributes
  361. const secondaryBtnStrings = [];
  362. for (let button of secondary) {
  363. let label = await this.getStrings(button.label);
  364. secondaryBtnStrings.push({label, attributes: label.attributes});
  365. }
  366. const secondaryActions = [{
  367. label: secondaryBtnStrings[0].label,
  368. accessKey: secondaryBtnStrings[0].attributes.accesskey,
  369. callback: () => {
  370. this.dispatchUserAction(secondary[0].action);
  371. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "DISMISS"});
  372. },
  373. }, {
  374. label: secondaryBtnStrings[1].label,
  375. accessKey: secondaryBtnStrings[1].attributes.accesskey,
  376. callback: () => {
  377. this._blockMessage(id);
  378. this.hideAddressBarNotifier();
  379. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "BLOCK"});
  380. RecommendationMap.delete(browser);
  381. },
  382. }, {
  383. label: secondaryBtnStrings[2].label,
  384. accessKey: secondaryBtnStrings[2].attributes.accesskey,
  385. callback: () => {
  386. this.dispatchUserAction(secondary[2].action);
  387. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "MANAGE"});
  388. },
  389. }];
  390. // Actually show the notification
  391. this.currentNotification = this.window.PopupNotifications.show(
  392. browser,
  393. POPUP_NOTIFICATION_ID,
  394. panelTitle,
  395. "cfr",
  396. mainAction,
  397. secondaryActions,
  398. {
  399. ...options,
  400. hideClose: true,
  401. eventCallback: this._popupStateChange,
  402. }
  403. );
  404. }
  405. /**
  406. * Respond to a user click on the recommendation by showing a doorhanger/
  407. * popup notification
  408. */
  409. async _showPopupOnClick(event) {
  410. const browser = this.window.gBrowser.selectedBrowser;
  411. if (!RecommendationMap.has(browser)) {
  412. // There's no recommendation for this browser, so the user shouldn't have
  413. // been able to click
  414. this.hideAddressBarNotifier();
  415. return;
  416. }
  417. const message = RecommendationMap.get(browser);
  418. const {id, content} = message;
  419. // The recommendation should remain either collapsed or expanded while the
  420. // doorhanger is showing
  421. this._clearScheduledStateChanges(browser, message);
  422. // A hacky way of setting the popup anchor outside the usual url bar icon box
  423. // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
  424. browser.cfrpopupnotificationanchor = this.container;
  425. this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "CLICK_DOORHANGER"});
  426. await this._renderPopup(message, browser);
  427. }
  428. }
  429. function isHostMatch(browser, host) {
  430. return (browser.documentURI.scheme.startsWith("http") &&
  431. browser.documentURI.host === host);
  432. }
  433. const CFRPageActions = {
  434. // For testing purposes
  435. RecommendationMap,
  436. PageActionMap,
  437. /**
  438. * To be called from browser.js on a location change, passing in the browser
  439. * that's been updated
  440. */
  441. updatePageActions(browser) {
  442. const win = browser.ownerGlobal;
  443. const pageAction = PageActionMap.get(win);
  444. if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
  445. return;
  446. }
  447. if (RecommendationMap.has(browser)) {
  448. const recommendation = RecommendationMap.get(browser);
  449. if (isHostMatch(browser, recommendation.host)) {
  450. // The browser has a recommendation specified with this host, so show
  451. // the page action
  452. pageAction.showAddressBarNotifier(recommendation);
  453. } else if (recommendation.retain) {
  454. // Keep the recommendation first time the user navigates away just in
  455. // case they will go back to the previous page
  456. pageAction.hideAddressBarNotifier();
  457. recommendation.retain = false;
  458. } else {
  459. // The user has navigated away from the specified host in the given
  460. // browser, so the recommendation is no longer valid and should be removed
  461. RecommendationMap.delete(browser);
  462. pageAction.hideAddressBarNotifier();
  463. }
  464. } else {
  465. // There's no recommendation specified for this browser, so hide the page action
  466. pageAction.hideAddressBarNotifier();
  467. }
  468. },
  469. /**
  470. * Fetch the URL to the latest add-on xpi so the recommendation can download it.
  471. * @param id The add-on ID
  472. * @return A string for the URL that was fetched
  473. */
  474. async _fetchLatestAddonVersion(id) {
  475. let url = null;
  476. try {
  477. const response = await fetch(`${ADDONS_API_URL}/${id}/`, {credentials: "omit"});
  478. if (response.status !== 204 && response.ok) {
  479. const json = await response.json();
  480. url = json.current_version.files[0].url;
  481. }
  482. } catch (e) {
  483. Cu.reportError("Failed to get the latest add-on version for this recommendation");
  484. }
  485. return url;
  486. },
  487. /**
  488. * Force a recommendation to be shown. Should only happen via the Admin page.
  489. * @param browser The browser for the recommendation
  490. * @param recommendation The recommendation to show
  491. * @param dispatchToASRouter A function to dispatch resulting actions to
  492. * @return Did adding the recommendation succeed?
  493. */
  494. async forceRecommendation(browser, recommendation, dispatchToASRouter) {
  495. // If we are forcing via the Admin page, the browser comes in a different format
  496. const win = browser.browser.ownerGlobal;
  497. const {id, content} = recommendation;
  498. RecommendationMap.set(browser.browser, {id, retain: true, content});
  499. if (!PageActionMap.has(win)) {
  500. PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
  501. }
  502. await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
  503. return true;
  504. },
  505. /**
  506. * Add a recommendation specific to the given browser and host.
  507. * @param browser The browser for the recommendation
  508. * @param host The host for the recommendation
  509. * @param recommendation The recommendation to show
  510. * @param dispatchToASRouter A function to dispatch resulting actions to
  511. * @return Did adding the recommendation succeed?
  512. */
  513. async addRecommendation(browser, host, recommendation, dispatchToASRouter) {
  514. const win = browser.ownerGlobal;
  515. if (PrivateBrowsingUtils.isWindowPrivate(win)) {
  516. return false;
  517. }
  518. if (browser !== win.gBrowser.selectedBrowser || !isHostMatch(browser, host)) {
  519. return false;
  520. }
  521. if (RecommendationMap.has(browser)) {
  522. // Don't replace an existing message
  523. return false;
  524. }
  525. const {id, content} = recommendation;
  526. RecommendationMap.set(browser, {id, host, retain: true, content});
  527. if (!PageActionMap.has(win)) {
  528. PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
  529. }
  530. await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
  531. return true;
  532. },
  533. /**
  534. * Clear all recommendations and hide all PageActions
  535. */
  536. clearRecommendations() {
  537. // WeakMaps aren't iterable so we have to test all existing windows
  538. for (const win of Services.wm.getEnumerator("navigator:browser")) {
  539. if (win.closed || !PageActionMap.has(win)) {
  540. continue;
  541. }
  542. PageActionMap.get(win).hideAddressBarNotifier();
  543. }
  544. // WeakMaps don't have a `clear` method
  545. PageActionMap = new WeakMap();
  546. RecommendationMap = new WeakMap();
  547. this.PageActionMap = PageActionMap;
  548. this.RecommendationMap = RecommendationMap;
  549. },
  550. };
  551. this.PageAction = PageAction;
  552. this.CFRPageActions = CFRPageActions;
  553. const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"];