summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs')
-rw-r--r--toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs485
1 files changed, 485 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
new file mode 100644
index 0000000000..6707af7a58
--- /dev/null
+++ b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
@@ -0,0 +1,485 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/preferences/formAutofill.ftl"], true)
+);
+
+class ProfileAutoCompleteResult {
+ constructor(
+ searchString,
+ focusedFieldName,
+ allFieldNames,
+ matchingProfiles,
+ { resultCode = null, isSecure = true, isInputAutofilled = false }
+ ) {
+ // nsISupports
+ this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]);
+
+ // The user's query string
+ this.searchString = searchString;
+ // The field name of the focused input.
+ this._focusedFieldName = focusedFieldName;
+ // The matching profiles contains the information for filling forms.
+ this._matchingProfiles = matchingProfiles;
+ // The default item that should be entered if none is selected
+ this.defaultIndex = 0;
+ // The reason the search failed
+ this.errorDescription = "";
+ // The value used to determine whether the form is secure or not.
+ this._isSecure = isSecure;
+ // The value to indicate whether the focused input has been autofilled or not.
+ this._isInputAutofilled = isInputAutofilled;
+ // All fillable field names in the form including the field name of the currently-focused input.
+ this._allFieldNames = [
+ ...this._matchingProfiles.reduce((fieldSet, curProfile) => {
+ for (let field of Object.keys(curProfile)) {
+ fieldSet.add(field);
+ }
+
+ return fieldSet;
+ }, new Set()),
+ ].filter(field => allFieldNames.includes(field));
+
+ // Force return success code if the focused field is auto-filled in order
+ // to show clear form button popup.
+ if (isInputAutofilled) {
+ resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ }
+ // The result code of this result object.
+ if (resultCode) {
+ this.searchResult = resultCode;
+ } else if (matchingProfiles.length) {
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ } else {
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ }
+
+ // An array of primary and secondary labels for each profile.
+ this._popupLabels = this._generateLabels(
+ this._focusedFieldName,
+ this._allFieldNames,
+ this._matchingProfiles
+ );
+ }
+
+ /**
+ * @returns {number} The number of results
+ */
+ get matchCount() {
+ return this._popupLabels.length;
+ }
+
+ _checkIndexBounds(index) {
+ if (index < 0 || index >= this._popupLabels.length) {
+ throw Components.Exception(
+ "Index out of range.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+
+ /**
+ * Get the secondary label based on the focused field name and related field names
+ * in the same form.
+ *
+ * @param {string} focusedFieldName The field name of the focused input
+ * @param {Array<object>} allFieldNames The field names in the same section
+ * @param {object} profile The profile providing the labels to show.
+ * @returns {string} The secondary label
+ */
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ return "";
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {}
+
+ /**
+ * Get the value of the result at the given index.
+ *
+ * Always return empty string for form autofill feature to suppress
+ * AutoCompleteController from autofilling, as we'll populate the
+ * fields on our own.
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getValueAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ }
+
+ getLabelAt(index) {
+ this._checkIndexBounds(index);
+
+ let label = this._popupLabels[index];
+ if (typeof label == "string") {
+ return label;
+ }
+ return JSON.stringify(label);
+ }
+
+ /**
+ * Retrieves a comment (metadata instance)
+ *
+ * @param {number} index The index of the comment requested
+ * @returns {string} The comment at the specified index
+ */
+ getCommentAt(index) {
+ this._checkIndexBounds(index);
+ return JSON.stringify(this._matchingProfiles[index]);
+ }
+
+ /**
+ * Retrieves a style hint specific to a particular index.
+ *
+ * @param {number} index The index of the style hint requested
+ * @returns {string} The style hint at the specified index
+ */
+ getStyleAt(index) {
+ this._checkIndexBounds(index);
+ if (index == this.matchCount - 1) {
+ return "autofill-footer";
+ }
+ if (this._isInputAutofilled) {
+ return "autofill-clear-button";
+ }
+
+ return "autofill-profile";
+ }
+
+ /**
+ * Retrieves an image url.
+ *
+ * @param {number} index The index of the image url requested
+ * @returns {string} The image url at the specified index
+ */
+ getImageAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ }
+
+ /**
+ * Retrieves a result
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ }
+
+ /**
+ * Returns true if the value at the given index is removable
+ *
+ * @param {number} index The index of the result to remove
+ * @returns {boolean} True if the value is removable
+ */
+ isRemovableAt(index) {
+ return true;
+ }
+
+ /**
+ * Removes a result from the resultset
+ *
+ * @param {number} index The index of the result to remove
+ */
+ removeValueAt(index) {
+ // There is no plan to support removing profiles via autocomplete.
+ }
+}
+
+export class AddressResult extends ProfileAutoCompleteResult {
+ constructor(...args) {
+ super(...args);
+ }
+
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ // We group similar fields into the same field name so we won't pick another
+ // field in the same group as the secondary label.
+ const GROUP_FIELDS = {
+ name: ["name", "given-name", "additional-name", "family-name"],
+ "street-address": [
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ ],
+ "country-name": ["country", "country-name"],
+ tel: [
+ "tel",
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+ ],
+ };
+
+ const secondaryLabelOrder = [
+ "street-address", // Street address
+ "name", // Full name
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country-name", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (
+ currentFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ return profile["-moz-street-address-one-line"];
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primaryLabel = profile[focusedFieldName];
+ if (
+ focusedFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ primaryLabel = profile["-moz-street-address-one-line"];
+ }
+ return {
+ primary: primaryLabel,
+ secondary: this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ ),
+ };
+ });
+ // Add an empty result entry for footer. Its content will come from
+ // the footer binding, so don't assign any value to it.
+ // The additional properties: categories and focusedCategory are required of
+ // the popup to generate autofill hint on the footer.
+ labels.push({
+ primary: "",
+ secondary: "",
+ categories: lazy.FormAutofillUtils.getCategoriesFromFieldNames(
+ this._allFieldNames
+ ),
+ focusedCategory: lazy.FormAutofillUtils.getCategoryFromFieldName(
+ this._focusedFieldName
+ ),
+ });
+
+ return labels;
+ }
+}
+
+export class CreditCardResult extends ProfileAutoCompleteResult {
+ constructor(...args) {
+ super(...args);
+ this._cardTypes = this._generateCardTypes(
+ this._focusedFieldName,
+ this._allFieldNames,
+ this._matchingProfiles
+ );
+ }
+
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ const GROUP_FIELDS = {
+ "cc-name": [
+ "cc-name",
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ ],
+ "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"],
+ };
+
+ const secondaryLabelOrder = [
+ "cc-number", // Credit card number
+ "cc-name", // Full name
+ "cc-exp", // Expiration date
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (currentFieldName == "cc-number") {
+ let { affix, label } = lazy.CreditCard.formatMaskedNumber(
+ profile[currentFieldName]
+ );
+ return affix + label;
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (!this._isSecure) {
+ let brandName =
+ lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+
+ return [
+ lazy.FormAutofillUtils.stringBundle.formatStringFromName(
+ "insecureFieldWarningDescription",
+ [brandName]
+ ),
+ ];
+ }
+
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primaryAffix;
+ let primary = profile[focusedFieldName];
+
+ if (focusedFieldName == "cc-number") {
+ let { affix, label } = lazy.CreditCard.formatMaskedNumber(primary);
+ primaryAffix = affix;
+ primary = label;
+ }
+ const secondary = this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ );
+ // The card type is displayed visually using an image. For a11y, we need
+ // to expose it as text. We do this using aria-label. However,
+ // aria-label overrides the text content, so we must include that also.
+ const ccType = profile["cc-type"];
+ const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType);
+ const ccTypeName = ccTypeL10nId
+ ? lazy.l10n.formatValueSync(ccTypeL10nId)
+ : ccType ?? ""; // Unknown card type
+ const ariaLabel = [ccTypeName, primaryAffix, primary, secondary]
+ .filter(chunk => !!chunk) // Exclude empty chunks.
+ .join(" ");
+ return {
+ primaryAffix,
+ primary,
+ secondary,
+ ariaLabel,
+ };
+ });
+ // Add an empty result entry for footer.
+ labels.push({ primary: "", secondary: "" });
+
+ return labels;
+ }
+
+ // This method needs to return an array that parallels the
+ // array returned by _generateLabels, above. As a consequence,
+ // its logic follows very closely.
+ _generateCardTypes(focusedFieldName, allFieldNames, profiles) {
+ if (this._isInputAutofilled) {
+ return [
+ "", // Clear button
+ "", // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let cardTypes = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => profile["cc-type"]);
+
+ // Add an empty result entry for footer.
+ cardTypes.push("");
+ return cardTypes;
+ }
+
+ getStyleAt(index) {
+ this._checkIndexBounds(index);
+ if (!this._isSecure) {
+ return "autofill-insecureWarning";
+ }
+
+ return super.getStyleAt(index);
+ }
+
+ getImageAt(index) {
+ this._checkIndexBounds(index);
+ let network = this._cardTypes[index];
+ return lazy.CreditCard.getCreditCardLogo(network);
+ }
+}