fune/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs

675 lines
22 KiB
JavaScript

/* 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/. */
/*
* Implements doorhanger singleton that wraps up the PopupNotifications and handles
* the doorhager UI for formautofill related features.
*/
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AutofillTelemetry } from "resource://autofill/AutofillTelemetry.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
});
XPCOMUtils.defineLazyGetter(lazy, "log", () =>
FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter")
);
const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill;
const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName;
const formatStringFromName =
FormAutofillUtils.stringBundle.formatStringFromName;
const brandShortName =
FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
let changeAutofillOptsKey = "changeAutofillOptions";
let autofillOptsKey = "autofillOptionsLink";
if (AppConstants.platform == "macosx") {
changeAutofillOptsKey += "OSX";
autofillOptsKey += "OSX";
}
const CONTENT = {
addFirstTimeUse: {
notificationId: "autofill-address",
message: formatStringFromName("saveAddressesMessage", [brandShortName]),
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: GetStringFromName("openAutofillMessagePanel"),
},
mainAction: {
label: GetStringFromName(changeAutofillOptsKey),
accessKey: GetStringFromName("changeAutofillOptionsAccessKey"),
callbackState: "open-pref",
},
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-address-save.svg",
checkbox: {
get checked() {
return Services.prefs.getBoolPref("services.sync.engine.addresses");
},
get label() {
// If sync account is not set, return null label to hide checkbox
return Services.prefs.prefHasUserValue("services.sync.username")
? GetStringFromName("addressesSyncCheckbox")
: null;
},
callback(event) {
let checked = event.target.checked;
Services.prefs.setBoolPref("services.sync.engine.addresses", checked);
lazy.log.debug("Set addresses sync to", checked);
},
},
hideClose: true,
},
},
addAddress: {
notificationId: "autofill-address",
message: formatStringFromName("saveAddressesMessage", [brandShortName]),
descriptionLabel: GetStringFromName("saveAddressDescriptionLabel"),
descriptionIcon: true,
linkMessage: GetStringFromName(autofillOptsKey),
spotlightURL: "about:preferences#privacy-address-autofill",
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: GetStringFromName("openAutofillMessagePanel"),
},
mainAction: {
label: GetStringFromName("saveAddressLabel"),
accessKey: GetStringFromName("saveAddressAccessKey"),
callbackState: "create",
},
secondaryActions: [
{
label: GetStringFromName("cancelAddressLabel"),
accessKey: GetStringFromName("cancelAddressAccessKey"),
callbackState: "cancel",
},
],
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-address-update.svg",
hideClose: true,
},
},
updateAddress: {
notificationId: "autofill-address",
message: GetStringFromName("updateAddressMessage"),
descriptionLabel: GetStringFromName("updateAddressNewDescriptionLabel"),
additionalDescriptionLabel: GetStringFromName(
"updateAddressOldDescriptionLabel"
),
descriptionIcon: false,
linkMessage: GetStringFromName(autofillOptsKey),
spotlightURL: "about:preferences#privacy-address-autofill",
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: GetStringFromName("openAutofillMessagePanel"),
},
mainAction: {
label: GetStringFromName("updateAddressLabel"),
accessKey: GetStringFromName("updateAddressAccessKey"),
callbackState: "update",
},
secondaryActions: [
{
label: GetStringFromName("createAddressLabel"),
accessKey: GetStringFromName("createAddressAccessKey"),
callbackState: "create",
},
],
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-address-update.svg",
hideClose: true,
},
},
addCreditCard: {
notificationId: "autofill-credit-card",
message: formatStringFromName("saveCreditCardMessage", [brandShortName]),
descriptionLabel: GetStringFromName("saveCreditCardDescriptionLabel"),
descriptionIcon: true,
linkMessage: GetStringFromName(autofillOptsKey),
spotlightURL: "about:preferences#privacy-credit-card-autofill",
anchor: {
id: "autofill-credit-card-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: GetStringFromName("openAutofillMessagePanel"),
},
mainAction: {
label: GetStringFromName("saveCreditCardLabel"),
accessKey: GetStringFromName("saveCreditCardAccessKey"),
callbackState: "save",
},
secondaryActions: [
{
label: GetStringFromName("cancelCreditCardLabel"),
accessKey: GetStringFromName("cancelCreditCardAccessKey"),
callbackState: "cancel",
},
{
label: GetStringFromName("neverSaveCreditCardLabel"),
accessKey: GetStringFromName("neverSaveCreditCardAccessKey"),
callbackState: "disable",
},
],
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
hideClose: true,
checkbox: {
get checked() {
return Services.prefs.getBoolPref("services.sync.engine.creditcards");
},
get label() {
// Only set the label when the fallowing conditions existed:
// - sync account is set
// - credit card sync is disabled
// - credit card sync is available
// otherwise return null label to hide checkbox.
return Services.prefs.prefHasUserValue("services.sync.username") &&
!Services.prefs.getBoolPref("services.sync.engine.creditcards") &&
Services.prefs.getBoolPref(
"services.sync.engine.creditcards.available"
)
? GetStringFromName("creditCardsSyncCheckbox")
: null;
},
callback(event) {
let { secondaryButton, menubutton } =
event.target.closest("popupnotification");
let checked = event.target.checked;
Services.prefs.setBoolPref(
"services.sync.engine.creditcards",
checked
);
secondaryButton.disabled = checked;
menubutton.disabled = checked;
lazy.log.debug("Set creditCard sync to", checked);
},
},
},
},
updateCreditCard: {
notificationId: "autofill-credit-card",
message: GetStringFromName("updateCreditCardMessage"),
descriptionLabel: GetStringFromName("updateCreditCardDescriptionLabel"),
descriptionIcon: true,
linkMessage: GetStringFromName(autofillOptsKey),
spotlightURL: "about:preferences#privacy-credit-card-autofill",
anchor: {
id: "autofill-credit-card-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: GetStringFromName("openAutofillMessagePanel"),
},
mainAction: {
label: GetStringFromName("updateCreditCardLabel"),
accessKey: GetStringFromName("updateCreditCardAccessKey"),
callbackState: "update",
},
secondaryActions: [
{
label: GetStringFromName("createCreditCardLabel"),
accessKey: GetStringFromName("createCreditCardAccessKey"),
callbackState: "create",
},
],
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
hideClose: true,
},
},
};
export let FormAutofillPrompter = {
/**
* Generate the main action and secondary actions from content parameters and
* promise resolve.
*
* @private
* @param {object} mainActionParams
* Parameters for main action.
* @param {Array<object>} secondaryActionParams
* Array of the parameters for secondary actions.
* @param {Function} resolve Should be called in action callback.
* @returns {Array<object>}
Return the mainAction and secondary actions in an array for showing doorhanger
*/
_createActions(mainActionParams, secondaryActionParams, resolve) {
if (!mainActionParams) {
return [null, null];
}
let { label, accessKey, callbackState } = mainActionParams;
let callback = resolve.bind(null, callbackState);
let mainAction = { label, accessKey, callback };
if (!secondaryActionParams) {
return [mainAction, null];
}
let secondaryActions = [];
for (let params of secondaryActionParams) {
let cb = resolve.bind(null, params.callbackState);
secondaryActions.push({
label: params.label,
accessKey: params.accessKey,
callback: cb,
});
}
return [mainAction, secondaryActions];
},
_getNotificationElm(browser, id) {
let notificationId = id + "-notification";
let chromeDoc = browser.ownerDocument;
return chromeDoc.getElementById(notificationId);
},
/**
* Append the link label element to the popupnotificationcontent.
*
* @param {XULElement} content
* popupnotificationcontent
* @param {string} message
* The localized string for link title.
* @param {string} link
* Makes it possible to open and highlight a section in preferences
*/
_appendPrivacyPanelLink(content, message, link) {
let chromeDoc = content.ownerDocument;
let privacyLinkElement = chromeDoc.createXULElement("label", {
is: "text-link",
});
privacyLinkElement.setAttribute("useoriginprincipal", true);
privacyLinkElement.setAttribute(
"href",
link || "about:preferences#privacy-form-autofill"
);
privacyLinkElement.setAttribute("value", message);
content.appendChild(privacyLinkElement);
},
/**
* Append the description section to the popupnotificationcontent.
*
* @param {XULElement} content
* popupnotificationcontent
* @param {string} descriptionLabel
* The label showing above description.
* @param {string} descriptionIcon
* The src of description icon.
* @param {string} descriptionId
* The id of description
*/
_appendDescription(
content,
descriptionLabel,
descriptionIcon,
descriptionId
) {
let chromeDoc = content.ownerDocument;
let docFragment = chromeDoc.createDocumentFragment();
let descriptionLabelElement = chromeDoc.createXULElement("label");
descriptionLabelElement.setAttribute("value", descriptionLabel);
docFragment.appendChild(descriptionLabelElement);
let descriptionWrapper = chromeDoc.createXULElement("hbox");
descriptionWrapper.className = "desc-message-box";
if (descriptionIcon) {
let descriptionIconElement = chromeDoc.createXULElement("image");
if (
typeof descriptionIcon == "string" &&
(descriptionIcon.includes("cc-logo") ||
descriptionIcon.includes("icon-credit"))
) {
descriptionIconElement.setAttribute("src", descriptionIcon);
}
descriptionWrapper.appendChild(descriptionIconElement);
}
let descriptionElement = chromeDoc.createXULElement(descriptionId);
descriptionWrapper.appendChild(descriptionElement);
docFragment.appendChild(descriptionWrapper);
content.appendChild(docFragment);
},
_updateDescription(content, descriptionId, description) {
let element = content.querySelector(descriptionId);
element.textContent = description;
},
/**
* Create an image element for notification anchor if it doesn't already exist.
*
* @param {XULElement} browser
* Target browser element for showing doorhanger.
* @param {object} anchor
* Anchor options for setting the anchor element.
* @param {string} anchor.id
* ID of the anchor element.
* @param {string} anchor.URL
* Path of the icon asset.
* @param {string} anchor.tooltiptext
* Tooltip string for the anchor.
*/
_setAnchor(browser, anchor) {
let chromeDoc = browser.ownerDocument;
let { id, URL, tooltiptext } = anchor;
let anchorEt = chromeDoc.getElementById(id);
if (!anchorEt) {
let notificationPopupBox = chromeDoc.getElementById(
"notification-popup-box"
);
// Icon shown on URL bar
let anchorElement = chromeDoc.createXULElement("image");
anchorElement.id = id;
anchorElement.setAttribute("src", URL);
anchorElement.classList.add("notification-anchor-icon");
anchorElement.setAttribute("role", "button");
anchorElement.setAttribute("tooltiptext", tooltiptext);
notificationPopupBox.appendChild(anchorElement);
}
},
_addCheckboxListener(browser, { notificationId, options }) {
if (!options.checkbox) {
return;
}
let { checkbox } = this._getNotificationElm(browser, notificationId);
if (checkbox && !checkbox.hidden) {
checkbox.addEventListener("command", options.checkbox.callback);
}
},
_removeCheckboxListener(browser, { notificationId, options }) {
if (!options.checkbox) {
return;
}
let { checkbox } = this._getNotificationElm(browser, notificationId);
if (checkbox && !checkbox.hidden) {
checkbox.removeEventListener("command", options.checkbox.callback);
}
},
/**
* Show save or update address doorhanger
*
* @param {Element<browser>} browser Browser to show the save/update address prompt
* @param {object} storage Address storage
* @param {object} newRecord Address record to save
* @param {string} flowId Unique GUID to record a series of the same user action
* @param {object} options
* @param {object} [options.mergeableRecord] Record to be merged
* @param {Array} [options.mergeableFields] List of field name that can be merged
*/
async promptToSaveAddress(
browser,
storage,
newRecord,
flowId,
{ mergeableRecord, mergeableFields }
) {
// Overwrite the guid if there is a duplicate
let doorhangerType;
if (mergeableRecord) {
doorhangerType = "updateAddress";
} else if (FormAutofill.isAutofillAddressesCaptureV2Enabled) {
doorhangerType = "addAddress";
} else {
doorhangerType = "addFirstTimeUse";
this._updateStorageAfterInteractWithPrompt("save", storage, newRecord);
// Show first time use doorhanger
if (FormAutofill.isAutofillAddressesFirstTimeUse) {
Services.prefs.setBoolPref(
FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF,
false
);
} else {
return;
}
}
const description = FormAutofillUtils.getAddressLabel(newRecord);
const additionalDescription = mergeableRecord
? FormAutofillUtils.getAddressLabel(mergeableRecord)
: null;
const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger(
browser,
doorhangerType,
description,
flowId,
{ additionalDescription }
);
if (state == "cancel") {
return;
} else if (state == "open-pref") {
browser.ownerGlobal.openPreferences("privacy-address-autofill");
return;
}
this._updateStorageAfterInteractWithPrompt(
state,
storage,
newRecord,
mergeableRecord?.guid
);
},
async promptToSaveCreditCard(browser, storage, record, flowId) {
// Overwrite the guid if there is a duplicate
let doorhangerType;
const duplicateRecord = (await storage.getDuplicateRecords(record).next())
.value;
if (duplicateRecord) {
doorhangerType = "updateCreditCard";
} else {
doorhangerType = "addCreditCard";
}
const number = record["cc-number"] || record["cc-number-decrypted"];
const name = record["cc-name"];
const network = lazy.CreditCard.getType(number);
const maskedNumber = lazy.CreditCard.getMaskedNumber(number);
const description = `${maskedNumber}` + (name ? `, ${name}` : ``);
const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network);
const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger(
browser,
doorhangerType,
description,
flowId,
{ descriptionIcon }
);
if (state == "cancel") {
return;
} else if (state == "disable") {
Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false);
return;
}
if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
lazy.log.warn("User canceled encryption login");
return;
}
this._updateStorageAfterInteractWithPrompt(
state,
storage,
record,
duplicateRecord?.guid
);
},
async _updateStorageAfterInteractWithPrompt(
state,
storage,
record,
guid = null
) {
let changedGUID = null;
if (state == "create" || state == "save") {
changedGUID = await storage.add(record);
} else if (state == "update") {
await storage.update(guid, record, true);
changedGUID = guid;
}
storage.notifyUsed(changedGUID);
},
_getUpdatedCCIcon(network) {
return FormAutofillUtils.getCreditCardLogo(network);
},
/**
* Show different types of doorhanger by leveraging PopupNotifications.
*
* @param {XULElement} browser Target browser element for showing doorhanger.
* @param {string} type The type of the doorhanger. There will have first time use/update/credit card.
* @param {string} description The message that provides more information on doorhanger.
* @param {string} flowId guid used to correlate events relating to the same form
* @param {object} [options = {}] a list of options for this method
* @param {string} options.descriptionIcon The icon for descriotion
* @param {string} options.additionalDescription The message that provides more information on doorhanger.
* @returns {Promise} Resolved with action type when action callback is triggered.
*/
async _showCCorAddressCaptureDoorhanger(
browser,
type,
description,
flowId,
{ descriptionIcon = null, additionalDescription = null }
) {
const telemetryType = type.endsWith("CreditCard")
? AutofillTelemetry.CREDIT_CARD
: AutofillTelemetry.ADDRESS;
const isCapture = type.startsWith("add");
AutofillTelemetry.recordDoorhangerShown(telemetryType, flowId, isCapture);
lazy.log.debug("show doorhanger with type:", type);
return new Promise(resolve => {
let {
notificationId,
message,
descriptionLabel,
additionalDescriptionLabel,
linkMessage,
spotlightURL,
anchor,
mainAction,
secondaryActions,
options,
} = CONTENT[type];
descriptionIcon = descriptionIcon ?? CONTENT[type].descriptionIcon;
const { ownerGlobal: chromeWin, ownerDocument: chromeDoc } = browser;
options.eventCallback = topic => {
lazy.log.debug("eventCallback:", topic);
if (topic == "removed" || topic == "dismissed") {
this._removeCheckboxListener(browser, { notificationId, options });
return;
}
// The doorhanger is customizable only when notification box is shown
if (topic != "shown") {
return;
}
this._addCheckboxListener(browser, { notificationId, options });
// There's no preferences link or other customization in first time use doorhanger.
if (type == "addFirstTimeUse") {
return;
}
const DESCRIPTION_ID = "description";
const ADDITIONAL_DESCRIPTION_ID = "additional-description";
const NOTIFICATION_ID = notificationId + "-notification";
const notification = chromeDoc.getElementById(NOTIFICATION_ID);
const notificationContent =
notification.querySelector("popupnotificationcontent") ||
chromeDoc.createXULElement("popupnotificationcontent");
if (!notification.contains(notificationContent)) {
notificationContent.setAttribute("orient", "vertical");
this._appendDescription(
notificationContent,
descriptionLabel,
descriptionIcon,
DESCRIPTION_ID
);
if (additionalDescription) {
this._appendDescription(
notificationContent,
additionalDescriptionLabel,
descriptionIcon,
ADDITIONAL_DESCRIPTION_ID
);
}
this._appendPrivacyPanelLink(
notificationContent,
linkMessage,
spotlightURL
);
notification.appendNotificationContent(notificationContent);
}
this._updateDescription(
notificationContent,
DESCRIPTION_ID,
description
);
if (additionalDescription) {
this._updateDescription(
notificationContent,
ADDITIONAL_DESCRIPTION_ID,
additionalDescription
);
}
};
this._setAnchor(browser, anchor);
chromeWin.PopupNotifications.show(
browser,
notificationId,
message,
anchor.id,
...this._createActions(mainAction, secondaryActions, resolve),
options
);
}).then(state => {
AutofillTelemetry.recordDoorhangerClicked(
telemetryType,
state,
flowId,
isCapture
);
return state;
});
},
};