2018-08-27 18:53:18 +03:00
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
|
2018-09-26 22:19:24 +03:00
|
|
|
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
2018-09-20 21:36:20 +03:00
|
|
|
ChromeUtils.import("resource://gre/modules/Localization.jsm");
|
2018-08-31 21:19:47 +03:00
|
|
|
ChromeUtils.import("resource://gre/modules/Services.jsm");
|
2018-09-26 22:19:24 +03:00
|
|
|
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
|
2018-08-31 21:19:47 +03:00
|
|
|
|
|
|
|
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
|
|
|
|
"resource://gre/modules/PrivateBrowsingUtils.jsm");
|
2018-08-30 22:06:11 +03:00
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
|
2018-08-31 21:19:47 +03:00
|
|
|
const SUMO_BASE_URL = Services.urlFormatter.formatURLPref("app.support.baseURL");
|
2018-09-26 22:19:24 +03:00
|
|
|
const ADDONS_API_URL = "https://services.addons.mozilla.org/api/v3/addons/addon";
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
const DELAY_BEFORE_EXPAND_MS = 1000;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
|
|
|
|
* defined in the ExtensionDoorhanger.schema.json.
|
|
|
|
*
|
|
|
|
* A recommendation is specific to a browser and host and is active until the
|
|
|
|
* given browser is closed or the user navigates (within that browser) away from
|
|
|
|
* the host.
|
|
|
|
*/
|
2018-08-31 21:19:47 +03:00
|
|
|
let RecommendationMap = new WeakMap();
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A WeakMap from windows to their CFR PageAction.
|
|
|
|
*/
|
2018-08-31 21:19:47 +03:00
|
|
|
let PageActionMap = new WeakMap();
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* We need one PageAction for each window
|
|
|
|
*/
|
|
|
|
class PageAction {
|
|
|
|
constructor(win, dispatchToASRouter) {
|
|
|
|
this.window = win;
|
|
|
|
this.urlbar = win.document.getElementById("urlbar");
|
|
|
|
this.container = win.document.getElementById("contextual-feature-recommendation");
|
|
|
|
this.button = win.document.getElementById("cfr-button");
|
|
|
|
this.label = win.document.getElementById("cfr-label");
|
|
|
|
|
2018-08-29 17:08:31 +03:00
|
|
|
// This should NOT be use directly to dispatch message-defined actions attached to buttons.
|
|
|
|
// Please use dispatchUserAction instead.
|
2018-08-27 18:53:18 +03:00
|
|
|
this._dispatchToASRouter = dispatchToASRouter;
|
2018-08-29 17:08:31 +03:00
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
this._popupStateChange = this._popupStateChange.bind(this);
|
|
|
|
this._collapse = this._collapse.bind(this);
|
|
|
|
this._handleClick = this._handleClick.bind(this);
|
2018-08-29 17:08:31 +03:00
|
|
|
this.dispatchUserAction = this.dispatchUserAction.bind(this);
|
2018-08-27 18:53:18 +03:00
|
|
|
|
2018-08-30 22:06:11 +03:00
|
|
|
this._l10n = new Localization([
|
2018-09-20 21:36:20 +03:00
|
|
|
"browser/newtab/asrouter.ftl",
|
2018-08-30 22:06:11 +03:00
|
|
|
]);
|
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
// Saved timeout IDs for scheduled state changes, so they can be cancelled
|
|
|
|
this.stateTransitionTimeoutIDs = [];
|
2018-08-31 21:19:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
async show(recommendation, shouldExpand = false) {
|
2018-08-27 18:53:18 +03:00
|
|
|
this.container.hidden = false;
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
this.label.value = await this.getStrings(recommendation.content.notification_text);
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
// Wait for layout to flush to avoid a synchronous reflow then calculate the
|
|
|
|
// label width. We can safely get the width even though the recommendation is
|
|
|
|
// collapsed; the label itself remains full width (with its overflow hidden)
|
2018-12-19 04:52:03 +03:00
|
|
|
let [{width}] = await this.window.promiseDocumentFlushed(() => this.label.getClientRects());
|
2018-08-27 18:53:18 +03:00
|
|
|
this.urlbar.style.setProperty("--cfr-label-width", `${width}px`);
|
|
|
|
|
2018-09-20 21:36:20 +03:00
|
|
|
this.container.addEventListener("click", this._handleClick);
|
|
|
|
// Collapse the recommendation on url bar focus in order to free up more
|
|
|
|
// space to display and edit the url
|
|
|
|
this.urlbar.addEventListener("focus", this._collapse);
|
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
if (shouldExpand) {
|
|
|
|
this._clearScheduledStateChanges();
|
|
|
|
|
|
|
|
// After one second, expand
|
|
|
|
this._expand(DELAY_BEFORE_EXPAND_MS);
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
this._dispatchImpression(recommendation);
|
|
|
|
// Only send an impression ping upon the first expansion.
|
|
|
|
// Note that when the user clicks on the "show" button on the asrouter admin
|
|
|
|
// page (both `bucket_id` and `id` will be set as null), we don't want to send
|
|
|
|
// the impression ping in that case.
|
|
|
|
if (!!recommendation.id && !!recommendation.content.bucket_id) {
|
|
|
|
this._sendTelemetry({message_id: recommendation.id, bucket_id: recommendation.content.bucket_id, event: "IMPRESSION"});
|
|
|
|
}
|
2018-08-27 18:53:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
hide() {
|
|
|
|
this.container.hidden = true;
|
|
|
|
this._clearScheduledStateChanges();
|
|
|
|
this.urlbar.removeAttribute("cfr-recommendation-state");
|
2018-09-20 21:36:20 +03:00
|
|
|
this.container.removeEventListener("click", this._handleClick);
|
|
|
|
this.urlbar.removeEventListener("focus", this._collapse);
|
|
|
|
if (this.currentNotification) {
|
|
|
|
this.window.PopupNotifications.remove(this.currentNotification);
|
|
|
|
this.currentNotification = null;
|
|
|
|
}
|
2018-09-20 02:42:00 +03:00
|
|
|
}
|
|
|
|
|
2018-09-20 21:36:20 +03:00
|
|
|
_expand(delay) {
|
|
|
|
if (delay > 0) {
|
2018-09-20 02:42:00 +03:00
|
|
|
this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
|
|
|
|
this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
|
|
|
|
}, delay));
|
2018-09-20 21:36:20 +03:00
|
|
|
} else {
|
|
|
|
// Non-delayed state change overrides any scheduled state changes
|
|
|
|
this._clearScheduledStateChanges();
|
|
|
|
this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
|
2018-08-27 18:53:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-20 21:36:20 +03:00
|
|
|
_collapse(delay) {
|
|
|
|
if (delay > 0) {
|
2018-09-20 02:42:00 +03:00
|
|
|
this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
|
|
|
|
if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
|
|
|
|
this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
|
|
|
|
}
|
|
|
|
}, delay));
|
2018-09-20 21:36:20 +03:00
|
|
|
} else {
|
|
|
|
// Non-delayed state change overrides any scheduled state changes
|
|
|
|
this._clearScheduledStateChanges();
|
|
|
|
if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
|
|
|
|
this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
|
|
|
|
}
|
2018-08-27 18:53:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_clearScheduledStateChanges() {
|
|
|
|
while (this.stateTransitionTimeoutIDs.length > 0) {
|
|
|
|
// clearTimeout is safe even with invalid/expired IDs
|
|
|
|
this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is called when the popup closes as a result of interaction _outside_
|
|
|
|
// the popup, e.g. by hitting <esc>
|
|
|
|
_popupStateChange(state) {
|
|
|
|
if (["dismissed", "removed"].includes(state)) {
|
|
|
|
this._collapse();
|
2018-09-20 21:36:20 +03:00
|
|
|
if (this.currentNotification) {
|
|
|
|
this.window.PopupNotifications.remove(this.currentNotification);
|
|
|
|
this.currentNotification = null;
|
|
|
|
}
|
2018-08-27 18:53:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-20 21:36:20 +03:00
|
|
|
dispatchUserAction(action) {
|
|
|
|
this._dispatchToASRouter(
|
|
|
|
{type: "USER_ACTION", data: action},
|
|
|
|
{browser: this.window.gBrowser.selectedBrowser}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
_dispatchImpression(message) {
|
|
|
|
this._dispatchToASRouter({type: "IMPRESSION", data: message});
|
|
|
|
}
|
|
|
|
|
|
|
|
_sendTelemetry(ping) {
|
|
|
|
this._dispatchToASRouter({
|
|
|
|
type: "DOORHANGER_TELEMETRY",
|
|
|
|
data: {action: "cfr_user_event", source: "CFR", ...ping},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
_blockMessage(messageID) {
|
|
|
|
this._dispatchToASRouter(
|
|
|
|
{type: "BLOCK_MESSAGE_BY_ID", data: {id: messageID}}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2018-08-30 22:06:11 +03:00
|
|
|
/**
|
|
|
|
* getStrings - Handles getting the localized strings vs message overrides.
|
|
|
|
* If string_id is not defined it assumes you passed in an override
|
|
|
|
* message and it just returns it.
|
2018-08-31 21:19:47 +03:00
|
|
|
* If subAttribute is provided, the string for it is returned.
|
|
|
|
* @return A string. One of 1) passed in string 2) a String object with
|
|
|
|
* attributes property if there are attributes 3) the sub attribute.
|
2018-08-30 22:06:11 +03:00
|
|
|
*/
|
2018-08-31 21:19:47 +03:00
|
|
|
async getStrings(string, subAttribute = "") {
|
2018-08-30 22:06:11 +03:00
|
|
|
if (!string.string_id) {
|
2018-09-20 21:36:20 +03:00
|
|
|
if (subAttribute) {
|
|
|
|
if (string.attributes) {
|
|
|
|
return string.attributes[subAttribute];
|
|
|
|
}
|
|
|
|
|
|
|
|
Cu.reportError(`String ${string.value} does not contain any attributes`);
|
|
|
|
return subAttribute;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (typeof string.value === "string") {
|
|
|
|
const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
|
|
|
|
stringWithAttributes.attributes = string.attributes;
|
|
|
|
return stringWithAttributes;
|
|
|
|
}
|
|
|
|
|
2018-08-30 22:06:11 +03:00
|
|
|
return string;
|
|
|
|
}
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
const [localeStrings] = await this._l10n.formatMessages([{
|
|
|
|
id: string.string_id,
|
2018-09-20 21:36:20 +03:00
|
|
|
args: string.args,
|
2018-08-31 21:19:47 +03:00
|
|
|
}]);
|
2018-08-30 22:06:11 +03:00
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
|
|
|
|
if (localeStrings.attributes) {
|
2018-08-30 22:06:11 +03:00
|
|
|
const attributes = localeStrings.attributes.reduce((acc, attribute) => {
|
|
|
|
acc[attribute.name] = attribute.value;
|
|
|
|
return acc;
|
|
|
|
}, {});
|
2018-08-31 21:19:47 +03:00
|
|
|
mainString.attributes = attributes;
|
2018-08-30 22:06:11 +03:00
|
|
|
}
|
2018-08-31 21:19:47 +03:00
|
|
|
|
|
|
|
return subAttribute ? mainString.attributes[subAttribute] : mainString;
|
2018-08-30 22:06:11 +03:00
|
|
|
}
|
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
/**
|
|
|
|
* Respond to a user click on the recommendation by showing a doorhanger/
|
|
|
|
* popup notification
|
|
|
|
*/
|
2018-08-31 21:19:47 +03:00
|
|
|
async _handleClick(event) { // eslint-disable-line max-statements
|
2018-08-27 18:53:18 +03:00
|
|
|
const browser = this.window.gBrowser.selectedBrowser;
|
|
|
|
if (!RecommendationMap.has(browser)) {
|
|
|
|
// There's no recommendation for this browser, so the user shouldn't have
|
|
|
|
// been able to click
|
|
|
|
this.hide();
|
|
|
|
return;
|
|
|
|
}
|
2018-08-31 21:19:47 +03:00
|
|
|
const {id, content} = RecommendationMap.get(browser);
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
// The recommendation should remain either collapsed or expanded while the
|
|
|
|
// doorhanger is showing
|
|
|
|
this._clearScheduledStateChanges();
|
|
|
|
|
|
|
|
// A hacky way of setting the popup anchor outside the usual url bar icon box
|
|
|
|
// See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
|
|
|
|
browser.cfrpopupnotificationanchor = this.container;
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
const headerLabel = this.window.document.getElementById("cfr-notification-header-label");
|
|
|
|
const headerLink = this.window.document.getElementById("cfr-notification-header-link");
|
|
|
|
const headerImage = this.window.document.getElementById("cfr-notification-header-image");
|
|
|
|
const author = this.window.document.getElementById("cfr-notification-author");
|
|
|
|
const footerText = this.window.document.getElementById("cfr-notification-footer-text");
|
|
|
|
const footerFilledStars = this.window.document.getElementById("cfr-notification-footer-filled-stars");
|
|
|
|
const footerEmptyStars = this.window.document.getElementById("cfr-notification-footer-empty-stars");
|
|
|
|
const footerUsers = this.window.document.getElementById("cfr-notification-footer-users");
|
|
|
|
const footerSpacer = this.window.document.getElementById("cfr-notification-footer-spacer");
|
|
|
|
const footerLink = this.window.document.getElementById("cfr-notification-footer-learn-more-link");
|
|
|
|
|
|
|
|
headerLabel.value = await this.getStrings(content.heading_text);
|
|
|
|
headerLink.setAttribute("href", SUMO_BASE_URL + content.info_icon.sumo_path);
|
2018-12-11 17:18:21 +03:00
|
|
|
headerLink.setAttribute(this.window.RTL_UI ? "left" : "right", 0);
|
2018-08-31 21:19:47 +03:00
|
|
|
headerImage.setAttribute("tooltiptext", await this.getStrings(content.info_icon.label, "tooltiptext"));
|
|
|
|
headerLink.onclick = () => this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "RATIONALE"});
|
|
|
|
|
|
|
|
author.textContent = await this.getStrings({
|
|
|
|
string_id: "cfr-doorhanger-extension-author",
|
2018-09-20 21:36:20 +03:00
|
|
|
args: {name: content.addon.author},
|
2018-08-31 21:19:47 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
footerText.textContent = await this.getStrings(content.text);
|
|
|
|
|
|
|
|
const {rating} = content.addon;
|
|
|
|
if (rating) {
|
|
|
|
const MAX_RATING = 5;
|
|
|
|
const STARS_WIDTH = 17 * MAX_RATING;
|
|
|
|
const calcWidth = stars => `${stars / MAX_RATING * STARS_WIDTH}px`;
|
|
|
|
footerFilledStars.style.width = calcWidth(rating);
|
|
|
|
footerEmptyStars.style.width = calcWidth(MAX_RATING - rating);
|
|
|
|
|
|
|
|
const ratingString = await this.getStrings({
|
|
|
|
string_id: "cfr-doorhanger-extension-rating",
|
2018-09-20 21:36:20 +03:00
|
|
|
args: {total: rating},
|
2018-08-31 21:19:47 +03:00
|
|
|
}, "tooltiptext");
|
|
|
|
footerFilledStars.setAttribute("tooltiptext", ratingString);
|
|
|
|
footerEmptyStars.setAttribute("tooltiptext", ratingString);
|
|
|
|
} else {
|
|
|
|
footerFilledStars.style.width = "";
|
|
|
|
footerEmptyStars.style.width = "";
|
|
|
|
footerFilledStars.removeAttribute("tooltiptext");
|
|
|
|
footerEmptyStars.removeAttribute("tooltiptext");
|
|
|
|
}
|
|
|
|
|
|
|
|
const {users} = content.addon;
|
|
|
|
if (users) {
|
|
|
|
footerUsers.setAttribute("value", await this.getStrings({
|
|
|
|
string_id: "cfr-doorhanger-extension-total-users",
|
2018-09-20 21:36:20 +03:00
|
|
|
args: {total: users},
|
2018-08-31 21:19:47 +03:00
|
|
|
}));
|
|
|
|
footerUsers.removeAttribute("hidden");
|
|
|
|
} else {
|
|
|
|
// Prevent whitespace around empty label from affecting other spacing
|
|
|
|
footerUsers.setAttribute("hidden", true);
|
|
|
|
footerUsers.removeAttribute("value");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Spacer pushes the link to the opposite end when there's other content
|
|
|
|
if (rating || users) {
|
|
|
|
footerSpacer.removeAttribute("hidden");
|
|
|
|
} else {
|
|
|
|
footerSpacer.setAttribute("hidden", true);
|
|
|
|
}
|
|
|
|
|
|
|
|
footerLink.value = await this.getStrings({string_id: "cfr-doorhanger-extension-learn-more-link"});
|
|
|
|
footerLink.setAttribute("href", content.addon.amo_url);
|
|
|
|
footerLink.onclick = () => this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "LEARN_MORE"});
|
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
const {primary, secondary} = content.buttons;
|
2018-08-31 21:19:47 +03:00
|
|
|
const primaryBtnStrings = await this.getStrings(primary.label);
|
2018-10-20 15:38:08 +03:00
|
|
|
|
|
|
|
// For each secondary action, get the strings and attributes
|
|
|
|
const secondaryBtnStrings = [];
|
|
|
|
for (let button of secondary) {
|
|
|
|
let label = await this.getStrings(button.label);
|
|
|
|
secondaryBtnStrings.push({label, attributes: label.attributes});
|
|
|
|
}
|
2018-08-27 18:53:18 +03:00
|
|
|
|
|
|
|
const mainAction = {
|
2018-08-31 21:19:47 +03:00
|
|
|
label: primaryBtnStrings,
|
2018-08-30 22:06:11 +03:00
|
|
|
accessKey: primaryBtnStrings.attributes.accesskey,
|
2018-08-31 21:19:47 +03:00
|
|
|
callback: () => {
|
|
|
|
this._blockMessage(id);
|
|
|
|
this.dispatchUserAction(primary.action);
|
|
|
|
this.hide();
|
|
|
|
this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "INSTALL"});
|
|
|
|
RecommendationMap.delete(browser);
|
2018-09-20 21:36:20 +03:00
|
|
|
},
|
2018-08-27 18:53:18 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
const secondaryActions = [{
|
2018-10-20 15:38:08 +03:00
|
|
|
label: secondaryBtnStrings[0].label,
|
|
|
|
accessKey: secondaryBtnStrings[0].attributes.accesskey,
|
2018-08-31 21:19:47 +03:00
|
|
|
callback: () => {
|
2018-10-20 15:38:08 +03:00
|
|
|
this.dispatchUserAction(secondary[0].action);
|
2018-08-31 21:19:47 +03:00
|
|
|
this.hide();
|
|
|
|
this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "DISMISS"});
|
|
|
|
RecommendationMap.delete(browser);
|
2018-09-20 21:36:20 +03:00
|
|
|
},
|
2018-10-20 15:38:08 +03:00
|
|
|
}, {
|
|
|
|
label: secondaryBtnStrings[1].label,
|
|
|
|
accessKey: secondaryBtnStrings[1].attributes.accesskey,
|
|
|
|
callback: () => {
|
|
|
|
this._blockMessage(id);
|
|
|
|
this.hide();
|
2018-11-10 22:13:10 +03:00
|
|
|
this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "BLOCK"});
|
2018-10-20 15:38:08 +03:00
|
|
|
RecommendationMap.delete(browser);
|
|
|
|
},
|
|
|
|
}, {
|
|
|
|
label: secondaryBtnStrings[2].label,
|
|
|
|
accessKey: secondaryBtnStrings[2].attributes.accesskey,
|
|
|
|
callback: () => {
|
|
|
|
this.dispatchUserAction(secondary[2].action);
|
|
|
|
this.hide();
|
2018-11-10 22:13:10 +03:00
|
|
|
this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "MANAGE"});
|
2018-10-20 15:38:08 +03:00
|
|
|
RecommendationMap.delete(browser);
|
|
|
|
},
|
2018-08-27 18:53:18 +03:00
|
|
|
}];
|
|
|
|
|
|
|
|
const options = {
|
|
|
|
popupIconURL: content.addon.icon,
|
|
|
|
hideClose: true,
|
2018-09-20 21:36:20 +03:00
|
|
|
eventCallback: this._popupStateChange,
|
2018-08-27 18:53:18 +03:00
|
|
|
};
|
|
|
|
|
2018-08-31 21:19:47 +03:00
|
|
|
this._sendTelemetry({message_id: id, bucket_id: content.bucket_id, event: "CLICK_DOORHANGER"});
|
|
|
|
this.currentNotification = this.window.PopupNotifications.show(
|
2018-08-27 18:53:18 +03:00
|
|
|
browser,
|
|
|
|
POPUP_NOTIFICATION_ID,
|
2018-08-31 21:19:47 +03:00
|
|
|
await this.getStrings(content.addon.title),
|
2018-08-27 18:53:18 +03:00
|
|
|
"cfr",
|
|
|
|
mainAction,
|
|
|
|
secondaryActions,
|
|
|
|
options
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function isHostMatch(browser, host) {
|
|
|
|
return (browser.documentURI.scheme.startsWith("http") &&
|
|
|
|
browser.documentURI.host === host);
|
|
|
|
}
|
|
|
|
|
|
|
|
const CFRPageActions = {
|
|
|
|
// For testing purposes
|
|
|
|
RecommendationMap,
|
|
|
|
PageActionMap,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* To be called from browser.js on a location change, passing in the browser
|
|
|
|
* that's been updated
|
|
|
|
*/
|
|
|
|
updatePageActions(browser) {
|
|
|
|
const win = browser.ownerGlobal;
|
|
|
|
const pageAction = PageActionMap.get(win);
|
|
|
|
if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (RecommendationMap.has(browser)) {
|
2018-08-31 21:19:47 +03:00
|
|
|
const recommendation = RecommendationMap.get(browser);
|
|
|
|
if (isHostMatch(browser, recommendation.host)) {
|
2018-08-27 18:53:18 +03:00
|
|
|
// The browser has a recommendation specified with this host, so show
|
|
|
|
// the page action
|
2018-08-31 21:19:47 +03:00
|
|
|
pageAction.show(recommendation);
|
2018-08-27 18:53:18 +03:00
|
|
|
} else {
|
|
|
|
// The user has navigated away from the specified host in the given
|
|
|
|
// browser, so the recommendation is no longer valid and should be removed
|
|
|
|
RecommendationMap.delete(browser);
|
|
|
|
pageAction.hide();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// There's no recommendation specified for this browser, so hide the page action
|
|
|
|
pageAction.hide();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2018-09-26 22:19:24 +03:00
|
|
|
/**
|
|
|
|
* Fetch the URL to the latest add-on xpi so the recommendation can download it.
|
|
|
|
* @param addon The add-on provided by the CFRMessageProvider
|
|
|
|
* @return A string for the URL that was fetched
|
|
|
|
*/
|
|
|
|
async _fetchLatestAddonVersion({id}) {
|
|
|
|
let url = null;
|
|
|
|
try {
|
|
|
|
const response = await fetch(`${ADDONS_API_URL}/${id}`);
|
|
|
|
if (response.status !== 204 && response.ok) {
|
|
|
|
const json = await response.json();
|
|
|
|
url = json.current_version.files[0].url;
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
Cu.reportError("Failed to get the latest add-on version for this recommendation");
|
|
|
|
}
|
|
|
|
return url;
|
|
|
|
},
|
|
|
|
|
|
|
|
async _maybeAddAddonInstallURL(recommendation) {
|
|
|
|
const {content, template} = recommendation;
|
|
|
|
// If this is CFR is not for an add-on, return the original recommendation
|
|
|
|
if (template !== "cfr_doorhanger") {
|
|
|
|
return recommendation;
|
|
|
|
}
|
|
|
|
|
|
|
|
const url = await this._fetchLatestAddonVersion(content.addon);
|
|
|
|
// If we failed to get a url to the latest xpi, return false so we know not to show
|
|
|
|
// a recommendation
|
|
|
|
if (!url) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update the action's data with the url to the latest xpi, leave the rest
|
|
|
|
// of the recommendation properties intact
|
|
|
|
return {
|
|
|
|
...recommendation,
|
|
|
|
content: {
|
|
|
|
...content,
|
|
|
|
buttons: {
|
|
|
|
...content.buttons,
|
|
|
|
primary: {
|
|
|
|
...content.buttons.primary,
|
|
|
|
action: {...content.buttons.primary.action, data: {url}},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
2018-08-29 17:08:31 +03:00
|
|
|
/**
|
|
|
|
* Force a recommendation to be shown. Should only happen via the Admin page.
|
2018-09-26 22:19:24 +03:00
|
|
|
* @param browser The browser for the recommendation
|
|
|
|
* @param originalRecommendation The recommendation to show
|
|
|
|
* @param dispatchToASRouter A function to dispatch resulting actions to
|
|
|
|
* @return Did adding the recommendation succeed?
|
2018-08-29 17:08:31 +03:00
|
|
|
*/
|
2018-09-26 22:19:24 +03:00
|
|
|
async forceRecommendation(browser, originalRecommendation, dispatchToASRouter) {
|
2018-08-29 17:08:31 +03:00
|
|
|
// If we are forcing via the Admin page, the browser comes in a different format
|
|
|
|
const win = browser.browser.ownerGlobal;
|
2018-09-26 22:19:24 +03:00
|
|
|
const recommendation = await this._maybeAddAddonInstallURL(originalRecommendation);
|
|
|
|
if (!recommendation) {
|
|
|
|
return false;
|
|
|
|
}
|
2018-08-29 17:08:31 +03:00
|
|
|
const {id, content} = recommendation;
|
|
|
|
RecommendationMap.set(browser.browser, {id, content});
|
|
|
|
if (!PageActionMap.has(win)) {
|
|
|
|
PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
|
|
|
|
}
|
2018-08-31 21:19:47 +03:00
|
|
|
await PageActionMap.get(win).show(recommendation, true);
|
2018-08-29 17:08:31 +03:00
|
|
|
return true;
|
|
|
|
},
|
|
|
|
|
2018-08-27 18:53:18 +03:00
|
|
|
/**
|
|
|
|
* Add a recommendation specific to the given browser and host.
|
2018-09-26 22:19:24 +03:00
|
|
|
* @param browser The browser for the recommendation
|
|
|
|
* @param host The host for the recommendation
|
|
|
|
* @param originalRecommendation The recommendation to show
|
|
|
|
* @param dispatchToASRouter A function to dispatch resulting actions to
|
|
|
|
* @return Did adding the recommendation succeed?
|
2018-08-27 18:53:18 +03:00
|
|
|
*/
|
2018-09-26 22:19:24 +03:00
|
|
|
async addRecommendation(browser, host, originalRecommendation, dispatchToASRouter) {
|
2018-08-27 18:53:18 +03:00
|
|
|
const win = browser.ownerGlobal;
|
2018-08-31 21:19:47 +03:00
|
|
|
if (PrivateBrowsingUtils.isWindowPrivate(win)) {
|
|
|
|
return false;
|
|
|
|
}
|
2018-08-29 17:08:31 +03:00
|
|
|
if (browser !== win.gBrowser.selectedBrowser || !isHostMatch(browser, host)) {
|
2018-09-26 22:19:24 +03:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const recommendation = await this._maybeAddAddonInstallURL(originalRecommendation);
|
|
|
|
if (!recommendation) {
|
2018-08-27 18:53:18 +03:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const {id, content} = recommendation;
|
|
|
|
RecommendationMap.set(browser, {id, host, content});
|
|
|
|
if (!PageActionMap.has(win)) {
|
|
|
|
PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
|
|
|
|
}
|
2018-08-31 21:19:47 +03:00
|
|
|
await PageActionMap.get(win).show(recommendation, true);
|
2018-08-27 18:53:18 +03:00
|
|
|
return true;
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Clear all recommendations and hide all PageActions
|
|
|
|
*/
|
|
|
|
clearRecommendations() {
|
2018-08-31 21:19:47 +03:00
|
|
|
// WeakMaps aren't iterable so we have to test all existing windows
|
|
|
|
for (const win of Services.wm.getEnumerator("navigator:browser")) {
|
|
|
|
if (win.closed || !PageActionMap.has(win)) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
PageActionMap.get(win).hide();
|
2018-08-27 18:53:18 +03:00
|
|
|
}
|
2018-08-31 21:19:47 +03:00
|
|
|
// WeakMaps don't have a `clear` method
|
|
|
|
PageActionMap = new WeakMap();
|
|
|
|
RecommendationMap = new WeakMap();
|
2018-09-20 21:36:20 +03:00
|
|
|
this.PageActionMap = PageActionMap;
|
|
|
|
this.RecommendationMap = RecommendationMap;
|
|
|
|
},
|
2018-08-27 18:53:18 +03:00
|
|
|
};
|
2018-09-20 21:36:20 +03:00
|
|
|
|
|
|
|
this.PageAction = PageAction;
|
2018-08-29 17:08:31 +03:00
|
|
|
this.CFRPageActions = CFRPageActions;
|
2018-08-27 18:53:18 +03:00
|
|
|
|
2018-09-20 21:36:20 +03:00
|
|
|
const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"];
|