Source code
Revision control
Copy as Markdown
Other Tools
/* 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
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,
fillCategories,
{ 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));
this._fillCategories = fillCategories;
// 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,
this._fillCategories
);
}
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,
_fillCategories
) {}
/**
* 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 item = this.getAt(index);
return typeof item == "string" ? item : item.primary || item.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) {
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, fillCategories) {
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;
}
const focusedCategory =
lazy.FormAutofillUtils.getCategoryFromFieldName(focusedFieldName);
const labels = [];
for (let idx = 0; idx < profiles.length; idx++) {
const profile = profiles[idx];
let primary = profile[focusedFieldName];
// Skip results without a primary label.
if (!primary) {
continue;
}
if (
focusedFieldName == "street-address" &&
profile["-moz-street-address-one-line"]
) {
primary = profile["-moz-street-address-one-line"];
}
const status = this.getStatusNote(fillCategories[idx], focusedCategory);
const secondary = this._getSecondaryLabel(
focusedFieldName,
allFieldNames,
profile
);
// Exclude empty chunks.
const ariaLabel = [primary, secondary, status]
.filter(chunk => !!chunk)
.join(" ");
labels.push({
primary,
secondary,
status,
ariaLabel,
});
}
const allCategories =
lazy.FormAutofillUtils.getCategoriesFromFieldNames(allFieldNames);
if (allCategories?.length) {
const 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, _fillCategories) {
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);
}
}