/* 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://gre/modules/shared/AutofillTelemetry.sys.mjs"; import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "log", () => FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter") ); const l10n = new Localization( [ "browser/preferences/formAutofill.ftl", "toolkit/formautofill/formAutofill.ftl", "branding/brand.ftl", ], true ); const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill; 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(`h1`); 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), }; this.#setAnchor(); return new Promise(resolve => { this.resolve = resolve; this.chromeWin.PopupNotifications.show( this.browser, this.ui.id, this.getNotificationHeader?.() ?? "", this.ui.anchor.id, ...this.#createActions(), 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. */ #setAnchor() { let anchor = this.doc.getElementById(this.ui.anchor.id); if (!anchor) { // Icon shown on URL bar anchor = this.doc.createXULElement("image"); anchor.id = this.ui.anchor.id; anchor.setAttribute("src", this.ui.anchor.URL); anchor.classList.add("notification-anchor-icon"); anchor.setAttribute("role", "button"); anchor.setAttribute("tooltiptext", this.ui.anchor.tooltiptext); const popupBox = this.doc.getElementById("notification-popup-box"); popupBox.appendChild(anchor); } } /** * Generate the main action and secondary actions from content parameters and * promise resolve. */ #createActions() { function getLabelAndAccessKey(param) { 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 mainActionParams = this.ui.footer.mainAction; const secondaryActionParams = this.ui.footer.secondaryActions; const callback = () => { AutofillTelemetry.recordDoorhangerClicked( this.constructor.telemetryType, mainActionParams.callbackState, this.constructor.telemetryObject, this.flowId ); this.resolve(mainActionParams.callbackState); }; const mainAction = { ...getLabelAndAccessKey(mainActionParams), callback, }; let secondaryActions = []; for (const params of secondaryActionParams) { const callback = () => { AutofillTelemetry.recordDoorhangerClicked( this.constructor.telemetryType, params.callbackState, this.constructor.telemetryObject, this.flowId ); this.resolve(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 * elements that represents the formatted line. * * @param {Array>} 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} An array of formatted text elements. */ #formatLine(datalist, showDiff) { const createSpan = (text, style = null) => { let s; if (showDiff) { if (style == "remove") { s = this.doc.createElement("del"); s.setAttribute("class", "address-update-text-diff-removed"); } else if (style == "add") { s = this.doc.createElement("mark"); s.setAttribute("class", "address-update-text-diff-added"); } else { s = this.doc.createElement("span"); } } else { s = this.doc.createElement("span"); } s.textContent = text; 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 "street-address": data = [ [ fieldName, FormAutofillUtils.toOneLineAddress( this.oldRecord["street-address"] ), FormAutofillUtils.toOneLineAddress( this.newRecord["street-address"] ), ], ]; break; case "address": data = [ [ "address-level2", this.oldRecord["address-level2"], this.newRecord["address-level2"], ], [ "address-level1", FormAutofillUtils.getAbbreviatedSubregionName( this.oldRecord["address-level1"], this.oldRecord.country ) || this.oldRecord["address-level1"], FormAutofillUtils.getAbbreviatedSubregionName( this.newRecord["address-level1"], this.newRecord.country ) || this.newRecord["address-level1"], ], [ "postal-code", this.oldRecord["postal-code"], this.newRecord["postal-code"], ], ]; break; case "name": 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); // ToDo: provide meaningful alt values (bug 1870155): img.setAttribute("alt", ""); section.appendChild(img); // Each line is consisted of multiple 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)) ); } #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); if (popup) { input.selectedItem = FormAutofillUtils.findAddressSelectOptionWithMenuPopup( popup, this.newRecord, fieldName ); } else { input.value = this.newRecord[fieldName] ?? ""; } 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"); } } } export class CreditCardSaveDoorhanger extends AutofillDoorhanger { static contentClass = "credit-card-capture-content"; static telemetryType = AutofillTelemetry.CREDIT_CARD; static telemetryObject = "capture_doorhanger"; static spotlightURL = "about:preferences#privacy-credit-card-autofill"; constructor(browser, oldRecord, newRecord, flowId) { super(browser, oldRecord, newRecord, flowId); } /** * We have not yet sync address and credit card design. After syncing, * we should be able to use the same "class" */ static content(panel) { return panel.querySelector(`.${CreditCardSaveDoorhanger.contentClass}`); } get content() { return CreditCardSaveDoorhanger.content(this.panel); } addCheckboxListener() { if (!this.ui.options.checkbox) { return; } const { checkbox } = this.panel; if (checkbox && !checkbox.hidden) { checkbox.addEventListener("command", 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); }); } } removeCheckboxListener() { if (!this.ui.options.checkbox) { return; } const { checkbox } = this.panel; if (checkbox && !checkbox.hidden) { checkbox.removeEventListener( "command", this.ui.options.checkbox.callback ); } } appendDescription() { const docFragment = this.doc.createDocumentFragment(); const label = this.doc.createXULElement("label"); this.doc.l10n.setAttributes(label, this.ui.description.l10nId); docFragment.appendChild(label); const descriptionWrapper = this.doc.createXULElement("hbox"); descriptionWrapper.className = "desc-message-box"; const number = this.newRecord["cc-number"] || this.newRecord["cc-number-decrypted"]; const name = this.newRecord["cc-name"]; const network = lazy.CreditCard.getType(number); const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network); if (descriptionIcon) { const icon = this.doc.createXULElement("image"); if ( typeof descriptionIcon == "string" && (descriptionIcon.includes("cc-logo") || descriptionIcon.includes("icon-credit")) ) { icon.setAttribute("src", descriptionIcon); } descriptionWrapper.appendChild(icon); } const description = this.doc.createXULElement("description"); description.textContent = `${lazy.CreditCard.getMaskedNumber(number)}` + (name ? `, ${name}` : ``); descriptionWrapper.appendChild(description); docFragment.appendChild(descriptionWrapper); this.content.appendChild(docFragment); } appendPrivacyPanelLink() { const privacyLinkElement = this.doc.createXULElement("label", { is: "text-link", }); privacyLinkElement.setAttribute("useoriginprincipal", true); privacyLinkElement.setAttribute( "href", CreditCardSaveDoorhanger.spotlightURL || "about:preferences#privacy-form-autofill" ); const linkId = `autofill-options-link${ AppConstants.platform == "macosx" ? "-osx" : "" }`; this.doc.l10n.setAttributes(privacyLinkElement, linkId); this.content.appendChild(privacyLinkElement); } // TODO: Currently, the header and description are unused. Align // these with the address doorhanger's implementation during // the credit card doorhanger redesign. getNotificationHeader() { return l10n.formatValueSync(this.ui.header.l10nId); } renderHeader() { // Not implement } renderDescription() { // Not implement } renderContent() { this.content.replaceChildren(); this.appendDescription(); this.appendPrivacyPanelLink(); } onEventCallback(state) { super.onEventCallback(state); if (state == "removed" || state == "dismissed") { this.removeCheckboxListener(); } else if (state == "shown") { this.addCheckboxListener(); } } // The record to be saved by this doorhanger recordToSave() { return this.newRecord; } } export class CreditCardUpdateDoorhanger extends CreditCardSaveDoorhanger { static telemetryType = AutofillTelemetry.CREDIT_CARD; static telemetryObject = "update_doorhanger"; constructor(browser, oldRecord, newRecord, flowId) { super(browser, oldRecord, newRecord, flowId); } } 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, }, }, [CreditCardSaveDoorhanger.name]: { id: "credit-card-save-update", anchor: { id: "autofill-credit-card-notification-icon", URL: "chrome://formautofill/content/formfill-anchor.svg", tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), }, header: { l10nId: "credit-card-save-doorhanger-header", }, description: { l10nId: "credit-card-save-doorhanger-description", }, content: {}, footer: { mainAction: { l10nId: "credit-card-capture-save-button", callbackState: "create", }, secondaryActions: [ { l10nId: "credit-card-capture-cancel-button", callbackState: "cancel", }, { l10nId: "credit-card-capture-never-save-button", 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" ) ? l10n.formatValueSync( "credit-card-doorhanger-credit-cards-sync-checkbox" ) : null; }, }, }, }, [CreditCardUpdateDoorhanger.name]: { id: "credit-card-save-update", anchor: { id: "autofill-credit-card-notification-icon", URL: "chrome://formautofill/content/formfill-anchor.svg", tooltiptext: l10n.formatValueSync("autofill-message-tooltip"), }, header: { l10nId: "credit-card-update-doorhanger-header", }, description: { l10nId: "credit-card-update-doorhanger-description", }, content: {}, footer: { mainAction: { l10nId: "credit-card-capture-update-button", callbackState: "update", }, secondaryActions: [ { l10nId: "credit-card-capture-save-new-button", callbackState: "create", }, ], }, options: { persistWhileVisible: true, popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", hideClose: true, }, }, }; export let FormAutofillPrompter = { async promptToSaveCreditCard( browser, storage, flowId, { oldRecord, newRecord } ) { const showUpdateDoorhanger = !!Object.keys(oldRecord).length; const { ownerGlobal: win } = browser; win.MozXULElement.insertFTLIfNeeded( "toolkit/formautofill/formAutofill.ftl" ); let action; const doorhanger = showUpdateDoorhanger ? new CreditCardUpdateDoorhanger(browser, oldRecord, newRecord, flowId) : new CreditCardSaveDoorhanger(browser, oldRecord, newRecord, flowId); action = await doorhanger.show(); 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 lazy.OSKeyStore.ensureLoggedIn(false)).authenticated) { lazy.log.warn("User canceled encryption login"); return; } this._updateStorageAfterInteractWithPrompt( browser, storage, "credit-card", action == "update" ? oldRecord : null, doorhanger.recordToSave() ); }, /** * Show save or update address doorhanger * * @param {Element} 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: win } = browser; win.MozXULElement.insertFTLIfNeeded( "toolkit/formautofill/formAutofill.ftl" ); // address-autofill-* are defined in browser/preferences now win.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() ); }, // 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); }, };