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

1532 lines
46 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 { AutofillTelemetry } from "resource://autofill/AutofillTelemetry.sys.mjs";
import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
FormAutofillNameUtils:
"resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "log", () =>
FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter")
);
const l10n = new Localization(
["browser/preferences/formAutofill.ftl", "branding/brand.ftl"],
true
);
const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill;
const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName;
const formatStringFromName =
FormAutofillUtils.stringBundle.formatStringFromName;
const brandShortName =
FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
let autofillOptsKey = "autofillOptionsLink";
if (AppConstants.platform == "macosx") {
autofillOptsKey += "OSX";
}
let CONTENT = {};
/**
* `AutofillDoorhanger` provides a base for both address capture and credit card
* capture doorhanger notifications. It handles the UI generation and logic
* related to displaying the doorhanger,
*
* The UI data sourced from the `CONTENT` variable is used for rendering. Derived classes
* should override the `render()` method to customize the layout.
*/
export class AutofillDoorhanger {
/**
* Constructs an instance of the `AutofillDoorhanger` class.
*
* @param {object} browser The browser where the doorhanger will be displayed.
* @param {object} oldRecord The old record that can be merged with the new record
* @param {object} newRecord The new record submitted by users
*/
static headerClass = "address-capture-header";
static descriptionClass = "address-capture-description";
static contentClass = "address-capture-content";
static menuButtonId = "address-capture-menu-button";
static preferenceURL = null;
static learnMoreURL = null;
constructor(browser, oldRecord, newRecord, flowId) {
this.browser = browser;
this.oldRecord = oldRecord ?? {};
this.newRecord = newRecord;
this.flowId = flowId;
}
get ui() {
return CONTENT[this.constructor.name];
}
// PopupNotification appends a "-notification" suffix to the id to avoid
// id conflict.
get notificationId() {
return this.ui.id + "-notification";
}
// The popup notification element
get panel() {
return this.browser.ownerDocument.getElementById(this.notificationId);
}
get doc() {
return this.browser.ownerDocument;
}
get chromeWin() {
return this.browser.ownerGlobal;
}
/*
* An autofill doorhanger consists 3 parts - header, description, and content
* The content part contains customized UI layout for this doorhanger
*/
// The container of the header part
static header(panel) {
return panel.querySelector(`.${AutofillDoorhanger.headerClass}`);
}
get header() {
return AutofillDoorhanger.header(this.panel);
}
// The container of the description part
static description(panel) {
return panel.querySelector(`.${AutofillDoorhanger.descriptionClass}`);
}
get description() {
return AutofillDoorhanger.description(this.panel);
}
// The container of the content part
static content(panel) {
return panel.querySelector(`.${AutofillDoorhanger.contentClass}`);
}
get content() {
return AutofillDoorhanger.content(this.panel);
}
static menuButton(panel) {
return panel.querySelector(`#${AutofillDoorhanger.menuButtonId}`);
}
get menuButton() {
return AutofillDoorhanger.menuButton(this.panel);
}
static menuPopup(panel) {
return AutofillDoorhanger.menuButton(panel).querySelector(
`.toolbar-menupopup`
);
}
get menuPopup() {
return AutofillDoorhanger.menuPopup(this.panel);
}
static preferenceButton(panel) {
return AutofillDoorhanger.menuButton(panel).querySelector(
`[data-l10n-id=address-capture-manage-address-button]`
);
}
static learnMoreButton(panel) {
return AutofillDoorhanger.menuButton(panel).querySelector(
`[data-l10n-id=address-capture-learn-more-button]`
);
}
get preferenceURL() {
return this.constructor.preferenceURL;
}
get learnMoreURL() {
return this.constructor.learnMoreURL;
}
onMenuItemClick(evt) {
AutofillTelemetry.recordDoorhangerClicked(
this.constructor.telemetryType,
evt,
this.constructor.telemetryObject,
this.flowId
);
if (evt == "open-pref") {
this.browser.ownerGlobal.openPreferences(this.preferenceURL);
} else if (evt == "learn-more") {
const url =
Services.urlFormatter.formatURLPref("app.support.baseURL") +
this.learnMoreURL;
this.browser.ownerGlobal.openWebLinkIn(url, "tab", {
relatedToCurrent: true,
});
}
}
// Build the doorhanger markup
render() {
this.renderHeader();
this.renderDescription();
// doorhanger specific content
this.renderContent();
}
renderHeader() {
// Render the header text
const text = this.header.querySelector(`p`);
this.doc.l10n.setAttributes(text, this.ui.header.l10nId);
// Render the menu button
if (!this.ui.menu?.length || AutofillDoorhanger.menuButton(this.panel)) {
return;
}
const button = this.doc.createElement("button");
button.setAttribute("id", AutofillDoorhanger.menuButtonId);
button.setAttribute("class", "address-capture-icon-button");
this.doc.l10n.setAttributes(button, "address-capture-open-menu-button");
const menupopup = this.doc.createXULElement("menupopup");
menupopup.setAttribute("id", AutofillDoorhanger.menuButtonId);
menupopup.setAttribute("class", "toolbar-menupopup");
for (const [index, element] of this.ui.menu.entries()) {
const menuitem = this.doc.createXULElement("menuitem");
this.doc.l10n.setAttributes(menuitem, element.l10nId);
/* eslint-disable mozilla/balanced-listeners */
menuitem.addEventListener("command", event => {
event.stopPropagation();
this.onMenuItemClick(element.evt);
});
menupopup.appendChild(menuitem);
if (index != this.ui.menu.length - 1) {
menupopup.appendChild(this.doc.createXULElement("menuseparator"));
}
}
button.appendChild(menupopup);
/* eslint-disable mozilla/balanced-listeners */
button.addEventListener("click", event => {
event.stopPropagation();
menupopup.openPopup(button, "after_start");
});
this.header.appendChild(button);
}
renderDescription() {
if (this.ui.description?.l10nId) {
const text = this.description.querySelector(`p`);
this.doc.l10n.setAttributes(text, this.ui.description.l10nId);
this.description?.setAttribute("style", "");
} else {
this.description?.setAttribute("style", "display:none");
}
}
onEventCallback(state) {
lazy.log.debug(`Doorhanger receives event callback: ${state}`);
if (state == "showing") {
this.render();
}
}
async show() {
AutofillTelemetry.recordDoorhangerShown(
this.constructor.telemetryType,
this.constructor.telemetryObject,
this.flowId
);
let options = {
...this.ui.options,
eventCallback: state => this.onEventCallback(state),
};
AutofillDoorhanger.setAnchor(this.doc, this.ui.anchor);
return new Promise(resolve => {
this.resolve = resolve;
this.chromeWin.PopupNotifications.show(
this.browser,
this.ui.id,
"",
this.ui.anchor.id,
...AutofillDoorhanger.createActions(
this.ui.footer.mainAction,
this.ui.footer.secondaryActions,
resolve,
{
type: this.constructor.telemetryType,
object: this.constructor.telemetryObject,
flowId: this.flowId,
}
),
options
);
});
}
/**
* Closes the doorhanger with a given action.
* This method is specifically intended for closing the doorhanger in scenarios
* other than clicking the main or secondary buttons.
*/
closeDoorhanger(action) {
this.resolve(action);
const notification = this.chromeWin.PopupNotifications.getNotification(
this.ui.id,
this.browser
);
if (notification) {
this.chromeWin.PopupNotifications.remove(notification);
}
}
/**
* Create an image element for notification anchor if it doesn't already exist.
*
* @static
* @param {Document} doc - The document object where the anchor element should be created or modified.
* @param {object} anchor - An object containing the attributes for the anchor element.
* @param {string} anchor.id - The ID to assign to the anchor element.
* @param {string} anchor.URL - The image URL to set for the anchor element.
* @param {string} anchor.tooltiptext - The tooltip text to set for the anchor element.
*/
// TODO: this is a static method so credit card doorhangers can also use this API.
// we can change this to non-static after we impleemnt doorhanger with `class AutofillDoorhanger`
static setAnchor(doc, anchor) {
let anchorElement = doc.getElementById(anchor.id);
if (!anchorElement) {
const popupBox = doc.getElementById("notification-popup-box");
// Icon shown on URL bar
anchorElement = doc.createXULElement("image");
anchorElement.id = anchor.id;
anchorElement.setAttribute("src", anchor.URL);
anchorElement.classList.add("notification-anchor-icon");
anchorElement.setAttribute("role", "button");
anchorElement.setAttribute("tooltiptext", anchor.tooltiptext);
popupBox.appendChild(anchorElement);
}
}
/**
* 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} onClick Should be called in action callback.
* @returns {Array<object>}
Return the mainAction and secondary actions in an array for showing doorhanger
*/
// TODO: this is a static method so credit card doorhangers can also use this API.
static createActions(
mainActionParams,
secondaryActionParams,
onClick,
telemetryOptions
) {
function getLabelAndAccessKey(param) {
// This should be removed once we port credit card capture doorhanger to use fluent
if (!param.l10nId) {
return { label: param.label, accessKey: param.accessKey };
}
const msg = l10n.formatMessagesSync([{ id: param.l10nId }])[0];
return {
label: msg.attributes.find(x => x.name == "label").value,
accessKey: msg.attributes.find(x => x.name == "accessKey").value,
dismiss: param.dismiss,
};
}
const callback = () => {
AutofillTelemetry.recordDoorhangerClicked(
telemetryOptions.type,
mainActionParams.callbackState,
telemetryOptions.object,
telemetryOptions.flowId
);
onClick(mainActionParams.callbackState);
};
const mainAction = {
...getLabelAndAccessKey(mainActionParams),
callback,
};
let secondaryActions = [];
for (const params of secondaryActionParams) {
const callback = () => {
AutofillTelemetry.recordDoorhangerClicked(
telemetryOptions.type,
params.callbackState,
telemetryOptions.object,
telemetryOptions.flowId
);
onClick(params.callbackState);
};
secondaryActions.push({
...getLabelAndAccessKey(params),
callback,
});
}
return [mainAction, secondaryActions];
}
}
export class AddressSaveDoorhanger extends AutofillDoorhanger {
static preferenceURL = "privacy-address-autofill";
static learnMoreURL = "automatically-fill-your-address-web-forms";
static editButtonId = "address-capture-edit-address-button";
static telemetryType = AutofillTelemetry.ADDRESS;
static telemetryObject = "capture_doorhanger";
constructor(browser, oldRecord, newRecord, flowId) {
super(browser, oldRecord, newRecord, flowId);
}
static editButton(panel) {
return panel.querySelector(`#${AddressSaveDoorhanger.editButtonId}`);
}
get editButton() {
return AddressSaveDoorhanger.editButton(this.panel);
}
/**
* Formats a line by comparing the old and the new address field and returns an array of
* <span> elements that represents the formatted line.
*
* @param {Array<Array<string>>} datalist An array of pairs, where each pair contains old and new data.
* @param {boolean} showDiff True to format the text line that highlight the diff part.
*
* @returns {Array<HTMLSpanElement>} An array of formatted text elements.
*/
#formatLine(datalist, showDiff) {
const createSpan = (text, style = null) => {
const s = this.doc.createElement("span");
s.textContent = text;
if (showDiff) {
if (style == "remove") {
s.setAttribute("class", "address-update-text-diff-removed");
} else if (style == "add") {
s.setAttribute("class", "address-update-text-diff-added");
}
}
return s;
};
let spans = [];
let previousField;
for (const [field, oldData, newData] of datalist) {
if (!oldData && !newData) {
continue;
}
// Always add a whitespace between field data that we put in the same line.
// Ex. first-name: John, family-name: Doe becomes
// "John Doe"
if (spans.length) {
if (previousField == "address-level2" && field == "address-level1") {
spans.push(createSpan(", "));
} else {
spans.push(createSpan(" "));
}
}
if (!oldData) {
spans.push(createSpan(newData, "add"));
} else if (!newData || oldData == newData) {
// The same
spans.push(createSpan(oldData));
} else if (newData.startsWith(oldData)) {
// Have the same prefix
const diff = newData.slice(oldData.length).trim();
spans.push(createSpan(newData.slice(0, newData.length - diff.length)));
spans.push(createSpan(diff, "add"));
} else if (newData.endsWith(oldData)) {
// Have the same suffix
const diff = newData.slice(0, newData.length - oldData.length).trim();
spans.push(createSpan(diff, "add"));
spans.push(createSpan(newData.slice(diff.length)));
} else {
spans.push(createSpan(oldData, "remove"));
spans.push(createSpan(" "));
spans.push(createSpan(newData, "add"));
}
previousField = field;
}
return spans;
}
#formatTextByAddressCategory(fieldName) {
let data = [];
switch (fieldName) {
case "name":
data = ["given-name", "additional-name", "family-name"].map(field => [
field,
this.oldRecord[field],
this.newRecord[field],
]);
break;
case "street-address":
data = [
[
fieldName,
FormAutofillUtils.toOneLineAddress(
this.oldRecord["street-address"]
),
FormAutofillUtils.toOneLineAddress(
this.newRecord["street-address"]
),
],
];
break;
case "address":
data = ["address-level2", "address-level1", "postal-code"].map(
field => [field, this.oldRecord[field], this.newRecord[field]]
);
break;
case "country":
case "tel":
case "email":
case "organization":
data = [
[fieldName, this.oldRecord[fieldName], this.newRecord[fieldName]],
];
break;
}
const showDiff = !!Object.keys(this.oldRecord).length;
return this.#formatLine(data, showDiff);
}
renderDescription() {
if (lazy.formAutofillStorage.addresses.isEmpty()) {
super.renderDescription();
} else {
this.description?.setAttribute("style", "display:none");
}
}
renderContent() {
this.content.replaceChildren();
// Each section contains address fields that are grouped together while displaying
// the doorhanger.
for (const { imgClass, categories } of this.ui.content.sections) {
// Add all the address fields that are in the same category
let texts = [];
categories.forEach(category => {
const line = this.#formatTextByAddressCategory(category);
if (line.length) {
texts.push(line);
}
});
// If there is no data for this section, just ignore it.
if (!texts.length) {
continue;
}
const section = this.doc.createElement("div");
section.setAttribute("class", "address-save-update-row-container");
// Add image icon for this section
//const img = this.doc.createElement("img");
const img = this.doc.createXULElement("image");
img.setAttribute("class", imgClass);
section.appendChild(img);
// Each line is consisted of multiple <span> to form diff style texts
const lineContainer = this.doc.createElement("div");
for (const spans of texts) {
const p = this.doc.createElement("p");
spans.forEach(span => p.appendChild(span));
lineContainer.appendChild(p);
}
section.appendChild(lineContainer);
this.content.appendChild(section);
// Put the edit address button in the first section
if (!AddressSaveDoorhanger.editButton(this.panel)) {
const button = this.doc.createElement("button");
button.setAttribute("id", AddressSaveDoorhanger.editButtonId);
button.setAttribute("class", "address-capture-icon-button");
this.doc.l10n.setAttributes(
button,
"address-capture-edit-address-button"
);
// The element will be removed after the popup is closed
/* eslint-disable mozilla/balanced-listeners */
button.addEventListener("click", event => {
event.stopPropagation();
this.closeDoorhanger("edit-address");
});
section.appendChild(button);
}
}
}
// The record to be saved by this doorhanger
recordToSave() {
return this.newRecord;
}
}
/**
* Address Update doorhanger and Address Save doorhanger have the same implementation.
* The only difference is UI.
*/
export class AddressUpdateDoorhanger extends AddressSaveDoorhanger {
static telemetryObject = "update_doorhanger";
}
export class AddressEditDoorhanger extends AutofillDoorhanger {
static telemetryType = AutofillTelemetry.ADDRESS;
static telemetryObject = "edit_doorhanger";
constructor(browser, record, flowId) {
// Address edit dialog doesn't have "old" record
super(browser, null, record, flowId);
this.country = record.country || FormAutofill.DEFAULT_REGION;
}
// Address edit doorhanger changes layout according to the country
#layout = null;
get layout() {
if (this.#layout?.country != this.country) {
this.#layout = FormAutofillUtils.getFormFormat(this.country);
}
return this.#layout;
}
get country() {
return this.newRecord.country;
}
set country(c) {
if (this.newRecord.country == c) {
return;
}
// `recordToSave` only contains the latest data the current country support.
// For example, if a country doesn't have `address-level2`, `recordToSave`
// will not have the address field.
// `newRecord` is where we keep all the data regardless what the country is.
// Merge `recordToSave` to `newRecord` before switching country to keep
// `newRecord` update-to-date.
this.newRecord = Object.assign(this.newRecord, this.recordToSave());
// The layout of the address edit doorhanger should be changed when the
// country is changed.
this.#buildCountrySpecificAddressFields();
}
renderContent() {
this.content.replaceChildren();
this.#buildAddressFields(this.content, this.ui.content.fixedFields);
this.#buildCountrySpecificAddressFields();
}
// Put address fields that should be in the same line together.
// Determined by the `newLine` property that is defined in libaddressinput
#buildAddressFields(container, fields) {
const createRowContainer = () => {
const div = this.doc.createElement("div");
div.setAttribute("class", "address-edit-row-container");
container.appendChild(div);
return div;
};
let row = null;
let createRow = true;
for (const { fieldId, newLine } of fields) {
if (createRow) {
row = createRowContainer();
}
row.appendChild(this.#createInputField(fieldId));
createRow = newLine;
}
}
#buildCountrySpecificAddressFields() {
const fixedFieldIds = this.ui.content.fixedFields.map(f => f.fieldId);
let container = this.doc.getElementById(
"country-specific-fields-container"
);
if (container) {
// Country-specific fields might be rebuilt after users update the country
// field, so if the container already exists, we remove all its childern and
// then rebuild it.
container.replaceChildren();
} else {
container = this.doc.createElement("div");
container.setAttribute("id", "country-specific-fields-container");
// Find where to insert country-specific fields
const nth = fixedFieldIds.indexOf(
this.ui.content.countrySpecificFieldsBefore
);
this.content.insertBefore(container, this.content.children[nth]);
}
this.#buildAddressFields(
container,
// Filter out fields that are always displayed
this.layout.fieldsOrder.filter(f => !fixedFieldIds.includes(f.fieldId))
);
}
#getFieldDisplayData(field) {
if (field == "name") {
return lazy.FormAutofillNameUtils.joinNameParts({
given: this.newRecord["given-name"],
middle: this.newRecord["additional-name"],
family: this.newRecord["family-name"],
});
}
return this.newRecord[field];
}
#buildCountryMenupopup() {
const menupopup = this.doc.createXULElement("menupopup");
let menuitem = this.doc.createXULElement("menuitem");
menuitem.setAttribute("value", "");
menupopup.appendChild(menuitem);
const countries = [...FormAutofill.countries.entries()].sort((e1, e2) =>
e1[1].localeCompare(e2[1])
);
for (const [country] of countries) {
const countryName = Services.intl.getRegionDisplayNames(undefined, [
country.toLowerCase(),
]);
menuitem = this.doc.createXULElement("menuitem");
menuitem.setAttribute("label", countryName);
menuitem.setAttribute("value", country);
menupopup.appendChild(menuitem);
}
return menupopup;
}
#buildAddressLevel1Menupopup() {
const menupopup = this.doc.createXULElement("menupopup");
let menuitem = this.doc.createXULElement("menuitem");
menuitem.setAttribute("value", "");
menupopup.appendChild(menuitem);
for (const [regionCode, regionName] of this.layout.addressLevel1Options) {
menuitem = this.doc.createXULElement("menuitem");
menuitem.setAttribute("label", regionCode);
menuitem.setAttribute("value", regionName);
menupopup.appendChild(menuitem);
}
return menupopup;
}
/**
* Creates an input field with a label and attaches it to a container element.
* The type of the input field is determined by the `fieldName`.
*
* @param {string} fieldName The name of the address field
*/
#createInputField(fieldName) {
const div = this.doc.createElement("div");
div.setAttribute("class", "address-edit-input-container");
const inputId = AddressEditDoorhanger.getInputId(fieldName);
const label = this.doc.createElement("label");
label.setAttribute("for", inputId);
switch (fieldName) {
case "address-level1":
this.doc.l10n.setAttributes(label, this.layout.addressLevel1L10nId);
break;
case "address-level2":
this.doc.l10n.setAttributes(label, this.layout.addressLevel2L10nId);
break;
case "address-level3":
this.doc.l10n.setAttributes(label, this.layout.addressLevel3L10nId);
break;
case "postal-code":
this.doc.l10n.setAttributes(label, this.layout.postalCodeL10nId);
break;
case "country":
// workaround because `autofill-address-country` is already defined
this.doc.l10n.setAttributes(
label,
`autofill-address-${fieldName}-only`
);
break;
default:
this.doc.l10n.setAttributes(label, `autofill-address-${fieldName}`);
break;
}
div.appendChild(label);
let input;
let popup;
if ("street-address".includes(fieldName)) {
input = this.doc.createElement("textarea");
input.setAttribute("rows", 3);
} else if (fieldName == "country") {
input = this.doc.createXULElement("menulist");
popup = this.#buildCountryMenupopup();
popup.addEventListener("popuphidden", e => e.stopPropagation());
input.appendChild(popup);
// The element will be removed after the popup is closed
/* eslint-disable mozilla/balanced-listeners */
input.addEventListener("command", event => {
event.stopPropagation();
this.country = input.selectedItem.value;
});
} else if (
fieldName == "address-level1" &&
this.layout.addressLevel1Options
) {
input = this.doc.createXULElement("menulist");
popup = this.#buildAddressLevel1Menupopup();
popup.addEventListener("popuphidden", e => e.stopPropagation());
input.appendChild(popup);
} else {
input = this.doc.createElement("input");
}
input.setAttribute("id", inputId);
const value = this.#getFieldDisplayData(fieldName) ?? null;
if (popup) {
const menuitem = Array.from(popup.childNodes).find(
item =>
item.label.toLowerCase() === value?.toLowerCase() ||
item.value.toLowerCase() === value?.toLowerCase()
);
input.selectedItem = menuitem;
} else {
input.value = value;
}
div.appendChild(input);
return div;
}
/*
* This method generates a unique input ID using the field name of the address field.
*
* @param {string} fieldName The name of the address field
*/
static getInputId(fieldName) {
return `address-edit-${fieldName}-input`;
}
/*
* Return a regular expression that matches the ID pattern generated by getInputId.
*/
static #getInputIdMatchRegexp() {
const regex = /^address-edit-(.+)-input$/;
return regex;
}
/**
* Collects data from all visible address field inputs within the doorhanger.
* Since address fields may vary by country, only fields present for the
* current country's address format are included in the record.
*/
recordToSave() {
let record = {};
const regex = AddressEditDoorhanger.#getInputIdMatchRegexp();
const elements = this.panel.querySelectorAll("input, textarea, menulist");
for (const element of elements) {
const match = element.id.match(regex);
if (match && match[1]) {
record[match[1]] = element.value;
}
}
return record;
}
onEventCallback(state) {
super.onEventCallback(state);
// Close the edit address doorhanger when it has been dismissed.
if (state == "dismissed") {
this.closeDoorhanger("cancel");
}
}
}
CONTENT = {
[AddressSaveDoorhanger.name]: {
id: "address-save-update",
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
},
header: {
l10nId: "address-capture-save-doorhanger-header",
},
description: {
l10nId: "address-capture-save-doorhanger-description",
},
menu: [
{
l10nId: "address-capture-manage-address-button",
evt: "open-pref",
},
{
l10nId: "address-capture-learn-more-button",
evt: "learn-more",
},
],
content: {
// We divide address data into two sections to display in the Address Save Doorhanger.
sections: [
{
imgClass: "address-capture-img-address",
categories: [
"name",
"organization",
"street-address",
"address",
"country",
],
},
{
imgClass: "address-capture-img-email",
categories: ["email", "tel"],
},
],
},
footer: {
mainAction: {
l10nId: "address-capture-save-button",
callbackState: "create",
},
secondaryActions: [
{
l10nId: "address-capture-not-now-button",
callbackState: "cancel",
},
],
},
options: {
autofocus: true,
persistWhileVisible: true,
hideClose: true,
},
},
[AddressUpdateDoorhanger.name]: {
id: "address-save-update",
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
},
header: {
l10nId: "address-capture-update-doorhanger-header",
},
menu: [
{
l10nId: "address-capture-manage-address-button",
evt: "open-pref",
},
{
l10nId: "address-capture-learn-more-button",
evt: "learn-more",
},
],
content: {
// Addresses fields are categoried into two sections, each section
// has its own icon
sections: [
{
imgClass: "address-capture-img-address",
categories: [
"name",
"organization",
"street-address",
"address",
"country",
],
},
{
imgClass: "address-capture-img-email",
categories: ["email", "tel"],
},
],
},
footer: {
mainAction: {
l10nId: "address-capture-update-button",
callbackState: "update",
},
secondaryActions: [
{
l10nId: "address-capture-not-now-button",
callbackState: "cancel",
},
],
},
options: {
autofocus: true,
persistWhileVisible: true,
hideClose: true,
},
},
[AddressEditDoorhanger.name]: {
id: "address-edit",
anchor: {
id: "autofill-address-notification-icon",
URL: "chrome://formautofill/content/formfill-anchor.svg",
tooltiptext: l10n.formatValueSync("autofill-message-tooltip"),
},
header: {
l10nId: "address-capture-edit-doorhanger-header",
},
menu: null,
content: {
// We start by organizing the fields in a specific order:
// name, organization, and country are fixed and come first.
// These are followed by country-specific fields, which are
// laid out differently for each country (as referenced from libaddressinput).
// Finally, we place the telephone and email fields at the end.
countrySpecificFieldsBefore: "tel",
fixedFields: [
{ fieldId: "name", newLine: true },
{ fieldId: "organization", newLine: true },
{ fieldId: "country", newLine: true },
{ fieldId: "tel", newLine: false },
{ fieldId: "email", newLine: true },
],
},
footer: {
mainAction: {
l10nId: "address-capture-save-button",
callbackState: "save",
},
secondaryActions: [
{
l10nId: "address-capture-cancel-button",
callbackState: "cancel",
dismiss: true,
},
],
},
options: {
autofocus: true,
persistWhileVisible: true,
hideClose: true,
},
},
addCreditCard: {
notificationId: "autofill-credit-card-add",
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: l10n.formatValueSync("autofill-message-tooltip"),
},
mainAction: {
label: GetStringFromName("saveCreditCardLabel"),
accessKey: GetStringFromName("saveCreditCardAccessKey"),
callbackState: "save",
confimationHintId: "confirmation-hint-credit-card-created",
},
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-update",
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: l10n.formatValueSync("autofill-message-tooltip"),
},
mainAction: {
label: GetStringFromName("updateCreditCardLabel"),
accessKey: GetStringFromName("updateCreditCardAccessKey"),
callbackState: "update",
confirmationHintId: "confirmation-hint-credit-card-updated",
},
secondaryActions: [
{
label: GetStringFromName("createCreditCardLabel"),
accessKey: GetStringFromName("createCreditCardAccessKey"),
callbackState: "create",
confirmationHintId: "confirmation-hint-credit-card-created",
},
],
options: {
persistWhileVisible: true,
popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
hideClose: true,
},
},
};
export let FormAutofillPrompter = {
/**
* 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;
},
_getNotificationElm(browser, id) {
let notificationId = id + "-notification";
let chromeDoc = browser.ownerDocument;
return chromeDoc.getElementById(notificationId);
},
_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);
}
},
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";
}
lazy.log.debug(
`Show the ${duplicateRecord ? "update" : "sace"} credit card doorhanger`
);
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 action = await FormAutofillPrompter._showCreditCardCaptureDoorhanger(
browser,
doorhangerType,
description,
flowId,
{ descriptionIcon }
);
lazy.log.debug(`Doorhanger action is ${action}`);
if (action == "cancel") {
return;
} else if (action == "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(
browser,
storage,
"credit-card",
action == "update" ? duplicateRecord : null,
record
);
},
// TODO: Simplify the code after integrating credit card prompt to use AutofillDoorhanger
async _updateStorageAfterInteractWithPrompt(
browser,
storage,
type,
oldRecord,
newRecord
) {
let changedGUID = null;
if (oldRecord) {
changedGUID = oldRecord.guid;
await storage.update(changedGUID, newRecord, true);
} else {
changedGUID = await storage.add(newRecord);
}
storage.notifyUsed(changedGUID);
const hintId = `confirmation-hint-${type}-${
oldRecord ? "updated" : "created"
}`;
showConfirmation(browser, hintId);
},
_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
* @returns {Promise} Resolved with action type when action callback is triggered.
*/
async _showCreditCardCaptureDoorhanger(
browser,
type,
description,
flowId,
{ descriptionIcon = null }
) {
const telemetryType = AutofillTelemetry.CREDIT_CARD;
const telemetryObject = type.startsWith("add")
? "capture_doorhanger"
: "update_doorhanger";
AutofillTelemetry.recordDoorhangerShown(
telemetryType,
telemetryObject,
flowId
);
return new Promise(resolve => {
let {
notificationId,
message,
descriptionLabel,
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 });
const DESCRIPTION_ID = "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
);
this._appendPrivacyPanelLink(
notificationContent,
linkMessage,
spotlightURL
);
notification.appendNotificationContent(notificationContent);
}
this._updateDescription(
notificationContent,
DESCRIPTION_ID,
description
);
};
AutofillDoorhanger.setAnchor(browser.ownerDocument, anchor);
chromeWin.PopupNotifications.show(
browser,
notificationId,
message,
anchor.id,
...AutofillDoorhanger.createActions(
mainAction,
secondaryActions,
resolve,
{ type: telemetryType, object: telemetryObject, flowId }
),
options
);
});
},
/**
* Show save or update address doorhanger
*
* @param {Element<browser>} browser Browser to show the save/update address prompt
* @param {object} storage Address storage
* @param {string} flowId Unique GUID to record a series of the same user action
* @param {object} options
* @param {object} [options.oldRecord] Record to be merged
* @param {object} [options.newRecord] Record with more information
*/
async promptToSaveAddress(
browser,
storage,
flowId,
{ oldRecord, newRecord }
) {
const showUpdateDoorhanger = !!Object.keys(oldRecord).length;
lazy.log.debug(
`Show the ${showUpdateDoorhanger ? "update" : "save"} address doorhanger`
);
const { ownerGlobal: chromeWin } = browser;
await chromeWin.ensureCustomElements("moz-support-link");
chromeWin.MozXULElement.insertFTLIfNeeded(
"browser/preferences/formAutofill.ftl"
);
let doorhanger;
let action;
while (true) {
doorhanger = showUpdateDoorhanger
? new AddressUpdateDoorhanger(browser, oldRecord, newRecord, flowId)
: new AddressSaveDoorhanger(browser, oldRecord, newRecord, flowId);
action = await doorhanger.show();
if (action == "edit-address") {
doorhanger = new AddressEditDoorhanger(
browser,
{ ...oldRecord, ...newRecord },
flowId
);
action = await doorhanger.show();
// If users cancel the edit address doorhanger, show the save/update
// doorhanger again.
if (action == "cancel") {
continue;
}
}
break;
}
lazy.log.debug(`Doorhanger action is ${action}`);
if (action == "cancel") {
return;
}
this._updateStorageAfterInteractWithPrompt(
browser,
storage,
"address",
showUpdateDoorhanger ? oldRecord : null,
doorhanger.recordToSave()
);
},
};