Files
tubestation/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
Dimi 5c286f71aa Bug 1898745 - P1. Store DOM content reference id in FieldDetail structure r=NeilDeakin,credential-management-reviewers
In order to run the field detection heuristics in the parent process,
the parent requires a unique identifier for DOM elements to facilitate
communication with the child.

Therefore, we utilize the id from ContentDOMReference as the unique identifier.
This identifier is stored in the FieldDetail structure, which the formautofill
module uses to maintain all information related to an identified field.

Differential Revision: https://phabricator.services.mozilla.com/D211550
2024-07-09 10:34:22 +00:00

585 lines
16 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/. */
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,
focusedFieldDetail,
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 = focusedFieldDetail.fieldName;
// The content dom reference id of the focused input.
this._focusedElementId = focusedFieldDetail.elementId;
// 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<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.getAt(index);
return "";
}
getLabelAt(index) {
const label = this.getAt(index);
return typeof label == "string" ? label : label.primary;
}
/**
* Retrieves a comment (metadata instance)
*
* @param {number} index The index of the comment requested
* @returns {string} The comment at the specified index
*/
getCommentAt(index) {
const item = this.getAt(index);
if (item.style == "status") {
return JSON.stringify(item);
}
const data = {
fillMessageData: {
focusElementId: this._focusedElementId,
},
};
const type = this.getTypeOfIndex(index);
switch (type) {
case "clear":
data.fillMessageName = "FormAutofill:ClearForm";
break;
case "manage":
data.fillMessageName = "FormAutofill:OpenPreferences";
break;
case "insecure":
data.noLearnMore = true;
break;
default: {
if (item.comment) {
return item.comment;
}
data.fillMessageName = "FormAutofill:FillForm";
data.fillMessageData.profile = this._matchingProfiles[index];
break;
}
}
return JSON.stringify({ ...item, ...data });
}
/**
* 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);
}
}