/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", }); ChromeUtils.defineLazyGetter( lazy, "l10n", () => new Localization(["toolkit/formautofill/formAutofill.ftl"], true) ); class ProfileAutoCompleteResult { externalEntries = []; 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 { this.searchResult = matchingProfiles.length ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; } // An array of primary and secondary labels for each profile. this._popupLabels = this._generateLabels( this._focusedFieldName, this._allFieldNames, this._matchingProfiles ); } getAt(index) { for (const group of [this._popupLabels, this.externalEntries]) { if (index < group.length) { return group[index]; } index -= group.length; } throw Components.Exception( "Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE ); } /** * @returns {number} The number of results */ get matchCount() { return this._popupLabels.length + this.externalEntries.length; } /** * 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} _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.getAt(index); return ""; } getLabelAt(index) { const label = this.getAt(index); if (typeof label == "string") { return label; } let type = this.getTypeOfIndex(index); if (type == "clear" || type == "manage") { return label.primary; } 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) { let type = this.getTypeOfIndex(index); switch (type) { case "clear": return '{"fillMessageName": "FormAutofill:ClearForm"}'; case "manage": return '{"fillMessageName": "FormAutofill:OpenPreferences"}'; case "insecure": return '{"noLearnMore": true }'; } const item = this.getAt(index); return item.comment ?? 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) { const itemStyle = this.getAt(index).style; if (itemStyle) { return itemStyle; } switch (this.getTypeOfIndex(index)) { case "manage": return "action"; case "clear": return "action"; case "insecure": return "insecureWarning"; default: return "autofill"; } } /** * 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) { return this.getAt(index).image ?? ""; } /** * 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 false; } /** * 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. } /** * Returns a type string that identifies te type of row at the given index. * * @param {number} index The index of the result requested * @returns {string} The type at the specified index */ getTypeOfIndex(index) { if (this._isInputAutofilled && index == 0) { return "clear"; } if (index == this._popupLabels.length - 1) { return "manage"; } return "item"; } } export class AddressResult extends ProfileAutoCompleteResult { _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) { const manageLabel = lazy.l10n.formatValueSync( "autofill-manage-addresses-label" ); let footerItem = { primary: manageLabel, secondary: "", }; if (this._isInputAutofilled) { const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label"); let labels = [ { primary: clearLabel, }, ]; labels.push(footerItem); return labels; } let focusedCategory = lazy.FormAutofillUtils.getCategoryFromFieldName(focusedFieldName); // 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"]; } let profileFields = allFieldNames.filter( fieldName => !!profile[fieldName] ); let categories = lazy.FormAutofillUtils.getCategoriesFromFieldNames(profileFields); let status = this.getStatusNote(categories, focusedCategory); let secondary = this._getSecondaryLabel( focusedFieldName, allFieldNames, profile ); const ariaLabel = [primaryLabel, secondary, status] .filter(chunk => !!chunk) // Exclude empty chunks. .join(" "); return { primary: primaryLabel, secondary, status, ariaLabel, }; }); let allCategories = lazy.FormAutofillUtils.getCategoriesFromFieldNames(allFieldNames); if (allCategories && allCategories.length) { let statusItem = { primary: "", secondary: "", status: this.getStatusNote(allCategories, focusedCategory), style: "status", }; labels.push(statusItem); } labels.push(footerItem); return labels; } getStatusNote(categories, focusedCategory) { if (!categories || !categories.length) { return ""; } // If the length of categories is 1, that means all the fillable fields are in the same // category. We will change the way to inform user according to this flag. When the value // is true, we show "Also autofills ...", otherwise, show "Autofills ..." only. let hasExtraCategories = categories.length > 1; // Show the categories in certain order to conform with the spec. let orderedCategoryList = [ "address", "name", "organization", "tel", "email", ]; let showCategories = hasExtraCategories ? orderedCategoryList.filter( category => categories.includes(category) && category != focusedCategory ) : [orderedCategoryList.find(category => category == focusedCategory)]; let formatter = new Intl.ListFormat(undefined, { style: "narrow", }); let categoriesText = showCategories.map(category => lazy.l10n.formatValueSync("autofill-category-" + category) ); categoriesText = formatter.format(categoriesText); let statusTextTmplKey = hasExtraCategories ? "autofill-phishing-warningmessage-extracategory" : "autofill-phishing-warningmessage"; return lazy.l10n.formatValueSync(statusTextTmplKey, { categories: categoriesText, }); } } export class CreditCardResult extends ProfileAutoCompleteResult { _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") { return lazy.CreditCard.formatMaskedNumber(profile[currentFieldName]); } 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] ), ]; } const manageLabel = lazy.l10n.formatValueSync( "autofill-manage-payment-methods-label" ); let footerItem = { primary: manageLabel, }; if (this._isInputAutofilled) { const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label"); let labels = [ { primary: clearLabel, }, ]; labels.push(footerItem); return labels; } // Skip results without a primary label. let labels = profiles .filter(profile => { return !!profile[focusedFieldName]; }) .map(profile => { let primary = profile[focusedFieldName]; if (focusedFieldName == "cc-number") { primary = lazy.CreditCard.formatMaskedNumber(primary); } 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 image = lazy.CreditCard.getCreditCardLogo(ccType); const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType); const ccTypeName = ccTypeL10nId ? lazy.l10n.formatValueSync(ccTypeL10nId) : ccType ?? ""; // Unknown card type const ariaLabel = [ ccTypeName, primary.toString().replaceAll("*", ""), secondary, ] .filter(chunk => !!chunk) // Exclude empty chunks. .join(" "); return { primary: primary.toString().replaceAll("*", "•"), secondary: secondary.toString().replaceAll("*", "•"), ariaLabel, image, }; }); labels.push(footerItem); return labels; } getTypeOfIndex(index) { if (!this._isSecure) { return "insecure"; } return super.getTypeOfIndex(index); } }