summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel')
-rw-r--r--toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs489
-rw-r--r--toolkit/components/satchel/FormHistoryChild.sys.mjs154
-rw-r--r--toolkit/components/satchel/FormHistoryParent.sys.mjs123
-rw-r--r--toolkit/components/satchel/components.conf2
-rw-r--r--toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs1
-rw-r--r--toolkit/components/satchel/jar.mn2
-rw-r--r--toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs28
-rw-r--r--toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs14
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs168
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs302
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs41
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs522
-rw-r--r--toolkit/components/satchel/megalist/content/Dialog.mjs116
-rw-r--r--toolkit/components/satchel/megalist/content/MegalistView.mjs98
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.css93
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.ftl28
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.html175
-rw-r--r--toolkit/components/satchel/megalist/content/search-input.mjs36
-rw-r--r--toolkit/components/satchel/nsFormFillController.cpp249
-rw-r--r--toolkit/components/satchel/nsFormFillController.h14
-rw-r--r--toolkit/components/satchel/nsIFormFillController.idl26
-rw-r--r--toolkit/components/satchel/test/browser/browser.toml11
-rw-r--r--toolkit/components/satchel/test/browser/browser_autocomplete.js403
-rw-r--r--toolkit/components/satchel/test/browser/browser_popup_mouseover.js8
-rw-r--r--toolkit/components/satchel/test/browser/formhistory_autocomplete.sqlitebin0 -> 72704 bytes
-rw-r--r--toolkit/components/satchel/test/unit/test_autocomplete.js388
-rw-r--r--toolkit/components/satchel/test/unit/test_previous_result.js25
-rw-r--r--toolkit/components/satchel/test/unit/xpcshell.toml15
28 files changed, 1786 insertions, 1745 deletions
diff --git a/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs
index 2799d2e955..3bdff897d5 100644
--- a/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs
+++ b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs
@@ -3,187 +3,12 @@
* 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 {
- GenericAutocompleteItem,
- sendFillRequestToParent,
-} from "resource://gre/modules/FillHelpers.sys.mjs";
-
-const lazy = {};
-
-ChromeUtils.defineESModuleGetters(lazy, {
- FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
-});
+import { sendFillRequestToParent } from "resource://gre/modules/FillHelpers.sys.mjs";
const formFillController = Cc[
"@mozilla.org/satchel/form-fill-controller;1"
].getService(Ci.nsIFormFillController);
-function isAutocompleteDisabled(aField) {
- if (!aField) {
- return false;
- }
-
- if (aField.autocomplete !== "") {
- return aField.autocomplete === "off";
- }
-
- return aField.form?.autocomplete === "off";
-}
-
-/**
- * An abstraction to talk with the FormHistory database over
- * the message layer. FormHistoryClient will take care of
- * figuring out the most appropriate message manager to use,
- * and what things to send.
- *
- * It is assumed that FormHistoryAutoComplete will only ever use
- * one instance at a time, and will not attempt to perform more
- * than one search request with the same instance at a time.
- * However, FormHistoryAutoComplete might call remove() any number of
- * times with the same instance of the client.
- *
- * @param {object} clientInfo
- * Info required to build the FormHistoryClient
- * @param {Node} clientInfo.formField
- * A DOM node that we're requesting form history for.
- * @param {string} clientInfo.inputName
- * The name of the input to do the FormHistory look-up with.
- * If this is searchbar-history, then formField needs to be null,
- * otherwise constructing will throw.
- */
-export class FormHistoryClient {
- constructor({ formField, inputName }) {
- if (formField) {
- if (inputName == this.SEARCHBAR_ID) {
- throw new Error(
- "FormHistoryClient constructed with both a formField and an inputName. " +
- "This is not supported, and only empty results will be returned."
- );
- }
- const window = formField.ownerGlobal;
- this.windowGlobal = window.windowGlobalChild;
- }
-
- this.inputName = inputName;
- this.id = FormHistoryClient.nextRequestID++;
- }
-
- static nextRequestID = 1;
- SEARCHBAR_ID = "searchbar-history";
- cancelled = false;
- inputName = "";
-
- getActor() {
- return this.windowGlobal?.getActor("FormHistory");
- }
-
- /**
- * Query FormHistory for some results.
- *
- * @param {string} searchString
- * The string to search FormHistory for. See
- * FormHistory.getAutoCompleteResults.
- * @param {object} params
- * An Object with search properties. See
- * FormHistory.getAutoCompleteResults.
- * @param {string} scenarioName
- * Optional autocompletion scenario name.
- * @param {Function} callback
- * A callback function that will take a single
- * argument (the found entries).
- */
- requestAutoCompleteResults(searchString, params, scenarioName, callback) {
- this.cancelled = false;
-
- // Use the actor if possible, otherwise for the searchbar,
- // use the more roundabout per-process message manager which has
- // no sendQuery method.
- const actor = this.getActor();
- if (actor) {
- actor
- .sendQuery("FormHistory:AutoCompleteSearchAsync", {
- searchString,
- params,
- scenarioName,
- })
- .then(
- results => this.handleAutoCompleteResults(results, callback),
- () => this.cancel()
- );
- } else {
- this.callback = callback;
- Services.cpmm.addMessageListener(
- "FormHistory:AutoCompleteSearchResults",
- this
- );
- Services.cpmm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", {
- id: this.id,
- searchString,
- params,
- scenarioName,
- });
- }
- }
-
- handleAutoCompleteResults(results, callback) {
- if (this.cancelled) {
- return;
- }
-
- if (!callback) {
- console.error("FormHistoryClient received response with no callback");
- return;
- }
-
- callback(results);
- this.cancel();
- }
-
- /**
- * Cancel an in-flight results request. This ensures that the
- * callback that requestAutoCompleteResults was passed is never
- * called from this FormHistoryClient.
- */
- cancel() {
- if (this.callback) {
- Services.cpmm.removeMessageListener(
- "FormHistory:AutoCompleteSearchResults",
- this
- );
- this.callback = null;
- }
- this.cancelled = true;
- }
-
- /**
- * Remove an item from FormHistory.
- *
- * @param {string} value
- *
- * The value to remove for this particular
- * field.
- *
- * @param {string} guid
- *
- * The guid for the item being removed.
- */
- remove(value, guid) {
- const actor = this.getActor() || Services.cpmm;
- actor.sendAsyncMessage("FormHistory:RemoveEntry", {
- inputName: this.inputName,
- value,
- guid,
- });
- }
-
- receiveMessage(msg) {
- const { id, results } = msg.data;
- if (id == this.id) {
- this.handleAutoCompleteResults(results, this.callback);
- }
- }
-}
-
/**
* This autocomplete result combines 3 arrays of entries, fixedEntries and
* externalEntries.
@@ -197,10 +22,10 @@ export class FormHistoryClient {
* @implements {nsIAutoCompleteResult}
*/
export class FormHistoryAutoCompleteResult {
- constructor(client, entries, fieldName, searchString) {
- this.client = client;
+ constructor(input, entries, inputName, searchString) {
+ this.input = input;
this.entries = entries;
- this.fieldName = fieldName;
+ this.inputName = inputName;
this.searchString = searchString;
}
@@ -210,9 +35,9 @@ export class FormHistoryAutoCompleteResult {
]);
// private
- client = null;
+ input = null;
entries = null;
- fieldName = null;
+ inputName = null;
#fixedEntries = [];
externalEntries = [];
@@ -221,68 +46,6 @@ export class FormHistoryAutoCompleteResult {
this.removeDuplicateHistoryEntries();
}
- canSearchIncrementally(searchString) {
- const prevSearchString = this.searchString.trim();
- return (
- prevSearchString.length > 1 &&
- searchString.includes(prevSearchString.toLowerCase())
- );
- }
-
- incrementalSearch(searchString) {
- this.searchString = searchString;
- searchString = searchString.trim().toLowerCase();
- this.#fixedEntries = this.#fixedEntries.filter(item =>
- item.label.toLowerCase().includes(searchString)
- );
-
- const searchTokens = searchString.split(/\s+/);
- // We have a list of results for a shorter search string, so just
- // filter them further based on the new search string and add to a new array.
- let filteredEntries = [];
- for (const entry of this.entries) {
- // Remove results that do not contain the token
- // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars
- if (searchTokens.some(tok => !entry.textLowerCase.includes(tok))) {
- continue;
- }
- this.#calculateScore(entry, searchString, searchTokens);
- filteredEntries.push(entry);
- }
- filteredEntries.sort((a, b) => b.totalScore - a.totalScore);
- this.entries = filteredEntries;
- this.removeDuplicateHistoryEntries();
- }
-
- /*
- * #calculateScore
- *
- * entry -- an nsIAutoCompleteResult entry
- * aSearchString -- current value of the input (lowercase)
- * searchTokens -- array of tokens of the search string
- *
- * Returns: an int
- */
- #calculateScore(entry, aSearchString, searchTokens) {
- let boundaryCalc = 0;
- // for each word, calculate word boundary weights
- for (const token of searchTokens) {
- if (entry.textLowerCase.startsWith(token)) {
- boundaryCalc++;
- }
- if (entry.textLowerCase.includes(" " + token)) {
- boundaryCalc++;
- }
- }
- boundaryCalc = boundaryCalc * this._boundaryWeight;
- // now add more weight if we have a traditional prefix match and
- // multiply boundary bonuses by boundary weight
- if (entry.textLowerCase.startsWith(aSearchString)) {
- boundaryCalc += this._prefixWeight;
- }
- entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
- }
-
/**
* Remove items from history list that are already present in fixed list.
* We do this rather than the opposite ( i.e. remove items from fixed list)
@@ -389,7 +152,13 @@ export class FormHistoryAutoCompleteResult {
removeValueAt(index) {
if (this.#isFormHistoryEntry(index)) {
const [removedEntry] = this.entries.splice(index, 1);
- this.client.remove(removedEntry.text, removedEntry.guid);
+ const actor =
+ this.input.ownerGlobal.windowGlobalChild.getActor("FormHistory");
+ actor.sendAsyncMessage("FormHistory:RemoveEntry", {
+ inputName: this.inputName,
+ value: removedEntry.text,
+ guid: removedEntry.guid,
+ });
}
}
@@ -400,13 +169,6 @@ export class FormHistoryAutoCompleteResult {
export class FormHistoryAutoComplete {
constructor() {
- // Preferences. Add observer so we get notified of changes.
- this._prefBranch = Services.prefs.getBranch("browser.formfill.");
- this._prefBranch.addObserver("", this.observer, true);
- this.observer._self = this;
-
- this._debug = this._prefBranch.getBoolPref("debug");
- this._enabled = this._prefBranch.getBoolPref("enable");
Services.obs.addObserver(this, "autocomplete-will-enter-text");
}
@@ -416,237 +178,12 @@ export class FormHistoryAutoComplete {
"nsISupportsWeakReference",
]);
- // Only one query via FormHistoryClient is performed at a time, and the
- // most recent FormHistoryClient which will be stored in _pendingClient
- // while the query is being performed. It will be cleared when the query
- // finishes, is cancelled, or an error occurs. If a new query occurs while
- // one is already pending, the existing one is cancelled.
- #pendingClient = null;
-
- observer = {
- _self: null,
-
- QueryInterface: ChromeUtils.generateQI([
- "nsIObserver",
- "nsISupportsWeakReference",
- ]),
-
- observe(_subject, topic, data) {
- const self = this._self;
-
- if (topic == "nsPref:changed") {
- const prefName = data;
- self.log(`got change to ${prefName} preference`);
-
- switch (prefName) {
- case "debug":
- self._debug = self._prefBranch.getBoolPref(prefName);
- break;
- case "enable":
- self._enabled = self._prefBranch.getBoolPref(prefName);
- break;
- }
- }
- },
- };
-
// AutoCompleteE10S needs to be able to call autoCompleteSearchAsync without
// going through IDL in order to pass a mock DOM object field.
get wrappedJSObject() {
return this;
}
- /*
- * log
- *
- * Internal function for logging debug messages to the Error Console
- * window
- */
- log(message) {
- if (!this._debug) {
- return;
- }
- Services.console.logStringMessage("FormHistoryAutoComplete: " + message);
- }
-
- /*
- * autoCompleteSearchAsync
- *
- * aInputName -- |name| or |id| attribute value from the form input being
- * autocompleted
- * aUntrimmedSearchString -- current value of the input
- * aField -- HTMLInputElement being autocompleted (may be null if from chrome)
- * aPreviousResult -- previous search result, if any.
- * aAddDataList -- add results from list=datalist for aField.
- * aListener -- nsIFormHistoryAutoCompleteObserver that listens for the nsIAutoCompleteResult
- * that may be returned asynchronously.
- */
- autoCompleteSearchAsync(
- aInputName,
- aUntrimmedSearchString,
- aField,
- aPreviousResult,
- aAddDataList,
- aListener
- ) {
- // Guard against void DOM strings filtering into this code.
- if (typeof aInputName === "object") {
- aInputName = "";
- }
- if (typeof aUntrimmedSearchString === "object") {
- aUntrimmedSearchString = "";
- }
-
- const client = new FormHistoryClient({
- formField: aField,
- inputName: aInputName,
- });
-
- function reportSearchResult(result) {
- aListener?.onSearchCompletion(result);
- }
-
- // If we have datalist results, they become our "empty" result.
- const result = new FormHistoryAutoCompleteResult(
- client,
- [],
- aInputName,
- aUntrimmedSearchString
- );
-
- if (aAddDataList) {
- result.fixedEntries = this.getDataListSuggestions(aField);
- }
-
- if (!this._enabled) {
- reportSearchResult(result);
- return;
- }
-
- // Don't allow form inputs (aField != null) to get results from
- // search bar history.
- if (aInputName == "searchbar-history" && aField) {
- this.log(`autoCompleteSearch for input name "${aInputName}" is denied`);
- reportSearchResult(result);
- return;
- }
-
- if (isAutocompleteDisabled(aField)) {
- this.log("autoCompleteSearch not allowed due to autcomplete=off");
- reportSearchResult(result);
- return;
- }
-
- const searchString = aUntrimmedSearchString.trim().toLowerCase();
- const prevResult = aPreviousResult?.wrappedJSObject;
- if (prevResult?.canSearchIncrementally(searchString)) {
- this.log("Using previous autocomplete result");
- prevResult.incrementalSearch(aUntrimmedSearchString);
- reportSearchResult(prevResult);
- } else {
- this.log("Creating new autocomplete search result.");
- this.getAutoCompleteValues(
- client,
- aInputName,
- searchString,
- lazy.FormScenarios.detect({ input: aField }).signUpForm
- ? "SignUpFormScenario"
- : "",
- ({ formHistoryEntries, externalEntries }) => {
- formHistoryEntries ??= [];
- externalEntries ??= [];
-
- if (aField?.maxLength > -1) {
- result.entries = formHistoryEntries.filter(
- el => el.text.length <= aField.maxLength
- );
- } else {
- result.entries = formHistoryEntries;
- }
-
- result.externalEntries.push(
- ...externalEntries.map(
- entry =>
- new GenericAutocompleteItem(
- entry.image,
- entry.title,
- entry.subtitle,
- entry.fillMessageName,
- entry.fillMessageData
- )
- )
- );
-
- result.removeDuplicateHistoryEntries();
- reportSearchResult(result);
- }
- );
- }
- }
-
- getDataListSuggestions(aField) {
- const items = [];
-
- if (!aField?.list) {
- return items;
- }
-
- const upperFieldValue = aField.value.toUpperCase();
-
- for (const option of aField.list.options) {
- const label = option.label || option.text || option.value || "";
-
- if (!label.toUpperCase().includes(upperFieldValue)) {
- continue;
- }
-
- items.push({
- label,
- value: option.value,
- });
- }
-
- return items;
- }
-
- stopAutoCompleteSearch() {
- if (this.#pendingClient) {
- this.#pendingClient.cancel();
- this.#pendingClient = null;
- }
- }
-
- /*
- * Get the values for an autocomplete list given a search string.
- *
- * client - a FormHistoryClient instance to perform the search with
- * fieldname - fieldname field within form history (the form input name)
- * searchString - string to search for
- * scenarioName - Optional autocompletion scenario name.
- * callback - called when the values are available. Passed an array of objects,
- * containing properties for each result. The callback is only called
- * when successful.
- */
- getAutoCompleteValues(
- client,
- fieldname,
- searchString,
- scenarioName,
- callback
- ) {
- this.stopAutoCompleteSearch();
- client.requestAutoCompleteResults(
- searchString,
- { fieldname },
- scenarioName,
- entries => {
- this.#pendingClient = null;
- callback(entries);
- }
- );
- this.#pendingClient = client;
- }
-
async observe(subject, topic, data) {
switch (topic) {
case "autocomplete-will-enter-text": {
diff --git a/toolkit/components/satchel/FormHistoryChild.sys.mjs b/toolkit/components/satchel/FormHistoryChild.sys.mjs
index e97b5238e8..5939979269 100644
--- a/toolkit/components/satchel/FormHistoryChild.sys.mjs
+++ b/toolkit/components/satchel/FormHistoryChild.sys.mjs
@@ -8,6 +8,10 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormHistoryAutoCompleteResult:
+ "resource://gre/modules/FormHistoryAutoComplete.sys.mjs",
+ FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
+ GenericAutocompleteItem: "resource://gre/modules/FillHelpers.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
@@ -17,12 +21,6 @@ XPCOMUtils.defineLazyPreferenceGetter(
"gEnabled",
"browser.formfill.enable"
);
-XPCOMUtils.defineLazyServiceGetter(
- lazy,
- "gFormFillService",
- "@mozilla.org/satchel/form-fill-controller;1",
- "nsIFormFillController"
-);
function log(message) {
if (!lazy.gDebug) {
@@ -43,6 +41,10 @@ export class FormHistoryChild extends JSWindowActorChild {
}
}
+ static getInputName(input) {
+ return input.name || input.id;
+ }
+
#onFormSubmission(event) {
const form = event.detail.form;
if (
@@ -77,7 +79,7 @@ export class FormHistoryChild extends JSWindowActorChild {
// Bug 1780571, Bug 394612: If Login Manager marked this input, don't save it.
// The login manager will deal with remembering it.
- if (lazy.gFormFillService.isLoginManagerField(input)) {
+ if (this.manager.getActor("LoginManager")?.isLoginManagerField(input)) {
continue;
}
@@ -107,7 +109,7 @@ export class FormHistoryChild extends JSWindowActorChild {
continue;
}
- const name = input.name || input.id;
+ const name = FormHistoryChild.getInputName(input);
if (!name) {
continue;
}
@@ -137,4 +139,140 @@ export class FormHistoryChild extends JSWindowActorChild {
this.sendAsyncMessage("FormHistory:FormSubmitEntries", entries);
}
}
+
+ get actorName() {
+ return "FormHistory";
+ }
+
+ /**
+ * Get the search options when searching for autocomplete entries in the parent
+ *
+ * @param {HTMLInputElement} input - The input element to search for autocompelte entries
+ * @returns {object} the search options for the input
+ */
+ getAutoCompleteSearchOption(input) {
+ const inputName = FormHistoryChild.getInputName(input);
+ const scenarioName = lazy.FormScenarios.detect({ input }).signUpForm
+ ? "SignUpFormScenario"
+ : "";
+
+ return { inputName, scenarioName };
+ }
+
+ /**
+ * Ask the provider whether it might have autocomplete entry to show
+ * for the given input.
+ *
+ * @param {HTMLInputElement} input - The input element to search for autocompelte entries
+ * @returns {boolean} true if we shold search for autocomplete entries
+ */
+ shouldSearchForAutoComplete(input) {
+ if (!lazy.gEnabled) {
+ return false;
+ }
+
+ const inputName = FormHistoryChild.getInputName(input);
+ // Don't allow form inputs (aField != null) to get results from
+ // search bar history.
+ if (inputName == "searchbar-history") {
+ log(`autoCompleteSearch for input name "${inputName}" is denied`);
+ return false;
+ }
+
+ if (input.autocomplete == "off" || input.form?.autocomplete == "off") {
+ log("autoCompleteSearch not allowed due to autcomplete=off");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Convert the search result to autocomplete results
+ *
+ * @param {string} searchString - The string to search for
+ * @param {HTMLInputElement} input - The input element to search for autocompelte entries
+ * @param {Array<object>} records - autocomplete records
+ * @returns {AutocompleteResult}
+ */
+ searchResultToAutoCompleteResult(searchString, input, records) {
+ const inputName = FormHistoryChild.getInputName(input);
+ const acResult = new lazy.FormHistoryAutoCompleteResult(
+ input,
+ [],
+ inputName,
+ searchString
+ );
+
+ acResult.fixedEntries = this.getDataListSuggestions(input);
+ if (!records) {
+ return acResult;
+ }
+
+ const entries = records.formHistoryEntries;
+ const externalEntries = records.externalEntries;
+
+ if (input?.maxLength > -1) {
+ acResult.entries = entries.filter(
+ el => el.text.length <= input.maxLength
+ );
+ } else {
+ acResult.entries = entries;
+ }
+
+ acResult.externalEntries.push(
+ ...externalEntries.map(
+ entry =>
+ new lazy.GenericAutocompleteItem(
+ entry.image,
+ entry.title,
+ entry.subtitle,
+ entry.fillMessageName,
+ entry.fillMessageData
+ )
+ )
+ );
+
+ acResult.removeDuplicateHistoryEntries();
+ return acResult;
+ }
+
+ #isTextControl(input) {
+ return [
+ "text",
+ "email",
+ "search",
+ "tel",
+ "url",
+ "number",
+ "month",
+ "week",
+ "password",
+ ].includes(input.type);
+ }
+
+ getDataListSuggestions(input) {
+ const items = [];
+
+ if (!this.#isTextControl(input) || !input.list) {
+ return items;
+ }
+
+ const upperFieldValue = input.value.toUpperCase();
+
+ for (const option of input.list.options) {
+ const label = option.label || option.text || option.value || "";
+
+ if (!label.toUpperCase().includes(upperFieldValue)) {
+ continue;
+ }
+
+ items.push({
+ label,
+ value: option.value,
+ });
+ }
+
+ return items;
+ }
}
diff --git a/toolkit/components/satchel/FormHistoryParent.sys.mjs b/toolkit/components/satchel/FormHistoryParent.sys.mjs
index 8fe943566e..4c8a3dc7a2 100644
--- a/toolkit/components/satchel/FormHistoryParent.sys.mjs
+++ b/toolkit/components/satchel/FormHistoryParent.sys.mjs
@@ -2,16 +2,29 @@
* 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 { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
+ FirefoxRelayTelemetry: "resource://gre/modules/FirefoxRelayTelemetry.mjs",
FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
});
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PREFERENCE_PREFIX_WEIGHT",
+ "browser.formfill.prefixWeight"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PREFERENCE_BOUNDARY_WEIGHT",
+ "browser.formfill.boundaryWeight"
+);
+
export class FormHistoryParent extends JSWindowActorParent {
receiveMessage({ name, data }) {
switch (name) {
@@ -27,7 +40,7 @@ export class FormHistoryParent extends JSWindowActorParent {
break;
case "PasswordManager:offerRelayIntegration": {
- FirefoxRelayTelemetry.recordRelayOfferedEvent(
+ lazy.FirefoxRelayTelemetry.recordRelayOfferedEvent(
"clicked",
data.telemetry.flowId,
data.telemetry.scenarioName
@@ -36,7 +49,7 @@ export class FormHistoryParent extends JSWindowActorParent {
}
case "PasswordManager:generateRelayUsername": {
- FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ lazy.FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
"clicked",
data.telemetry.flowId
);
@@ -64,10 +77,28 @@ export class FormHistoryParent extends JSWindowActorParent {
}
async #onAutoCompleteSearch({ searchString, params, scenarioName }) {
- const formHistoryPromise = lazy.FormHistory.getAutoCompleteResults(
- searchString,
- params
- );
+ searchString = searchString.trim().toLowerCase();
+
+ let formHistoryPromise;
+ if (
+ FormHistoryParent.canSearchIncrementally(
+ searchString,
+ this.previousSearchString
+ )
+ ) {
+ formHistoryPromise = Promise.resolve(
+ FormHistoryParent.incrementalSearch(
+ searchString,
+ this.previousSearchString,
+ this.previousSearchResult
+ )
+ );
+ } else {
+ formHistoryPromise = lazy.FormHistory.getAutoCompleteResults(
+ searchString,
+ params
+ );
+ }
const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({
formOrigin: this.formOrigin,
@@ -79,6 +110,9 @@ export class FormHistoryParent extends JSWindowActorParent {
relayPromise,
]);
+ this.previousSearchString = searchString;
+ this.previousSearchResult = formHistoryEntries;
+
return { formHistoryEntries, externalEntries };
}
@@ -104,4 +138,79 @@ export class FormHistoryParent extends JSWindowActorParent {
const browser = this.getRootBrowser();
return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin);
}
+
+ async searchAutoCompleteEntries(searchString, data) {
+ const { inputName, scenarioName } = data;
+ const params = {
+ fieldname: inputName,
+ };
+ return this.#onAutoCompleteSearch({ searchString, params, scenarioName });
+ }
+
+ static canSearchIncrementally(searchString, previousSearchString) {
+ previousSearchString ||= "";
+ return (
+ previousSearchString.length > 1 &&
+ searchString.includes(previousSearchString)
+ );
+ }
+
+ static incrementalSearch(
+ searchString,
+ previousSearchString,
+ previousSearchResult
+ ) {
+ const searchTokens = searchString.split(/\s+/);
+ // We have a list of results for a shorter search string, so just
+ // filter them further based on the new search string and add to a new array.
+ let filteredEntries = [];
+ for (const entry of previousSearchResult) {
+ // Remove results that do not contain the token
+ // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars
+ if (searchTokens.some(tok => !entry.textLowerCase.includes(tok))) {
+ continue;
+ }
+ FormHistoryParent.calculateScore(entry, searchString, searchTokens);
+ filteredEntries.push(entry);
+ }
+ filteredEntries.sort((a, b) => b.totalScore - a.totalScore);
+ return filteredEntries;
+ }
+
+ /*
+ * calculateScore
+ *
+ * entry -- an nsIAutoCompleteResult entry
+ * searchString -- current value of the input (lowercase)
+ * searchTokens -- array of tokens of the search string
+ *
+ * Returns: an int
+ */
+ static calculateScore(entry, searchString, searchTokens) {
+ let boundaryCalc = 0;
+ // for each word, calculate word boundary weights
+ for (const token of searchTokens) {
+ if (entry.textLowerCase.startsWith(token)) {
+ boundaryCalc++;
+ }
+ if (entry.textLowerCase.includes(" " + token)) {
+ boundaryCalc++;
+ }
+ }
+ boundaryCalc = boundaryCalc * lazy.PREFERENCE_BOUNDARY_WEIGHT;
+ // now add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ if (entry.textLowerCase.startsWith(searchString)) {
+ boundaryCalc += lazy.PREFERENCE_PREFIX_WEIGHT;
+ }
+ entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
+ }
+
+ previewFields(_result) {
+ // Not implemented
+ }
+
+ autofillFields(_result) {
+ // Not implemented
+ }
}
diff --git a/toolkit/components/satchel/components.conf b/toolkit/components/satchel/components.conf
index d5a670efd9..88c98708bc 100644
--- a/toolkit/components/satchel/components.conf
+++ b/toolkit/components/satchel/components.conf
@@ -9,7 +9,7 @@ Classes = [
'cid': '{895db6c7-dbdf-40ea-9f64-b175033243dc}',
'contract_ids': [
'@mozilla.org/satchel/form-fill-controller;1',
- '@mozilla.org/autocomplete/search;1?name=form-history',
+ '@mozilla.org/autocomplete/search;1?name=form-fill-controller',
],
'type': 'nsFormFillController',
'constructor': 'nsFormFillController::GetSingleton',
diff --git a/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs
index 8f88373763..dd071d6ff1 100644
--- a/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs
+++ b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs
@@ -52,7 +52,6 @@ ChromeUtils.defineLazyGetter(lazy, "strings", function () {
return new Localization([
"branding/brand.ftl",
"browser/firefoxRelay.ftl",
- "toolkit/branding/accounts.ftl",
"toolkit/branding/brandings.ftl",
]);
});
diff --git a/toolkit/components/satchel/jar.mn b/toolkit/components/satchel/jar.mn
index a3f250f2e8..e7135ff58a 100644
--- a/toolkit/components/satchel/jar.mn
+++ b/toolkit/components/satchel/jar.mn
@@ -3,8 +3,8 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
toolkit.jar:
+ content/global/megalist/Dialog.mjs (megalist/content/Dialog.mjs)
content/global/megalist/megalist.css (megalist/content/megalist.css)
content/global/megalist/megalist.html (megalist/content/megalist.html)
content/global/megalist/MegalistView.mjs (megalist/content/MegalistView.mjs)
- content/global/megalist/search-input.mjs (megalist/content/search-input.mjs)
content/global/megalist/VirtualizedList.mjs (megalist/content/VirtualizedList.mjs)
diff --git a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
index f11a8a3198..66a062fa06 100644
--- a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
+++ b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
@@ -67,6 +67,13 @@ export class MegalistViewModel {
}
}
+ #commandsArray(snapshot) {
+ if (Array.isArray(snapshot.commands)) {
+ return snapshot.commands;
+ }
+ return Array.from(snapshot.commands());
+ }
+
/**
*
* Send snapshot of necessary line data across parent-child boundary.
@@ -95,7 +102,7 @@ export class MegalistViewModel {
snapshot.end = snapshotData.end;
}
if ("commands" in snapshotData) {
- snapshot.commands = snapshotData.commands;
+ snapshot.commands = this.#commandsArray(snapshotData);
}
if ("valueIcon" in snapshotData) {
snapshot.valueIcon = snapshotData.valueIcon;
@@ -104,7 +111,7 @@ export class MegalistViewModel {
snapshot.href = snapshotData.href;
}
if (snapshotData.stickers) {
- for (const sticker of snapshotData.stickers) {
+ for (const sticker of snapshotData.stickers()) {
snapshot.stickers ??= [];
snapshot.stickers.push(sticker);
}
@@ -177,13 +184,22 @@ export class MegalistViewModel {
}
async receiveCommand({ commandId, snapshotId, value } = {}) {
+ const dotIndex = commandId?.indexOf(".");
+ if (dotIndex >= 0) {
+ const dataSourceName = commandId.substring(0, dotIndex);
+ const functionName = commandId.substring(dotIndex + 1);
+ MegalistViewModel.#aggregator.callFunction(dataSourceName, functionName);
+ return;
+ }
+
const index = snapshotId
? snapshotId - this.#firstSnapshotId
: this.#selectedIndex;
const snapshot = this.#snapshots[index];
if (snapshot) {
- commandId = commandId ?? snapshot.commands[0]?.id;
- const mustVerify = snapshot.commands.find(c => c.id == commandId)?.verify;
+ const commands = this.#commandsArray(snapshot);
+ commandId = commandId ?? commands[0]?.id;
+ const mustVerify = commands.find(c => c.id == commandId)?.verify;
if (!mustVerify || (await this.#verifyUser())) {
// TODO:Enter the prompt message and pref for #verifyUser()
await snapshot[`execute${commandId}`]?.(value);
@@ -230,6 +246,10 @@ export class MegalistViewModel {
}
}
+ setLayout(layout) {
+ this.#messageToView("SetLayout", { layout });
+ }
+
async #verifyUser(promptMessage, prefName) {
if (!this.getOSAuthEnabled(prefName)) {
promptMessage = false;
diff --git a/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs
index e101fadd16..f3e39ade28 100644
--- a/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs
@@ -60,6 +60,16 @@ export class Aggregator {
this.#sources.push(source);
}
+ callFunction(dataSource, functionName) {
+ const source = this.#sources.find(
+ source => source.constructor.name === dataSource
+ );
+
+ if (source && source[functionName]) {
+ source[functionName]();
+ }
+ }
+
/**
* Exposes interface for a datasource to communicate with Aggregator.
*/
@@ -73,6 +83,10 @@ export class Aggregator {
refreshAllLinesOnScreen() {
aggregator.forEachViewModel(vm => vm.refreshAllLinesOnScreen());
},
+
+ setLayout(layout) {
+ aggregator.forEachViewModel(vm => vm.setLayout(layout));
+ },
};
}
}
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs
index f00df0b40b..f38d89f88f 100644
--- a/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs
@@ -56,103 +56,87 @@ export class AddressesDataSource extends DataSourceBase {
constructor(...args) {
super(...args);
- this.formatMessages(
- "addresses-section-label",
- "address-name-label",
- "address-phone-label",
- "address-email-label",
- "command-copy",
- "addresses-disabled",
- "command-delete",
- "command-edit",
- "addresses-command-create"
- ).then(
- ([
- headerLabel,
- nameLabel,
- phoneLabel,
- emailLabel,
- copyLabel,
- addressesDisabled,
- deleteLabel,
- editLabel,
- createLabel,
- ]) => {
- const copyCommand = { id: "Copy", label: copyLabel };
- const editCommand = { id: "Edit", label: editLabel };
- const deleteCommand = { id: "Delete", label: deleteLabel };
- this.#addressesDisabledMessage = addressesDisabled;
- this.#header = this.createHeaderLine(headerLabel);
- this.#header.commands.push({ id: "Create", label: createLabel });
-
- let self = this;
-
- function prototypeLine(label, key, options = {}) {
- return self.prototypeDataLine({
- label: { value: label },
- value: {
- get() {
- return this.editingValue ?? this.record[key];
- },
+ this.localizeStrings({
+ headerLabel: "addresses-section-label",
+ nameLabel: "address-name-label",
+ phoneLabel: "address-phone-label",
+ emailLabel: "address-email-label",
+ addressesDisabled: "addresses-disabled",
+ }).then(strings => {
+ const copyCommand = { id: "Copy", label: "command-copy" };
+ const editCommand = { id: "Edit", label: "command-edit" };
+ const deleteCommand = { id: "Delete", label: "command-delete" };
+ this.#addressesDisabledMessage = strings.addressesDisabled;
+ this.#header = this.createHeaderLine(strings.headerLabel);
+ this.#header.commands.push({
+ id: "Create",
+ label: "addresses-command-create",
+ });
+
+ let self = this;
+
+ function prototypeLine(label, key, options = {}) {
+ return self.prototypeDataLine({
+ label: { value: label },
+ value: {
+ get() {
+ return this.editingValue ?? this.record[key];
},
- commands: {
- value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record[key] ?? "";
+ this.refreshOnScreen();
},
- executeEdit: {
- value() {
- this.editingValue = this.record[key] ?? "";
- this.refreshOnScreen();
- },
+ },
+ executeSave: {
+ async value(value) {
+ if (await updateAddress(this.record, key, value)) {
+ this.executeCancel();
+ }
},
- executeSave: {
- async value(value) {
- if (await updateAddress(this.record, key, value)) {
- this.executeCancel();
- }
- },
- },
- ...options,
- });
- }
-
- this.#namePrototype = prototypeLine(nameLabel, "name", {
- start: { value: true },
+ },
+ ...options,
});
- this.#organizationPrototype = prototypeLine(
- "Organization",
- "organization"
- );
- this.#streetAddressPrototype = prototypeLine(
- "Street Address",
- "street-address"
- );
- this.#addressLevelThreePrototype = prototypeLine(
- "Neighbourhood",
- "address-level3"
- );
- this.#addressLevelTwoPrototype = prototypeLine(
- "City",
- "address-level2"
- );
- this.#addressLevelOnePrototype = prototypeLine(
- "Province",
- "address-level1"
- );
- this.#postalCodePrototype = prototypeLine("Postal Code", "postal-code");
- this.#countryPrototype = prototypeLine("Country", "country");
- this.#phonePrototype = prototypeLine(phoneLabel, "tel");
- this.#emailPrototype = prototypeLine(emailLabel, "email", {
- end: { value: true },
- });
-
- Services.obs.addObserver(this, "formautofill-storage-changed");
- Services.prefs.addObserver(
- "extensions.formautofill.addresses.enabled",
- this
- );
- this.#reloadDataSource();
}
- );
+
+ this.#namePrototype = prototypeLine(strings.nameLabel, "name", {
+ start: { value: true },
+ });
+ this.#organizationPrototype = prototypeLine(
+ "Organization",
+ "organization"
+ );
+ this.#streetAddressPrototype = prototypeLine(
+ "Street Address",
+ "street-address"
+ );
+ this.#addressLevelThreePrototype = prototypeLine(
+ "Neighbourhood",
+ "address-level3"
+ );
+ this.#addressLevelTwoPrototype = prototypeLine("City", "address-level2");
+ this.#addressLevelOnePrototype = prototypeLine(
+ "Province",
+ "address-level1"
+ );
+ this.#postalCodePrototype = prototypeLine("Postal Code", "postal-code");
+ this.#countryPrototype = prototypeLine("Country", "country");
+ this.#phonePrototype = prototypeLine(strings.phoneLabel, "tel");
+ this.#emailPrototype = prototypeLine(strings.emailLabel, "email", {
+ end: { value: true },
+ });
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ Services.prefs.addObserver(
+ "extensions.formautofill.addresses.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ });
}
async #reloadDataSource() {
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs
index 06266a7979..9fc1a4e429 100644
--- a/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs
@@ -68,187 +68,157 @@ export class BankCardDataSource extends DataSourceBase {
constructor(...args) {
super(...args);
// Wait for Fluent to provide strings before loading data
- this.formatMessages(
- "payments-section-label",
- "card-number-label",
- "card-expiration-label",
- "card-holder-label",
- "command-copy",
- "command-reveal",
- "command-conceal",
- "payments-disabled",
- "command-delete",
- "command-edit",
- "payments-command-create"
- ).then(
- ([
- headerLabel,
- numberLabel,
- expirationLabel,
- holderLabel,
- copyCommandLabel,
- revealCommandLabel,
- concealCommandLabel,
- cardsDisabled,
- deleteCommandLabel,
- editCommandLabel,
- cardsCreateCommandLabel,
- ]) => {
- const copyCommand = { id: "Copy", label: copyCommandLabel };
- const editCommand = {
- id: "Edit",
- label: editCommandLabel,
- verify: true,
- };
- const deleteCommand = {
- id: "Delete",
- label: deleteCommandLabel,
- verify: true,
- };
- this.#cardsDisabledMessage = cardsDisabled;
- this.#header = this.createHeaderLine(headerLabel);
- this.#header.commands.push({
- id: "Create",
- label: cardsCreateCommandLabel,
- });
- this.#cardNumberPrototype = this.prototypeDataLine({
- label: { value: numberLabel },
- concealed: { value: true, writable: true },
- start: { value: true },
- value: {
- async get() {
- if (this.editingValue !== undefined) {
- return this.editingValue;
- }
+ this.localizeStrings({
+ headerLabel: "payments-section-label",
+ numberLabel: "card-number-label",
+ expirationLabel: "card-expiration-label",
+ holderLabel: "card-holder-label",
+ cardsDisabled: "payments-disabled",
+ }).then(strings => {
+ const copyCommand = { id: "Copy", label: "command-copy" };
+ const editCommand = { id: "Edit", label: "command-edit", verify: true };
+ const deleteCommand = {
+ id: "Delete",
+ label: "command-delete",
+ verify: true,
+ };
+ this.#cardsDisabledMessage = strings.cardsDisabled;
+ this.#header = this.createHeaderLine(strings.headerLabel);
+ this.#header.commands.push({
+ id: "Create",
+ label: "payments-command-create",
+ });
+ this.#cardNumberPrototype = this.prototypeDataLine({
+ label: { value: strings.numberLabel },
+ concealed: { value: true, writable: true },
+ start: { value: true },
+ value: {
+ async get() {
+ if (this.isEditing()) {
+ return this.editingValue;
+ }
- if (this.concealed) {
- return (
- "••••••••" +
- this.record["cc-number"].replaceAll("*", "").substr(-4)
- );
- }
-
- await decryptCard(this.record);
- return this.record["cc-number-decrypted"];
- },
- },
- valueIcon: {
- get() {
- const typeToImage = {
- amex: "third-party/cc-logo-amex.png",
- cartebancaire: "third-party/cc-logo-cartebancaire.png",
- diners: "third-party/cc-logo-diners.svg",
- discover: "third-party/cc-logo-discover.png",
- jcb: "third-party/cc-logo-jcb.svg",
- mastercard: "third-party/cc-logo-mastercard.svg",
- mir: "third-party/cc-logo-mir.svg",
- unionpay: "third-party/cc-logo-unionpay.svg",
- visa: "third-party/cc-logo-visa.svg",
- };
+ if (this.concealed) {
return (
- "chrome://formautofill/content/" +
- (typeToImage[this.record["cc-type"]] ??
- "icon-credit-card-generic.svg")
+ "••••••••" +
+ this.record["cc-number"].replaceAll("*", "").substr(-4)
);
- },
- },
- commands: {
- get() {
- const commands = [
- { id: "Conceal", label: concealCommandLabel },
- { ...copyCommand, verify: true },
- editCommand,
- "-",
- deleteCommand,
- ];
- if (this.concealed) {
- commands[0] = {
- id: "Reveal",
- label: revealCommandLabel,
- verify: true,
- };
- }
- return commands;
- },
+ }
+
+ await decryptCard(this.record);
+ return this.record["cc-number-decrypted"];
},
- executeReveal: {
- value() {
- this.concealed = false;
- this.refreshOnScreen();
- },
+ },
+ valueIcon: {
+ get() {
+ const typeToImage = {
+ amex: "third-party/cc-logo-amex.png",
+ cartebancaire: "third-party/cc-logo-cartebancaire.png",
+ diners: "third-party/cc-logo-diners.svg",
+ discover: "third-party/cc-logo-discover.png",
+ jcb: "third-party/cc-logo-jcb.svg",
+ mastercard: "third-party/cc-logo-mastercard.svg",
+ mir: "third-party/cc-logo-mir.svg",
+ unionpay: "third-party/cc-logo-unionpay.svg",
+ visa: "third-party/cc-logo-visa.svg",
+ };
+ return (
+ "chrome://formautofill/content/" +
+ (typeToImage[this.record["cc-type"]] ??
+ "icon-credit-card-generic.svg")
+ );
},
- executeConceal: {
- value() {
- this.concealed = true;
- this.refreshOnScreen();
- },
+ },
+ commands: {
+ *value() {
+ if (this.concealed) {
+ yield { id: "Reveal", label: "command-reveal", verify: true };
+ } else {
+ yield { id: "Conceal", label: "command-conceal" };
+ }
+ yield { ...copyCommand, verify: true };
+ yield editCommand;
+ yield "-";
+ yield deleteCommand;
},
- executeCopy: {
- async value() {
- await decryptCard(this.record);
- this.copyToClipboard(this.record["cc-number-decrypted"]);
- },
+ },
+ executeReveal: {
+ value() {
+ this.concealed = false;
+ this.refreshOnScreen();
},
- executeEdit: {
- async value() {
- await decryptCard(this.record);
- this.editingValue = this.record["cc-number-decrypted"] ?? "";
- this.refreshOnScreen();
- },
+ },
+ executeConceal: {
+ value() {
+ this.concealed = true;
+ this.refreshOnScreen();
},
- executeSave: {
- async value(value) {
- if (updateCard(this.record, "cc-number", value)) {
- this.executeCancel();
- }
- },
+ },
+ executeCopy: {
+ async value() {
+ await decryptCard(this.record);
+ this.copyToClipboard(this.record["cc-number-decrypted"]);
},
- });
- this.#expirationPrototype = this.prototypeDataLine({
- label: { value: expirationLabel },
- value: {
- get() {
- return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`;
- },
+ },
+ executeEdit: {
+ async value() {
+ await decryptCard(this.record);
+ this.editingValue = this.record["cc-number-decrypted"] ?? "";
+ this.refreshOnScreen();
},
- commands: {
- value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ executeSave: {
+ async value(value) {
+ if (updateCard(this.record, "cc-number", value)) {
+ this.executeCancel();
+ }
},
- });
- this.#holderNamePrototype = this.prototypeDataLine({
- label: { value: holderLabel },
- end: { value: true },
- value: {
- get() {
- return this.editingValue ?? this.record["cc-name"];
- },
+ },
+ });
+ this.#expirationPrototype = this.prototypeDataLine({
+ label: { value: strings.expirationLabel },
+ value: {
+ get() {
+ return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`;
},
- commands: {
- value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ });
+ this.#holderNamePrototype = this.prototypeDataLine({
+ label: { value: strings.holderLabel },
+ end: { value: true },
+ value: {
+ get() {
+ return this.editingValue ?? this.record["cc-name"];
},
- executeEdit: {
- value() {
- this.editingValue = this.record["cc-name"] ?? "";
- this.refreshOnScreen();
- },
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record["cc-name"] ?? "";
+ this.refreshOnScreen();
},
- executeSave: {
- async value(value) {
- if (updateCard(this.record, "cc-name", value)) {
- this.executeCancel();
- }
- },
+ },
+ executeSave: {
+ async value(value) {
+ if (updateCard(this.record, "cc-name", value)) {
+ this.executeCancel();
+ }
},
- });
+ },
+ });
- Services.obs.addObserver(this, "formautofill-storage-changed");
- Services.prefs.addObserver(
- "extensions.formautofill.creditCards.enabled",
- this
- );
- this.#reloadDataSource();
- }
- );
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ Services.prefs.addObserver(
+ "extensions.formautofill.creditCards.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ });
}
/**
@@ -280,7 +250,7 @@ export class BankCardDataSource extends DataSourceBase {
`${card["cc-exp-month"]}/${card["cc-exp-year"]}`
.toUpperCase()
.includes(searchText) ||
- card["cc-name"].toUpperCase().includes(searchText)
+ card["cc-name"]?.toUpperCase().includes(searchText)
);
this.formatMessages({
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs
index 49be733aef..ee7dfed5eb 100644
--- a/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs
@@ -63,7 +63,30 @@ export class DataSourceBase {
this.#aggregatorApi.refreshAllLinesOnScreen();
}
+ setLayout(layout) {
+ this.#aggregatorApi.setLayout(layout);
+ }
+
formatMessages = createFormatMessages("preview/megalist.ftl");
+ static ftl = new Localization(["preview/megalist.ftl"]);
+
+ async localizeStrings(strings) {
+ const keys = Object.keys(strings);
+ const localisationIds = Object.values(strings).map(id => ({ id }));
+ const messages = await DataSourceBase.ftl.formatMessages(localisationIds);
+
+ for (let i = 0; i < messages.length; i++) {
+ let { attributes, value } = messages[i];
+ if (attributes) {
+ value = attributes.reduce(
+ (result, { name, value }) => ({ ...result, [name]: value }),
+ {}
+ );
+ }
+ strings[keys[i]] = value;
+ }
+ return strings;
+ }
/**
* Prototype for the each line.
@@ -94,6 +117,10 @@ export class DataSourceBase {
return true;
},
+ isEditing() {
+ return this.editingValue !== undefined;
+ },
+
copyToClipboard(text) {
lazy.ClipboardHelper.copyString(text, lazy.ClipboardHelper.Sensitive);
},
@@ -135,6 +162,9 @@ export class DataSourceBase {
refreshOnScreen() {
this.source.refreshSingleLineOnScreen(this);
},
+ setLayout(data) {
+ this.source.setLayout(data);
+ },
};
/**
@@ -144,7 +174,6 @@ export class DataSourceBase {
* @returns {object} section header line
*/
createHeaderLine(label) {
- const toggleCommand = { id: "Toggle", label: "" };
const result = {
label,
value: "",
@@ -164,7 +193,7 @@ export class DataSourceBase {
lineIsReady: () => true,
- commands: [toggleCommand],
+ commands: [{ id: "Toggle", label: "command-toggle" }],
executeToggle() {
this.collapsed = !this.collapsed;
@@ -172,10 +201,6 @@ export class DataSourceBase {
},
};
- this.formatMessages("command-toggle").then(([toggleLabel]) => {
- toggleCommand.label = toggleLabel;
- });
-
return result;
}
@@ -244,6 +269,10 @@ export class DataSourceBase {
return this.lines[index];
}
+ cancelDialog() {
+ this.setLayout(null);
+ }
+
*enumerateLinesForMatchingRecords(searchText, stats, match) {
stats.total = 0;
stats.count = 0;
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
index 324bc4d141..7e74ce2488 100644
--- a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
@@ -19,13 +19,6 @@ XPCOMUtils.defineLazyPreferenceGetter(
false
);
-XPCOMUtils.defineLazyPreferenceGetter(
- lazy,
- "VULNERABLE_PASSWORDS_ENABLED",
- "signon.management.page.vulnerable-passwords.enabled",
- false
-);
-
/**
* Data source for Logins.
*
@@ -45,235 +38,239 @@ export class LoginDataSource extends DataSourceBase {
constructor(...args) {
super(...args);
// Wait for Fluent to provide strings before loading data
- this.formatMessages(
- "passwords-section-label",
- "passwords-origin-label",
- "passwords-username-label",
- "passwords-password-label",
- "command-open",
- "command-copy",
- "command-reveal",
- "command-conceal",
- "passwords-disabled",
- "command-delete",
- "command-edit",
- "passwords-command-create",
- "passwords-command-import",
- "passwords-command-export",
- "passwords-command-remove-all",
- "passwords-command-settings",
- "passwords-command-help",
- "passwords-import-file-picker-title",
- "passwords-import-file-picker-import-button",
- "passwords-import-file-picker-csv-filter-title",
- "passwords-import-file-picker-tsv-filter-title"
- ).then(
- ([
- headerLabel,
- originLabel,
- usernameLabel,
- passwordLabel,
- openCommandLabel,
- copyCommandLabel,
- revealCommandLabel,
- concealCommandLabel,
- passwordsDisabled,
- deleteCommandLabel,
- editCommandLabel,
- passwordsCreateCommandLabel,
- passwordsImportCommandLabel,
- passwordsExportCommandLabel,
- passwordsRemoveAllCommandLabel,
- passwordsSettingsCommandLabel,
- passwordsHelpCommandLabel,
- passwordsImportFilePickerTitle,
- passwordsImportFilePickerImportButton,
- passwordsImportFilePickerCsvFilterTitle,
- passwordsImportFilePickerTsvFilterTitle,
- ]) => {
- const copyCommand = { id: "Copy", label: copyCommandLabel };
- const editCommand = { id: "Edit", label: editCommandLabel };
- const deleteCommand = { id: "Delete", label: deleteCommandLabel };
- this.breachedSticker = { type: "warning", label: "BREACH" };
- this.vulnerableSticker = { type: "risk", label: "🤮 Vulnerable" };
- this.#loginsDisabledMessage = passwordsDisabled;
- this.#header = this.createHeaderLine(headerLabel);
- this.#header.commands.push(
- { id: "Create", label: passwordsCreateCommandLabel },
- { id: "Import", label: passwordsImportCommandLabel },
- { id: "Export", label: passwordsExportCommandLabel },
- { id: "RemoveAll", label: passwordsRemoveAllCommandLabel },
- { id: "Settings", label: passwordsSettingsCommandLabel },
- { id: "Help", label: passwordsHelpCommandLabel }
+ this.localizeStrings({
+ headerLabel: "passwords-section-label",
+ originLabel: "passwords-origin-label",
+ usernameLabel: "passwords-username-label",
+ passwordLabel: "passwords-password-label",
+ passwordsDisabled: "passwords-disabled",
+ passwordsImportFilePickerTitle: "passwords-import-file-picker-title",
+ passwordsImportFilePickerImportButton:
+ "passwords-import-file-picker-import-button",
+ passwordsImportFilePickerCsvFilterTitle:
+ "passwords-import-file-picker-csv-filter-title",
+ passwordsImportFilePickerTsvFilterTitle:
+ "passwords-import-file-picker-tsv-filter-title",
+ dismissBreachCommandLabel: "passwords-dismiss-breach-alert-command",
+ }).then(strings => {
+ const copyCommand = { id: "Copy", label: "command-copy" };
+ const editCommand = { id: "Edit", label: "command-edit" };
+ const deleteCommand = { id: "Delete", label: "command-delete" };
+ const dismissBreachCommand = {
+ id: "DismissBreach",
+ label: strings.dismissBreachCommandLabel,
+ };
+ const noOriginSticker = { type: "error", label: "😾 Missing origin" };
+ const noPasswordSticker = { type: "error", label: "😾 Missing password" };
+ const breachedSticker = { type: "warning", label: "BREACH" };
+ const vulnerableSticker = { type: "risk", label: "🤮 Vulnerable" };
+ this.#loginsDisabledMessage = strings.passwordsDisabled;
+ this.#header = this.createHeaderLine(strings.headerLabel);
+ this.#header.commands.push(
+ { id: "Create", label: "passwords-command-create" },
+ { id: "Import", label: "passwords-command-import" },
+ { id: "Export", label: "passwords-command-export" },
+ { id: "RemoveAll", label: "passwords-command-remove-all" },
+ { id: "Settings", label: "passwords-command-settings" },
+ { id: "Help", label: "passwords-command-help" }
+ );
+ this.#header.executeImport = async () =>
+ this.#importFromFile(
+ strings.passwordsImportFilePickerTitle,
+ strings.passwordsImportFilePickerImportButton,
+ strings.passwordsImportFilePickerCsvFilterTitle,
+ strings.passwordsImportFilePickerTsvFilterTitle
);
- this.#header.executeImport = async () => {
- await this.#importFromFile(
- passwordsImportFilePickerTitle,
- passwordsImportFilePickerImportButton,
- passwordsImportFilePickerCsvFilterTitle,
- passwordsImportFilePickerTsvFilterTitle
- );
- };
- this.#header.executeSettings = () => {
- this.#openPreferences();
- };
- this.#header.executeHelp = () => {
- this.#getHelp();
- };
-
- this.#originPrototype = this.prototypeDataLine({
- label: { value: originLabel },
- start: { value: true },
- value: {
- get() {
- return this.record.displayOrigin;
- },
+
+ this.#header.executeRemoveAll = () => this.#removeAllPasswords();
+ this.#header.executeSettings = () => this.#openPreferences();
+ this.#header.executeHelp = () => this.#getHelp();
+ this.#header.executeExport = () => this.#exportAllPasswords();
+
+ this.#originPrototype = this.prototypeDataLine({
+ label: { value: strings.originLabel },
+ start: { value: true },
+ value: {
+ get() {
+ return this.record.displayOrigin;
+ },
+ },
+ valueIcon: {
+ get() {
+ return `page-icon:${this.record.origin}`;
},
- valueIcon: {
- get() {
- return `page-icon:${this.record.origin}`;
- },
+ },
+ href: {
+ get() {
+ return this.record.origin;
},
- href: {
- get() {
- return this.record.origin;
- },
+ },
+ commands: {
+ *value() {
+ yield { id: "Open", label: "command-open" };
+ yield copyCommand;
+ yield "-";
+ yield deleteCommand;
+
+ if (this.breached) {
+ yield dismissBreachCommand;
+ }
},
- commands: {
- value: [
- { id: "Open", label: openCommandLabel },
- copyCommand,
- "-",
- deleteCommand,
- ],
+ },
+ executeDismissBreach: {
+ value() {
+ lazy.LoginBreaches.recordBreachAlertDismissal(this.record.guid);
+ delete this.breached;
+ this.refreshOnScreen();
},
- executeCopy: {
- value() {
- this.copyToClipboard(this.record.origin);
- },
+ },
+ executeCopy: {
+ value() {
+ this.copyToClipboard(this.record.origin);
},
- });
- this.#usernamePrototype = this.prototypeDataLine({
- label: { value: usernameLabel },
- value: {
- get() {
- return this.editingValue ?? this.record.username;
- },
+ },
+ executeDelete: {
+ value() {
+ this.setLayout({ id: "remove-login" });
+ },
+ },
+ stickers: {
+ *value() {
+ if (this.isEditing() && !this.editingValue.length) {
+ yield noOriginSticker;
+ }
+
+ if (this.breached) {
+ yield breachedSticker;
+ }
},
- commands: { value: [copyCommand, editCommand, "-", deleteCommand] },
- executeEdit: {
- value() {
- this.editingValue = this.record.username ?? "";
- this.refreshOnScreen();
- },
+ },
+ });
+ this.#usernamePrototype = this.prototypeDataLine({
+ label: { value: strings.usernameLabel },
+ value: {
+ get() {
+ return this.editingValue ?? this.record.username;
},
- executeSave: {
- value(value) {
- try {
- const modifiedLogin = this.record.clone();
- modifiedLogin.username = value;
- Services.logins.modifyLogin(this.record, modifiedLogin);
- } catch (error) {
- //todo
- console.error("failed to modify login", error);
- }
- this.executeCancel();
- },
+ },
+ commands: { value: [copyCommand, editCommand, "-", deleteCommand] },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record.username ?? "";
+ this.refreshOnScreen();
},
- });
- this.#passwordPrototype = this.prototypeDataLine({
- label: { value: passwordLabel },
- concealed: { value: true, writable: true },
- end: { value: true },
- value: {
- get() {
- return (
- this.editingValue ??
- (this.concealed ? "••••••••" : this.record.password)
- );
- },
+ },
+ executeSave: {
+ value(value) {
+ try {
+ const modifiedLogin = this.record.clone();
+ modifiedLogin.username = value;
+ Services.logins.modifyLogin(this.record, modifiedLogin);
+ } catch (error) {
+ //todo
+ console.error("failed to modify login", error);
+ }
+ this.executeCancel();
+ },
+ },
+ });
+ this.#passwordPrototype = this.prototypeDataLine({
+ label: { value: strings.passwordLabel },
+ concealed: { value: true, writable: true },
+ end: { value: true },
+ value: {
+ get() {
+ return (
+ this.editingValue ??
+ (this.concealed ? "••••••••" : this.record.password)
+ );
},
- commands: {
- get() {
- const commands = [
- { id: "Conceal", label: concealCommandLabel },
- {
- id: "Copy",
- label: copyCommandLabel,
- verify: true,
- },
- editCommand,
- "-",
- deleteCommand,
- ];
- if (this.concealed) {
- commands[0] = {
- id: "Reveal",
- label: revealCommandLabel,
- verify: true,
- };
- }
- return commands;
- },
+ },
+ stickers: {
+ *value() {
+ if (this.isEditing() && !this.editingValue.length) {
+ yield noPasswordSticker;
+ }
+
+ if (this.vulnerable) {
+ yield vulnerableSticker;
+ }
},
- executeReveal: {
- value() {
- this.concealed = false;
- this.refreshOnScreen();
- },
+ },
+ commands: {
+ *value() {
+ if (this.concealed) {
+ yield { id: "Reveal", label: "command-reveal", verify: true };
+ } else {
+ yield { id: "Conceal", label: "command-conceal" };
+ }
+ yield { ...copyCommand, verify: true };
+ yield editCommand;
+ yield "-";
+ yield deleteCommand;
},
- executeConceal: {
- value() {
- this.concealed = true;
- this.refreshOnScreen();
- },
+ },
+ executeReveal: {
+ value() {
+ this.concealed = false;
+ this.refreshOnScreen();
},
- executeCopy: {
- value() {
- this.copyToClipboard(this.record.password);
- },
+ },
+ executeConceal: {
+ value() {
+ this.concealed = true;
+ this.refreshOnScreen();
},
- executeEdit: {
- value() {
- this.editingValue = this.record.password ?? "";
- this.refreshOnScreen();
- },
+ },
+ executeCopy: {
+ value() {
+ this.copyToClipboard(this.record.password);
},
- executeSave: {
- value(value) {
- try {
- const modifiedLogin = this.record.clone();
- modifiedLogin.password = value;
- Services.logins.modifyLogin(this.record, modifiedLogin);
- } catch (error) {
- //todo
- console.error("failed to modify login", error);
- }
- this.executeCancel();
- },
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record.password ?? "";
+ this.refreshOnScreen();
},
- });
+ },
+ executeSave: {
+ value(value) {
+ if (!value) {
+ return;
+ }
- Services.obs.addObserver(this, "passwordmgr-storage-changed");
- Services.prefs.addObserver("signon.rememberSignons", this);
- Services.prefs.addObserver(
- "signon.management.page.breach-alerts.enabled",
- this
- );
- Services.prefs.addObserver(
- "signon.management.page.vulnerable-passwords.enabled",
- this
- );
- this.#reloadDataSource();
- }
- );
+ try {
+ const modifiedLogin = this.record.clone();
+ modifiedLogin.password = value;
+ Services.logins.modifyLogin(this.record, modifiedLogin);
+ } catch (error) {
+ //todo
+ console.error("failed to modify login", error);
+ }
+ this.executeCancel();
+ },
+ },
+ });
+
+ Services.obs.addObserver(this, "passwordmgr-storage-changed");
+ Services.prefs.addObserver("signon.rememberSignons", this);
+ Services.prefs.addObserver(
+ "signon.management.page.breach-alerts.enabled",
+ this
+ );
+ Services.prefs.addObserver(
+ "signon.management.page.vulnerable-passwords.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ });
}
async #importFromFile(title, buttonLabel, csvTitle, tsvTitle) {
const { BrowserWindowTracker } = ChromeUtils.importESModule(
"resource:///modules/BrowserWindowTracker.sys.mjs"
);
- const browser = BrowserWindowTracker.getTopWindow().gBrowser;
+ const browsingContext = BrowserWindowTracker.getTopWindow().browsingContext;
let { result, path } = await this.openFilePickerDialog(
title,
buttonLabel,
@@ -287,26 +284,38 @@ export class LoginDataSource extends DataSourceBase {
extensionPattern: "*.tsv",
},
],
- browser.ownerGlobal
+ browsingContext
);
if (result != Ci.nsIFilePicker.returnCancel) {
- let summary;
try {
- summary = await LoginCSVImport.importFromCSV(path);
+ const summary = await LoginCSVImport.importFromCSV(path);
+ const counts = { added: 0, modified: 0, no_change: 0, error: 0 };
+
+ for (const item of summary) {
+ counts[item.result] += 1;
+ }
+ const l10nArgs = Object.values(counts).map(count => ({ count }));
+
+ this.setLayout({
+ id: "import-logins",
+ l10nArgs,
+ });
} catch (e) {
- // TODO: Display error for import
- }
- if (summary) {
- // TODO: Display successful import summary
+ this.setLayout({ id: "import-error" });
}
}
}
- async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) {
+ async openFilePickerDialog(
+ title,
+ okButtonLabel,
+ appendFilters,
+ browsingContext
+ ) {
return new Promise(resolve => {
let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
- fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen);
+ fp.init(browsingContext, title, Ci.nsIFilePicker.modeOpen);
for (const appendFilter of appendFilters) {
fp.appendFilter(appendFilter.title, appendFilter.extensionPattern);
}
@@ -318,6 +327,48 @@ export class LoginDataSource extends DataSourceBase {
});
}
+ #removeAllPasswords() {
+ let count = 0;
+ let currentRecord;
+ for (const line of this.lines) {
+ if (line.record != currentRecord) {
+ count += 1;
+ currentRecord = line.record;
+ }
+ }
+
+ this.setLayout({ id: "remove-logins", l10nArgs: [{ count }] });
+ }
+
+ #exportAllPasswords() {
+ this.setLayout({ id: "export-logins" });
+ }
+
+ confirmRemoveAll() {
+ Services.logins.removeAllLogins();
+ this.cancelDialog();
+ }
+
+ confirmExportLogins() {
+ // TODO: Implement this.
+ // We need to simplify this function first
+ // https://searchfox.org/mozilla-central/source/browser/components/aboutlogins/AboutLoginsParent.sys.mjs#377
+ // It's too messy right now.
+ this.cancelDialog();
+ }
+
+ confirmRemoveLogin() {
+ // TODO: Simplify getting record directly.
+ const login = this.lines?.[0]?.record;
+ Services.logins.removeLogin(login);
+ this.cancelDialog();
+ }
+
+ confirmRetryImport() {
+ // TODO: Implement this.
+ this.cancelDialog();
+ }
+
#openPreferences() {
const { BrowserWindowTracker } = ChromeUtils.importESModule(
"resource:///modules/BrowserWindowTracker.sys.mjs"
@@ -422,31 +473,8 @@ export class LoginDataSource extends DataSourceBase {
this.#passwordPrototype
);
- let breachIndex =
- originLine.stickers?.findIndex(s => s === this.breachedSticker) ?? -1;
- let breach = breachesMap.get(login.guid);
- if (breach && breachIndex < 0) {
- originLine.stickers ??= [];
- originLine.stickers.push(this.breachedSticker);
- } else if (!breach && breachIndex >= 0) {
- originLine.stickers.splice(breachIndex, 1);
- }
-
- const vulnerable = lazy.VULNERABLE_PASSWORDS_ENABLED
- ? lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID([
- login,
- ]).size
- : 0;
-
- let vulnerableIndex =
- passwordLine.stickers?.findIndex(s => s === this.vulnerableSticker) ??
- -1;
- if (vulnerable && vulnerableIndex < 0) {
- passwordLine.stickers ??= [];
- passwordLine.stickers.push(this.vulnerableSticker);
- } else if (!vulnerable && vulnerableIndex >= 0) {
- passwordLine.stickers.splice(vulnerableIndex, 1);
- }
+ originLine.breached = breachesMap.has(login.guid);
+ passwordLine.vulnerable = lazy.LoginBreaches.isVulnerablePassword(login);
});
this.afterReloadingDataSource();
diff --git a/toolkit/components/satchel/megalist/content/Dialog.mjs b/toolkit/components/satchel/megalist/content/Dialog.mjs
new file mode 100644
index 0000000000..f2eca6376c
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/Dialog.mjs
@@ -0,0 +1,116 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+const GENERIC_DIALOG_TEMPLATE = document.querySelector("#dialog-template");
+
+const DIALOGS = {
+ "remove-login": {
+ template: "#remove-login-dialog-template",
+ },
+ "export-logins": {
+ template: "#export-logins-dialog-template",
+ },
+ "remove-logins": {
+ template: "#remove-logins-dialog-template",
+ callback: dialog => {
+ const primaryButton = dialog.querySelector("button.primary");
+ const checkbox = dialog.querySelector(".confirm-checkbox");
+ const toggleButton = () => (primaryButton.disabled = !checkbox.checked);
+ checkbox.addEventListener("change", toggleButton);
+ toggleButton();
+ },
+ },
+ "import-logins": {
+ template: "#import-logins-dialog-template",
+ },
+ "import-error": {
+ template: "#import-error-dialog-template",
+ },
+};
+
+/**
+ * Setup dismiss and command handling logic for the dialog overlay.
+ *
+ * @param {Element} overlay - The overlay element containing the dialog
+ * @param {Function} messageHandler - Function to send message back to view model.
+ */
+const setupControls = (overlay, messageHandler) => {
+ const dialog = overlay.querySelector(".dialog-container");
+ const commandButtons = dialog.querySelectorAll("[data-command]");
+ for (const commandButton of commandButtons) {
+ const commandId = commandButton.dataset.command;
+ commandButton.addEventListener("click", () => messageHandler(commandId));
+ }
+
+ dialog.querySelectorAll("[close-dialog]").forEach(element => {
+ element.addEventListener("click", cancelDialog, { once: true });
+ });
+
+ document.addEventListener("keyup", function handleKeyUp(ev) {
+ if (ev.key === "Escape") {
+ cancelDialog();
+ document.removeEventListener("keyup", handleKeyUp);
+ }
+ });
+
+ document.addEventListener("click", function handleClickOutside(ev) {
+ if (!dialog.contains(ev.target)) {
+ cancelDialog();
+ document.removeEventListener("click", handleClickOutside);
+ }
+ });
+ dialog.querySelector("[autofocus]")?.focus();
+};
+
+/**
+ * Add data-l10n-args to elements with localizable attribute
+ *
+ * @param {Element} dialog - The dialog element.
+ * @param {Array<object>} l10nArgs - List of localization arguments.
+ */
+const populateL10nArgs = (dialog, l10nArgs) => {
+ const localizableElements = dialog.querySelectorAll("[localizable]");
+ for (const [index, localizableElement] of localizableElements.entries()) {
+ localizableElement.dataset.l10nArgs = JSON.stringify(l10nArgs[index]) ?? "";
+ }
+};
+
+/**
+ * Remove the currently displayed dialog overlay from the DOM.
+ */
+export const cancelDialog = () =>
+ document.querySelector(".dialog-overlay")?.remove();
+
+/**
+ * Create a new dialog overlay and populate it using the specified template and data.
+ *
+ * @param {object} dialogData - Data required to populate the dialog, includes template and localization args.
+ * @param {Function} messageHandler - Function to send message back to view model.
+ */
+export const createDialog = (dialogData, messageHandler) => {
+ const templateData = DIALOGS[dialogData?.id];
+
+ const genericTemplateClone = document.importNode(
+ GENERIC_DIALOG_TEMPLATE.content,
+ true
+ );
+
+ const overlay = genericTemplateClone.querySelector(".dialog-overlay");
+ const dialog = genericTemplateClone.querySelector(".dialog-container");
+
+ const overrideTemplate = document.querySelector(templateData.template);
+ const overrideTemplateClone = document.importNode(
+ overrideTemplate.content,
+ true
+ );
+
+ genericTemplateClone
+ .querySelector(".dialog-wrapper")
+ .appendChild(overrideTemplateClone);
+
+ populateL10nArgs(genericTemplateClone, dialogData.l10nArgs);
+ setupControls(overlay, messageHandler);
+ document.body.appendChild(genericTemplateClone);
+ templateData?.callback?.(dialog, messageHandler);
+};
diff --git a/toolkit/components/satchel/megalist/content/MegalistView.mjs b/toolkit/components/satchel/megalist/content/MegalistView.mjs
index 44a0198692..feec2409f8 100644
--- a/toolkit/components/satchel/megalist/content/MegalistView.mjs
+++ b/toolkit/components/satchel/megalist/content/MegalistView.mjs
@@ -4,13 +4,14 @@
import { html } from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import {
+ createDialog,
+ cancelDialog,
+} from "chrome://global/content/megalist/Dialog.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://global/content/megalist/VirtualizedList.mjs";
-// eslint-disable-next-line import/no-unassigned-import
-import "chrome://global/content/megalist/search-input.mjs";
-
/**
* Map with limit on how many entries it can have.
* When over limit entries are added, oldest one are removed.
@@ -77,6 +78,7 @@ export class MegalistView extends MozLitElement {
super();
this.selectedIndex = 0;
this.searchText = "";
+ this.layout = null;
window.addEventListener("MessageFromViewModel", ev =>
this.#onMessageFromViewModel(ev)
@@ -88,6 +90,7 @@ export class MegalistView extends MozLitElement {
listLength: { type: Number },
selectedIndex: { type: Number },
searchText: { type: String },
+ layout: { type: Object },
};
}
@@ -112,6 +115,10 @@ export class MegalistView extends MozLitElement {
#templates = {};
+ static queries = {
+ searchInput: ".search",
+ };
+
connectedCallback() {
super.connectedCallback();
this.ownerDocument.addEventListener("keydown", e => this.#handleKeydown(e));
@@ -296,6 +303,16 @@ export class MegalistView extends MozLitElement {
this.requestUpdate();
}
+ receiveSetLayout({ layout }) {
+ if (layout) {
+ createDialog(layout, commandId =>
+ this.#messageToViewModel("Command", { commandId })
+ );
+ } else {
+ cancelDialog();
+ }
+ }
+
#handleInputChange(e) {
const searchText = e.target.value;
this.#messageToViewModel("UpdateFilter", { searchText });
@@ -333,6 +350,15 @@ export class MegalistView extends MozLitElement {
}
#handleClick(e) {
+ const elementWithCommand = e.composedTarget.closest("[data-command]");
+ if (elementWithCommand) {
+ const commandId = elementWithCommand.dataset.command;
+ if (commandId) {
+ this.#messageToViewModel("Command", { commandId });
+ return;
+ }
+ }
+
const lineElement = e.composedTarget.closest(".line");
if (!lineElement) {
return;
@@ -360,6 +386,12 @@ export class MegalistView extends MozLitElement {
const popup = this.ownerDocument.createElement("div");
popup.className = "menuPopup";
+
+ let closeMenu = () => {
+ popup.remove();
+ this.searchInput.focus();
+ };
+
popup.addEventListener(
"keydown",
e => {
@@ -385,7 +417,7 @@ export class MegalistView extends MozLitElement {
switch (e.code) {
case "Escape":
- popup.remove();
+ closeMenu();
break;
case "Tab":
if (e.shiftKey) {
@@ -416,9 +448,7 @@ export class MegalistView extends MozLitElement {
e.composedTarget?.closest(".menuPopup") !=
e.relatedTarget?.closest(".menuPopup")
) {
- // TODO: this triggers on macOS before "click" event. Due to this,
- // we are not receiving the command.
- popup.remove();
+ closeMenu();
}
},
{ capture: true }
@@ -433,7 +463,7 @@ export class MegalistView extends MozLitElement {
}
const menuItem = this.ownerDocument.createElement("button");
- menuItem.textContent = command.label;
+ menuItem.setAttribute("data-l10n-id", command.label);
menuItem.addEventListener("click", e => {
this.#messageToViewModel("Command", {
snapshotId,
@@ -449,26 +479,50 @@ export class MegalistView extends MozLitElement {
popup.querySelector("button")?.focus();
}
+ /**
+ * Renders data-source specific UI that should be displayed before the
+ * virtualized list. This is determined by the "SetLayout" message provided
+ * by the View Model. Defaults to displaying the search input.
+ */
+ renderBeforeList() {
+ return html`
+ <input
+ class="search"
+ type="search"
+ data-l10n-id="filter-placeholder"
+ .value=${this.searchText}
+ @input=${e => this.#handleInputChange(e)}
+ />
+ `;
+ }
+
+ renderList() {
+ if (this.layout) {
+ return null;
+ }
+
+ return html` <virtualized-list
+ .lineCount=${this.listLength}
+ .lineHeight=${MegalistView.LINE_HEIGHT}
+ .selectedIndex=${this.selectedIndex}
+ .createLineElement=${index => this.createLineElement(index)}
+ @click=${e => this.#handleClick(e)}
+ >
+ </virtualized-list>`;
+ }
+
+ renderAfterList() {}
+
render() {
return html`
<link
rel="stylesheet"
href="chrome://global/content/megalist/megalist.css"
/>
- <div class="container">
- <search-input
- .value=${this.searchText}
- .change=${e => this.#handleInputChange(e)}
- >
- </search-input>
- <virtualized-list
- .lineCount=${this.listLength}
- .lineHeight=${MegalistView.LINE_HEIGHT}
- .selectedIndex=${this.selectedIndex}
- .createLineElement=${index => this.createLineElement(index)}
- @click=${e => this.#handleClick(e)}
- >
- </virtualized-list>
+ <div @click=${this.#handleClick} class="container">
+ <div class="beforeList">${this.renderBeforeList()}</div>
+ ${this.renderList()}
+ <div class="afterList">${this.renderAfterList()}</div>
</div>
`;
}
diff --git a/toolkit/components/satchel/megalist/content/megalist.css b/toolkit/components/satchel/megalist/content/megalist.css
index b442a7b60d..3f8bb9de2c 100644
--- a/toolkit/components/satchel/megalist/content/megalist.css
+++ b/toolkit/components/satchel/megalist/content/megalist.css
@@ -8,18 +8,27 @@
display: flex;
flex-direction: column;
justify-content: center;
- max-height: 100vh;
+ height: 100vh;
- > search-input {
+ > .beforeList {
margin: 20px;
+
+ .search {
+ padding: 8px;
+ border-radius: 4px;
+ border: 1px solid var(--in-content-border-color);
+ box-sizing: border-box;
+ width: 100%;
+ }
}
}
virtualized-list {
position: relative;
overflow: auto;
- margin: 20px;
-
+ margin-block: 20px;
+ padding-inline: 20px;
+ flex-grow: 1;
.lines-container {
padding-inline-start: unset;
}
@@ -29,7 +38,7 @@ virtualized-list {
display: flex;
align-items: stretch;
position: absolute;
- width: 100%;
+ width: calc(100% - 40px);
user-select: none;
box-sizing: border-box;
height: 64px;
@@ -93,11 +102,19 @@ virtualized-list {
> .content {
flex-grow: 1;
+ &:not(.section) {
+ display: grid;
+ grid-template-rows: max-content 1fr;
+ grid-template-columns: max-content;
+ grid-column-gap: 8px;
+ padding-inline-start: 8px;
+ padding-block-start: 4px;
+ }
+
> div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- padding-inline-start: 10px;
&:last-child {
padding-block-end: 10px;
@@ -115,6 +132,8 @@ virtualized-list {
> .label {
color: var(--text-color-deemphasized);
padding-block: 2px 4px;
+ grid-row: 1;
+ align-content: end;
}
> .value {
@@ -125,7 +144,7 @@ virtualized-list {
fill: currentColor;
width: auto;
height: 16px;
- margin-inline: 4px;
+ margin-inline-end: 4px;
vertical-align: text-bottom;
}
@@ -139,12 +158,14 @@ virtualized-list {
}
> .stickers {
- text-align: end;
- margin-block-start: 2px;
+ grid-row: 1;
+ align-content: start;
> span {
- padding: 2px;
+ padding: 4px;
margin-inline-end: 2px;
+ border-radius: 24px;
+ font-size: xx-small;
}
/* Hard-coded colors will be addressed in FXCM-1013 */
@@ -159,6 +180,12 @@ virtualized-list {
border: 1px solid maroon;
color: whitesmoke;
}
+
+ > span.error {
+ background-color: orange;
+ border: 1px solid orangered;
+ color: black;
+ }
}
&.section {
@@ -199,10 +226,46 @@ virtualized-list {
}
}
-.search {
- padding: 8px;
- border-radius: 4px;
- border: 1px solid var(--in-content-border-color);
- box-sizing: border-box;
+/* Dialog styles */
+.dialog-overlay {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 16px;
+ position: fixed;
+ top: 0;
+ left: 0;
width: 100%;
+ height: 100%;
+ z-index: 1;
+ background-color: rgba(0, 0, 0, 0.5);
+ box-sizing: border-box;
+ /* TODO: probably want to remove this later ? */
+ backdrop-filter: blur(6px);
+}
+
+.dialog-container {
+ display: grid;
+ padding: 16px 32px;
+ color: var(--in-content-text-color);
+ background-color: var(--in-content-box-background);
+ border: 1px solid var(--in-content-border-color);
+ box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.1);
+}
+
+.dialog-title {
+ margin: 0;
+}
+
+.dismiss-button {
+ justify-self: end;
+}
+
+.dialog-content {
+ margin-block-start: 16px;
+ margin-block-end: 16px;
+
+ .checkbox-text {
+ margin-block-start: 8px;
+ }
}
diff --git a/toolkit/components/satchel/megalist/content/megalist.ftl b/toolkit/components/satchel/megalist/content/megalist.ftl
index 69d085a7c5..4089477add 100644
--- a/toolkit/components/satchel/megalist/content/megalist.ftl
+++ b/toolkit/components/satchel/megalist/content/megalist.ftl
@@ -23,6 +23,7 @@ command-cancel = Cancel
passwords-section-label = Passwords
passwords-disabled = Passwords are disabled
+passwords-dismiss-breach-alert-command = Dismiss breach alert
passwords-command-create = Add Password
passwords-command-import = Import from a File…
passwords-command-export = Export Passwords…
@@ -65,6 +66,33 @@ passwords-filtered-count =
*[other] { $count } of { $total } passwords
}
+# Confirm the removal of all saved passwords
+# $total (number) - Total number of passwords
+passwords-remove-all-title =
+ { $total ->
+ [one] Remove { $total } password?
+ *[other] Remove all { $total } passwords?
+ }
+
+# Checkbox label to confirm the removal of saved passwords
+# $total (number) - Total number of passwords
+passwords-remove-all-confirm =
+ { $total ->
+ [1] Yes, remove password
+ *[other] Yes, remove passwords
+ }
+
+# Button label to confirm removal of saved passwords
+passwords-remove-all-confirm-button = Confirm
+
+# Message to confirm the removal of saved passwords
+# $total (number) - Total number of passwords
+passwords-remove-all-message =
+ { $total ->
+ [1] This will remove your saved password and any breach alerts. You cannot undo this action.
+ *[other] This will remove your saved passwords and any breach alerts. You cannot undo this action.
+ }
+
passwords-origin-label = Website address
passwords-username-label = Username
passwords-password-label = Password
diff --git a/toolkit/components/satchel/megalist/content/megalist.html b/toolkit/components/satchel/megalist/content/megalist.html
index 6ff3f089fc..9d15587033 100644
--- a/toolkit/components/satchel/megalist/content/megalist.html
+++ b/toolkit/components/satchel/megalist/content/megalist.html
@@ -15,7 +15,12 @@
src="chrome://global/content/megalist/MegalistView.mjs"
></script>
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://global/content/megalist/megalist.css"
+ />
<link rel="localization" href="preview/megalist.ftl" />
+ <link rel="localization" href="browser/aboutLogins.ftl" />
</head>
<body>
@@ -56,11 +61,11 @@
<template id="lineTemplate">
<div class="content">
<div class="label"></div>
+ <div class="stickers"></div>
<div class="value">
<img class="icon" />
<span></span>
</div>
- <div class="stickers"></div>
</div>
</template>
@@ -74,5 +79,173 @@
<div class="stickers"></div>
</div>
</template>
+
+ <template id="dialog-template">
+ <div class="dialog-overlay">
+ <div class="dialog-container">
+ <moz-button
+ data-l10n-id="confirmation-dialog-dismiss-button"
+ iconSrc="chrome://global/skin/icons/close.svg"
+ size="small"
+ type="icon ghost"
+ class="dismiss-button"
+ close-dialog
+ >
+ </moz-button>
+ <div class="dialog-wrapper"></div>
+ </div>
+ </div>
+ </template>
+
+ <template id="remove-logins-dialog-template">
+ <h2
+ class="dialog-title"
+ data-l10n-id="about-logins-confirm-remove-all-sync-dialog-title2"
+ localizable
+ ></h2>
+ <div class="dialog-content" slot="dialog-content">
+ <p data-l10n-id="about-logins-confirm-export-dialog-message2"></p>
+ <label>
+ <input type="checkbox" class="confirm-checkbox checkbox" autofocus />
+ <span
+ class="checkbox-text"
+ data-l10n-id="about-logins-confirm-remove-all-dialog-checkbox-label2"
+ ></span>
+ </label>
+ </div>
+ <moz-button-group>
+ <button
+ class="primary danger-button"
+ data-l10n-id="about-logins-confirm-remove-all-dialog-confirm-button-label"
+ data-command="LoginDataSource.confirmRemoveAll"
+ ></button>
+ <button
+ close-dialog
+ data-l10n-id="confirmation-dialog-cancel-button"
+ ></button>
+ </moz-button-group>
+ </template>
+
+ <template id="remove-login-dialog-template">
+ <h2
+ class="dialog-title"
+ data-l10n-id="about-logins-confirm-delete-dialog-title"
+ ></h2>
+ <div class="dialog-content" slot="dialog-content">
+ <p data-l10n-id="about-logins-confirm-delete-dialog-message"></p>
+ </div>
+ <moz-button-group>
+ <button
+ class="primary danger-button"
+ data-l10n-id="about-logins-confirm-remove-dialog-confirm-button"
+ data-command="LoginDataSource.confirmRemoveLogin"
+ ></button>
+ <button
+ close-dialog
+ data-l10n-id="confirmation-dialog-cancel-button"
+ ></button>
+ </moz-button-group>
+ </template>
+ <template id="export-logins-dialog-template">
+ <h2
+ class="dialog-title"
+ data-l10n-id="about-logins-confirm-export-dialog-title2"
+ ></h2>
+ <div class="dialog-content" slot="dialog-content">
+ <p data-l10n-id="about-logins-confirm-export-dialog-message2"></p>
+ </div>
+ <moz-button-group>
+ <button
+ class="primary danger-button"
+ data-l10n-id="about-logins-confirm-export-dialog-confirm-button2"
+ data-command="LoginDataSource.confirmExportLogins"
+ ></button>
+ <button
+ close-dialog
+ data-l10n-id="confirmation-dialog-cancel-button"
+ ></button>
+ </moz-button-group>
+ </template>
+
+ <template id="import-logins-dialog-template">
+ <h2
+ class="dialog-title"
+ data-l10n-id="about-logins-import-dialog-title"
+ ></h2>
+ <div class="dialog-content">
+ <div data-l10n-id="about-logins-import-dialog-items-added2" localizable>
+ <span></span>
+ <span data-l10n-name="count"></span>
+ </div>
+ <div
+ data-l10n-id="about-logins-import-dialog-items-modified2"
+ localizable
+ >
+ <span></span>
+ <span data-l10n-name="count"></span>
+ </div>
+ <div
+ data-l10n-id="about-logins-import-dialog-items-no-change2"
+ data-l10n-name="no-change"
+ localizable
+ >
+ <span></span>
+ <span data-l10n-name="count"></span>
+ <span data-l10n-name="meta"></span>
+ </div>
+ <div data-l10n-id="about-logins-import-dialog-items-error" localizable>
+ <span></span>
+ <span data-l10n-name="count"></span>
+ <span data-l10n-name="meta"></span>
+ </div>
+ <a
+ class="open-detailed-report"
+ href="about:loginsimportreport"
+ target="_blank"
+ data-l10n-id="about-logins-alert-import-message"
+ ></a>
+ </div>
+ <button
+ class="primary"
+ data-l10n-id="about-logins-import-dialog-done"
+ close-dialog
+ ></button>
+ </template>
+
+ <template id="import-error-dialog-template">
+ <h2
+ class="dialog-title"
+ data-l10n-id="about-logins-import-dialog-error-title"
+ ></h2>
+ <div class="dialog-content">
+ <p
+ data-l10n-id="about-logins-import-dialog-error-file-format-title"
+ ></p>
+ <p
+ data-l10n-id="about-logins-import-dialog-error-file-format-description"
+ ></p>
+ <p
+ data-l10n-id="about-logins-import-dialog-error-no-logins-imported"
+ ></p>
+ <a
+ class="error-learn-more-link"
+ href="https://support.mozilla.org/kb/import-login-data-file"
+ data-l10n-id="about-logins-import-dialog-error-learn-more"
+ target="_blank"
+ rel="noreferrer"
+ ></a>
+ </div>
+ <moz-button-group>
+ <button
+ class="primary"
+ data-l10n-id="about-logins-import-dialog-error-try-import-again"
+ data-command="LoginDataSource.confirmRetryImport"
+ ></button>
+ <button
+ close-dialog
+ data-l10n-id="confirmation-dialog-cancel-button"
+ ></button>
+ </moz-button-group>
+ </template>
</body>
</html>
diff --git a/toolkit/components/satchel/megalist/content/search-input.mjs b/toolkit/components/satchel/megalist/content/search-input.mjs
deleted file mode 100644
index e30d13ef2a..0000000000
--- a/toolkit/components/satchel/megalist/content/search-input.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
-import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
-
-export default class SearchInput extends MozLitElement {
- static get properties() {
- return {
- items: { type: Array },
- change: { type: Function },
- value: { type: String },
- };
- }
-
- render() {
- return html` <link
- rel="stylesheet"
- href="chrome://global/content/megalist/megalist.css"
- />
- <link
- rel="stylesheet"
- href="chrome://global/skin/in-content/common.css"
- />
- <input
- class="search"
- type="search"
- data-l10n-id="filter-placeholder"
- @input=${this.change}
- .value=${this.value}
- />`;
- }
-}
-
-customElements.define("search-input", SearchInput);
diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp
index 61d23d157c..342e1d29b7 100644
--- a/toolkit/components/satchel/nsFormFillController.cpp
+++ b/toolkit/components/satchel/nsFormFillController.cpp
@@ -23,7 +23,6 @@
#include "mozilla/Services.h"
#include "mozilla/StaticPrefs_ui.h"
#include "nsCRT.h"
-#include "nsIFormHistoryAutoComplete.h"
#include "nsString.h"
#include "nsPIDOMWindow.h"
#include "nsIAutoCompleteResult.h"
@@ -47,25 +46,8 @@ using mozilla::LogLevel;
static mozilla::LazyLogModule sLogger("satchel");
-static nsIFormHistoryAutoComplete* GetFormHistoryAutoComplete() {
- static nsCOMPtr<nsIFormHistoryAutoComplete> sInstance;
- static bool sInitialized = false;
- if (!sInitialized) {
- nsresult rv;
- sInstance =
- do_GetService("@mozilla.org/satchel/form-history-autocomplete;1", &rv);
-
- if (NS_SUCCEEDED(rv)) {
- ClearOnShutdown(&sInstance);
- sInitialized = true;
- }
- }
- return sInstance;
-}
-
-NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mLoginManagerAC,
- mFocusedPopup, mPopups, mLastListener,
- mLastFormHistoryAutoComplete)
+NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mFocusedPopup,
+ mLastListener)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsFormFillController)
NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIFormFillController)
@@ -205,8 +187,7 @@ void nsFormFillController::ARIAAttributeDefaultChanged(
MOZ_CAN_RUN_SCRIPT_BOUNDARY
void nsFormFillController::NodeWillBeDestroyed(nsINode* aNode) {
MOZ_LOG(sLogger, LogLevel::Verbose, ("NodeWillBeDestroyed: %p", aNode));
- mPwmgrInputs.Remove(aNode);
- mAutofillInputs.Remove(aNode);
+ mAutoCompleteInputs.Remove(aNode);
MaybeRemoveMutationObserver(aNode);
if (aNode == mListNode) {
mListNode = nullptr;
@@ -217,9 +198,9 @@ void nsFormFillController::NodeWillBeDestroyed(nsINode* aNode) {
}
void nsFormFillController::MaybeRemoveMutationObserver(nsINode* aNode) {
- // Nodes being tracked in mPwmgrInputs will have their observers removed when
- // they stop being tracked.
- if (!mPwmgrInputs.Get(aNode) && !mAutofillInputs.Get(aNode)) {
+ // Nodes being tracked in mAutoCompleteInputs will have their observers
+ // removed when they stop being tracked.
+ if (!mAutoCompleteInputs.Get(aNode)) {
aNode->RemoveMutationObserver(this);
}
}
@@ -228,84 +209,7 @@ void nsFormFillController::MaybeRemoveMutationObserver(nsINode* aNode) {
//// nsIFormFillController
NS_IMETHODIMP
-nsFormFillController::AttachPopupElementToDocument(Document* aDocument,
- dom::Element* aPopupEl) {
- if (!xpc::IsInAutomation()) {
- return NS_ERROR_NOT_AVAILABLE;
- }
-
- MOZ_LOG(sLogger, LogLevel::Debug,
- ("AttachPopupElementToDocument for document %p with popup %p",
- aDocument, aPopupEl));
- NS_ENSURE_TRUE(aDocument && aPopupEl, NS_ERROR_ILLEGAL_VALUE);
-
- nsCOMPtr<nsIAutoCompletePopup> popup = aPopupEl->AsAutoCompletePopup();
- NS_ENSURE_STATE(popup);
-
- mPopups.InsertOrUpdate(aDocument, popup);
- return NS_OK;
-}
-
-NS_IMETHODIMP
-nsFormFillController::DetachFromDocument(Document* aDocument) {
- if (!xpc::IsInAutomation()) {
- return NS_ERROR_NOT_AVAILABLE;
- }
- mPopups.Remove(aDocument);
- return NS_OK;
-}
-
-NS_IMETHODIMP
-nsFormFillController::MarkAsLoginManagerField(HTMLInputElement* aInput) {
- /*
- * The Login Manager can supply autocomplete results for username fields,
- * when a user has multiple logins stored for a site. It uses this
- * interface to indicate that the form manager shouldn't handle the
- * autocomplete. The form manager also checks for this tag when saving
- * form history (so it doesn't save usernames).
- */
- NS_ENSURE_STATE(aInput);
-
- // If the field was already marked, we don't want to show the popup again.
- if (mPwmgrInputs.Get(aInput)) {
- return NS_OK;
- }
-
- mPwmgrInputs.InsertOrUpdate(aInput, true);
- aInput->AddMutationObserverUnlessExists(this);
-
- nsFocusManager* fm = nsFocusManager::GetFocusManager();
- if (fm) {
- nsCOMPtr<nsIContent> focusedContent = fm->GetFocusedElement();
- if (focusedContent == aInput) {
- if (!mFocusedInput) {
- MaybeStartControllingInput(aInput);
- } else {
- // If we change who is responsible for searching the autocomplete
- // result, notify the controller that the previous result is not valid
- // anymore.
- nsCOMPtr<nsIAutoCompleteController> controller = mController;
- controller->ResetInternalState();
- }
- }
- }
-
- if (!mLoginManagerAC) {
- mLoginManagerAC =
- do_GetService("@mozilla.org/login-manager/autocompletesearch;1");
- }
-
- return NS_OK;
-}
-
-MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP nsFormFillController::IsLoginManagerField(
- HTMLInputElement* aInput, bool* isLoginManagerField) {
- *isLoginManagerField = mPwmgrInputs.Get(aInput);
- return NS_OK;
-}
-
-NS_IMETHODIMP
-nsFormFillController::MarkAsAutofillField(HTMLInputElement* aInput) {
+nsFormFillController::MarkAsAutoCompletableField(HTMLInputElement* aInput) {
/*
* Support other components implementing form autofill and handle autocomplete
* for the field.
@@ -313,13 +217,13 @@ nsFormFillController::MarkAsAutofillField(HTMLInputElement* aInput) {
NS_ENSURE_STATE(aInput);
MOZ_LOG(sLogger, LogLevel::Verbose,
- ("MarkAsAutofillField: aInput = %p", aInput));
+ ("MarkAsAutoCompletableField: aInput = %p", aInput));
- if (mAutofillInputs.Get(aInput)) {
+ if (mAutoCompleteInputs.Get(aInput)) {
return NS_OK;
}
- mAutofillInputs.InsertOrUpdate(aInput, true);
+ mAutoCompleteInputs.InsertOrUpdate(aInput, true);
aInput->AddMutationObserverUnlessExists(this);
aInput->EnablePreview();
@@ -522,18 +426,15 @@ nsFormFillController::GetSearchCount(uint32_t* aSearchCount) {
NS_IMETHODIMP
nsFormFillController::GetSearchAt(uint32_t index, nsACString& _retval) {
- if (mAutofillInputs.Get(mFocusedInput)) {
- MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: autofill-profiles field"));
- nsCOMPtr<nsIAutoCompleteSearch> profileSearch = do_GetService(
- "@mozilla.org/autocomplete/search;1?name=autofill-profiles");
- if (profileSearch) {
- _retval.AssignLiteral("autofill-profiles");
- return NS_OK;
- }
- }
-
- MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: form-history field"));
- _retval.AssignLiteral("form-history");
+ MOZ_LOG(sLogger, LogLevel::Debug,
+ ("GetSearchAt: form-fill-controller field"));
+
+ // The better solution should be AutoCompleteController gets the
+ // nsIAutoCompleteSearch interface from AutoCompletePopup and invokes the
+ // StartSearch without going through FormFillController. Currently
+ // FormFillController acts as the proxy to find the AutoCompletePopup for
+ // AutoCompleteController.
+ _retval.AssignLiteral("form-fill-controller");
return NS_OK;
}
@@ -636,13 +537,12 @@ nsFormFillController::GetNoRollupOnCaretMove(bool* aNoRollupOnCaretMove) {
NS_IMETHODIMP
nsFormFillController::GetNoRollupOnEmptySearch(bool* aNoRollupOnEmptySearch) {
- if (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) ||
- mFocusedInput->HasBeenTypePassword())) {
- // Don't close the login popup when the field is cleared (bug 1534896).
- *aNoRollupOnEmptySearch = true;
- } else {
- *aNoRollupOnEmptySearch = false;
+ if (mFocusedInput && mFocusedPopup) {
+ return mFocusedPopup->GetNoRollupOnEmptySearch(mFocusedInput,
+ aNoRollupOnEmptySearch);
}
+
+ *aNoRollupOnEmptySearch = false;
return NS_OK;
}
@@ -669,61 +569,32 @@ nsFormFillController::StartSearch(const nsAString& aSearchString,
nsIAutoCompleteObserver* aListener) {
MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch for %p", mFocusedInput));
- nsresult rv;
-
- // If the login manager has indicated it's responsible for this field, let it
- // handle the autocomplete. Otherwise, handle with form history.
- // This method is sometimes called in unit tests and from XUL without a
- // focused node.
- if (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) ||
- mFocusedInput->HasBeenTypePassword())) {
- MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: login field"));
-
- // Handle the case where a password field is focused but
- // MarkAsLoginManagerField wasn't called because password manager is
- // disabled.
- if (!mLoginManagerAC) {
- mLoginManagerAC =
- do_GetService("@mozilla.org/login-manager/autocompletesearch;1");
- }
-
- if (NS_WARN_IF(!mLoginManagerAC)) {
- return NS_ERROR_FAILURE;
- }
+ mLastListener = aListener;
- // XXX aPreviousResult shouldn't ever be a historyResult type, since we're
- // not letting satchel manage the field?
- mLastListener = aListener;
- rv = mLoginManagerAC->StartSearch(aSearchString, aPreviousResult,
- mFocusedInput, this);
- NS_ENSURE_SUCCESS(rv, rv);
- } else {
- MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: non-login field"));
- mLastListener = aListener;
+ if (mFocusedInput && mFocusedPopup) {
+ if (mAutoCompleteInputs.Get(mFocusedInput) ||
+ mFocusedInput->HasBeenTypePassword()) {
+ MOZ_LOG(sLogger, LogLevel::Debug,
+ ("StartSearch: formautofill or login field"));
- bool addDataList = IsTextControl(mFocusedInput);
- if (addDataList) {
- MaybeObserveDataListMutations();
+ return mFocusedPopup->StartSearch(aSearchString, mFocusedInput, this);
}
+ }
- auto* formHistoryAutoComplete = GetFormHistoryAutoComplete();
- NS_ENSURE_TRUE(formHistoryAutoComplete, NS_ERROR_FAILURE);
+ MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: form history field"));
- formHistoryAutoComplete->AutoCompleteSearchAsync(
- aSearchParam, aSearchString, mFocusedInput, aPreviousResult,
- addDataList, this);
- mLastFormHistoryAutoComplete = formHistoryAutoComplete;
+ bool addDataList = IsTextControl(mFocusedInput);
+ if (addDataList) {
+ MaybeObserveDataListMutations();
}
- return NS_OK;
+ return mFocusedPopup->StartSearch(aSearchString, mFocusedInput, this);
}
void nsFormFillController::MaybeObserveDataListMutations() {
// If an <input> is focused, check if it has a list="<datalist>" which can
// provide the list of suggestions.
- MOZ_ASSERT(!mPwmgrInputs.Get(mFocusedInput));
-
if (mFocusedInput) {
Element* list = mFocusedInput->GetList();
@@ -760,16 +631,10 @@ void nsFormFillController::RevalidateDataList() {
NS_IMETHODIMP
nsFormFillController::StopSearch() {
- // Make sure to stop and clear this, otherwise the controller will prevent
- // mLastFormHistoryAutoComplete from being deleted.
- if (mLastFormHistoryAutoComplete) {
- mLastFormHistoryAutoComplete->StopAutoCompleteSearch();
- mLastFormHistoryAutoComplete = nullptr;
+ if (mFocusedPopup) {
+ mFocusedPopup->StopSearch();
}
- if (mLoginManagerAC) {
- mLoginManagerAC->StopSearch();
- }
return NS_OK;
}
@@ -927,19 +792,8 @@ void nsFormFillController::AttachListeners(EventTarget* aEventTarget) {
void nsFormFillController::RemoveForDocument(Document* aDoc) {
MOZ_LOG(sLogger, LogLevel::Verbose, ("RemoveForDocument: %p", aDoc));
- for (auto iter = mPwmgrInputs.Iter(); !iter.Done(); iter.Next()) {
- const nsINode* key = iter.Key();
- if (key && (!aDoc || key->OwnerDoc() == aDoc)) {
- // mFocusedInput's observer is tracked separately, so don't remove it
- // here.
- if (key != mFocusedInput) {
- const_cast<nsINode*>(key)->RemoveMutationObserver(this);
- }
- iter.Remove();
- }
- }
- for (auto iter = mAutofillInputs.Iter(); !iter.Done(); iter.Next()) {
+ for (auto iter = mAutoCompleteInputs.Iter(); !iter.Done(); iter.Next()) {
const nsINode* key = iter.Key();
if (key && (!aDoc || key->OwnerDoc() == aDoc)) {
// mFocusedInput's observer is tracked separately, so don't remove it
@@ -975,19 +829,8 @@ void nsFormFillController::MaybeStartControllingInput(
return;
}
- bool autocomplete = nsContentUtils::IsAutocompleteEnabled(aInput);
-
- bool isPwmgrInput = false;
- if (mPwmgrInputs.Get(aInput) || aInput->HasBeenTypePassword()) {
- isPwmgrInput = true;
- }
-
- bool isAutofillInput = false;
- if (mAutofillInputs.Get(aInput)) {
- isAutofillInput = true;
- }
-
- if (isAutofillInput || isPwmgrInput || hasList || autocomplete) {
+ if (mAutoCompleteInputs.Get(aInput) || aInput->HasBeenTypePassword() ||
+ hasList || nsContentUtils::IsAutocompleteEnabled(aInput)) {
StartControllingInput(aInput);
}
}
@@ -1228,12 +1071,10 @@ void nsFormFillController::StartControllingInput(HTMLInputElement* aInput) {
return;
}
- nsCOMPtr<nsIAutoCompletePopup> popup = mPopups.Get(aInput->OwnerDoc());
+ nsCOMPtr<nsIAutoCompletePopup> popup =
+ do_QueryActor("AutoComplete", aInput->OwnerDoc());
if (!popup) {
- popup = do_QueryActor("AutoComplete", aInput->OwnerDoc());
- if (!popup) {
- return;
- }
+ return;
}
mFocusedPopup = popup;
diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h
index 239c293352..c6781e6d2f 100644
--- a/toolkit/components/satchel/nsFormFillController.h
+++ b/toolkit/components/satchel/nsFormFillController.h
@@ -19,7 +19,6 @@
#include "nsTHashMap.h"
#include "nsInterfaceHashtable.h"
#include "nsIDocShell.h"
-#include "nsILoginAutoCompleteSearch.h"
#include "nsIMutationObserver.h"
#include "nsIObserver.h"
#include "nsCycleCollectionParticipant.h"
@@ -98,13 +97,9 @@ class nsFormFillController final : public nsIFormFillController,
bool IsTextControl(nsINode* aNode);
- MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP isLoginManagerField(
- mozilla::dom::HTMLInputElement* aInput, bool* isLoginManagerField);
-
// members //////////////////////////////////////////
nsCOMPtr<nsIAutoCompleteController> mController;
- nsCOMPtr<nsILoginAutoCompleteSearch> mLoginManagerAC;
mozilla::dom::HTMLInputElement* mFocusedInput;
// mListNode is a <datalist> element which, is set, has the form fill
@@ -112,21 +107,14 @@ class nsFormFillController final : public nsIFormFillController,
nsINode* mListNode;
nsCOMPtr<nsIAutoCompletePopup> mFocusedPopup;
- // Only used by tests.
- nsInterfaceHashtable<nsRefPtrHashKey<mozilla::dom::Document>,
- nsIAutoCompletePopup>
- mPopups;
-
// The observer passed to StartSearch. It will be notified when the search
// is complete or the data from a datalist changes.
nsCOMPtr<nsIAutoCompleteObserver> mLastListener;
// This is cleared by StopSearch().
- nsCOMPtr<nsIFormHistoryAutoComplete> mLastFormHistoryAutoComplete;
nsString mLastSearchString;
- nsTHashMap<nsPtrHashKey<const nsINode>, bool> mPwmgrInputs;
- nsTHashMap<nsPtrHashKey<const nsINode>, bool> mAutofillInputs;
+ nsTHashMap<nsPtrHashKey<const nsINode>, bool> mAutoCompleteInputs;
uint16_t mFocusAfterRightClickThreshold;
uint32_t mTimeout;
diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl
index 24d9bf8193..32d4e4ce89 100644
--- a/toolkit/components/satchel/nsIFormFillController.idl
+++ b/toolkit/components/satchel/nsIFormFillController.idl
@@ -35,33 +35,13 @@ interface nsIFormFillController : nsISupports
*/
readonly attribute boolean passwordPopupAutomaticallyOpened;
- // Only used by tests.
- void attachPopupElementToDocument(in Document document, in Element popup);
- void detachFromDocument(in Document document);
-
- /*
- * Returns true if aInput is managed by the login manager.
- *
- * @param aInput - The HTML <input> element to tag
- */
- [can_run_script] boolean isLoginManagerField(in HTMLInputElement aInput);
-
- /*
- * Mark the specified <input> element as being managed by password manager.
- * Autocomplete requests will be handed off to the password manager, and will
- * not be stored in form history.
- *
- * @param aInput - The HTML <input> element to tag
- */
- [can_run_script] void markAsLoginManagerField(in HTMLInputElement aInput);
-
/*
- * Mark the specified <input> element as being managed by a form autofill component.
- * Autocomplete requests will be handed off to the autofill component.
+ * Mark the specified <input> element as being managed by autocomplete entry provider.
+ * Autocomplete requests will be handed off to the AutoCompleteChild.
*
* @param aInput - The HTML <input> element to mark
*/
- [can_run_script] void markAsAutofillField(in HTMLInputElement aInput);
+ [can_run_script] void markAsAutoCompletableField(in HTMLInputElement aInput);
/*
* Open the autocomplete popup, if possible.
diff --git a/toolkit/components/satchel/test/browser/browser.toml b/toolkit/components/satchel/test/browser/browser.toml
index 7207c27f34..d70757f983 100644
--- a/toolkit/components/satchel/test/browser/browser.toml
+++ b/toolkit/components/satchel/test/browser/browser.toml
@@ -1,10 +1,19 @@
[DEFAULT]
-support-files = ["!/toolkit/components/satchel/test/subtst_privbrowsing.html"]
+support-files = [
+ "!/toolkit/components/satchel/test/subtst_privbrowsing.html",
+ "formhistory_autocomplete.sqlite",
+]
+
+["browser_autocomplete.js"]
+lineno = "7"
["browser_close_tab.js"]
+lineno = "10"
["browser_popup_mouseover.js"]
skip-if = ["verify"]
+lineno = "13"
["browser_privbrowsing_perwindowpb.js"]
skip-if = ["verify"]
+lineno = "17"
diff --git a/toolkit/components/satchel/test/browser/browser_autocomplete.js b/toolkit/components/satchel/test/browser/browser_autocomplete.js
new file mode 100644
index 0000000000..808833187b
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_autocomplete.js
@@ -0,0 +1,403 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+
+function padLeft(number, length) {
+ let str = number + "";
+ while (str.length < length) {
+ str = "0" + str;
+ }
+ return str;
+}
+
+function getFormExpiryDays() {
+ if (Services.prefs.prefHasUserValue("browser.formfill.expire_days")) {
+ return Services.prefs.getIntPref("browser.formfill.expire_days");
+ }
+ return DEFAULT_EXPIRE_DAYS;
+}
+
+async function countEntries(name, value) {
+ let obj = {};
+ if (name) {
+ obj.fieldname = name;
+ }
+ if (value) {
+ obj.value = value;
+ }
+
+ return await FormHistory.count(obj);
+}
+
+const DEFAULT_EXPIRE_DAYS = 180;
+let gTimeGroupingSize;
+let gNumRecords;
+let gNow;
+
+add_setup(async function () {
+ gTimeGroupingSize =
+ Services.prefs.getIntPref("browser.formfill.timeGroupingSize") *
+ 1000 *
+ 1000;
+
+ const maxTimeGroupings = Services.prefs.getIntPref(
+ "browser.formfill.maxTimeGroupings"
+ );
+ const bucketSize = Services.prefs.getIntPref("browser.formfill.bucketSize");
+
+ // ===== Tests with constant timesUsed and varying lastUsed date =====
+ // insert 2 records per bucket to check alphabetical sort within
+ gNow = 1000 * Date.now();
+ gNumRecords = Math.ceil(maxTimeGroupings / bucketSize) * 2;
+
+ let changes = [];
+ for (let i = 0; i < gNumRecords; i += 2) {
+ let useDate = gNow - (i / 2) * bucketSize * gTimeGroupingSize;
+
+ changes.push({
+ op: "add",
+ fieldname: "field1",
+ value: "value" + padLeft(gNumRecords - 1 - i, 2),
+ timesUsed: 1,
+ firstUsed: useDate,
+ lastUsed: useDate,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field1",
+ value: "value" + padLeft(gNumRecords - 2 - i, 2),
+ timesUsed: 1,
+ firstUsed: useDate,
+ lastUsed: useDate,
+ });
+ }
+
+ await FormHistory.update(changes);
+
+ Assert.ok(
+ (await countEntries("field1", null)) > 0,
+ "Check initial state is as expected"
+ );
+
+ registerCleanupFunction(async () => {
+ await FormHistory.update([
+ {
+ op: "remove",
+ firstUsedStart: 0,
+ },
+ ]);
+ });
+});
+
+async function focusAndWaitForPopupOpen(browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+ input.focus();
+ });
+
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return browser.autoCompletePopup.popupOpen;
+ });
+}
+
+async function unfocusAndWaitForPopupClose(browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+ input.blur();
+ });
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !browser.autoCompletePopup.popupOpen;
+ });
+}
+
+add_task(async function test_search_contains_all_entries() {
+ const url = `data:text/html,<input type="text" name="field1">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ const { autoCompletePopup } = browser;
+
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(
+ gNumRecords,
+ autoCompletePopup.matchCount,
+ "Check search contains all entries"
+ );
+
+ info("Check search result ordering with empty search term");
+ let lastFound = gNumRecords;
+ for (let i = 0; i < gNumRecords; i += 2) {
+ Assert.equal(
+ parseInt(autoCompletePopup.view.getValueAt(i + 1).substr(5), 10),
+ --lastFound
+ );
+ Assert.equal(
+ parseInt(autoCompletePopup.view.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+
+ await unfocusAndWaitForPopupClose(browser);
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.querySelector("input").setUserInput("v");
+ });
+
+ await focusAndWaitForPopupOpen(browser);
+
+ info('Check search result ordering with "v"');
+ lastFound = gNumRecords;
+ for (let i = 0; i < gNumRecords; i += 2) {
+ Assert.equal(
+ parseInt(autoCompletePopup.view.getValueAt(i + 1).substr(5), 10),
+ --lastFound
+ );
+ Assert.equal(
+ parseInt(autoCompletePopup.view.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ }
+ );
+});
+
+add_task(async function test_times_used_samples() {
+ info("Begin tests with constant use dates and varying timesUsed");
+
+ const timesUsedSamples = 20;
+
+ let changes = [];
+ for (let i = 0; i < timesUsedSamples; i++) {
+ let timesUsed = timesUsedSamples - i;
+ let change = {
+ op: "add",
+ fieldname: "field2",
+ value: "value" + (timesUsedSamples - 1 - i),
+ timesUsed: timesUsed * gTimeGroupingSize,
+ firstUsed: gNow,
+ lastUsed: gNow,
+ };
+ changes.push(change);
+ }
+ await FormHistory.update(changes);
+
+ const url = `data:text/html,<input type="text" name="field2">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ await focusAndWaitForPopupOpen(browser);
+
+ info("Check search result ordering with empty search term");
+ let lastFound = timesUsedSamples;
+ for (let i = 0; i < timesUsedSamples; i++) {
+ Assert.equal(
+ parseInt(browser.autoCompletePopup.view.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+
+ await unfocusAndWaitForPopupClose(browser);
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.querySelector("input").setUserInput("v");
+ });
+ await focusAndWaitForPopupOpen(browser);
+
+ info('Check search result ordering with "v"');
+ lastFound = timesUsedSamples;
+ for (let i = 0; i < timesUsedSamples; i++) {
+ Assert.equal(
+ parseInt(browser.autoCompletePopup.view.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ }
+ );
+});
+
+add_task(async function test_age_bonus() {
+ info(
+ 'Check that "senior citizen" entries get a bonus (browser.formfill.agedBonus)'
+ );
+
+ const agedDate =
+ 1000 * (Date.now() - getFormExpiryDays() * 24 * 60 * 60 * 1000);
+
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field3",
+ value: "old but not senior",
+ timesUsed: 100,
+ firstUsed: agedDate + 60 * 1000 * 1000,
+ lastUsed: gNow,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field3",
+ value: "senior citizen",
+ timesUsed: 100,
+ firstUsed: agedDate - 60 * 1000 * 1000,
+ lastUsed: gNow,
+ });
+ await FormHistory.update(changes);
+
+ const url = `data:text/html,<input type="text" name="field3">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ const { autoCompletePopup } = browser;
+
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(autoCompletePopup.view.getValueAt(0), "senior citizen");
+ Assert.equal(autoCompletePopup.view.getValueAt(1), "old but not senior");
+ }
+ );
+});
+
+add_task(async function test_search_entry_past_future() {
+ info("Check entries that are really old or in the future");
+
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "date of 0",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: 0,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "in the future 1",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: gNow * 2,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "in the future 2",
+ timesUsed: 1,
+ firstUsed: gNow * 2,
+ lastUsed: gNow * 2,
+ });
+
+ await FormHistory.update(changes);
+
+ const url = `data:text/html,<input type="text" name="field4">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(browser.autoCompletePopup.matchCount, 3);
+ }
+ );
+});
+
+add_task(async function test_old_synchronous_api() {
+ info("Check old synchronous api");
+
+ const syncValues = ["sync1", "sync1a", "sync2", "sync3"];
+ let changes = [];
+ for (const value of syncValues) {
+ changes.push({ op: "add", fieldname: "field5", value });
+ }
+ await FormHistory.update(changes);
+});
+
+add_task(async function test_token_limit_db() {
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field6",
+ // value with 15 unique tokens
+ value: "a b c d e f g h i j k l m n o",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: 0,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field6",
+ value: "a b c d e f g h i j .",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: gNow * 2,
+ });
+
+ await FormHistory.update(changes);
+
+ const url = `data:text/html,<input type="text" name="field6">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ info(
+ "Check that the number of tokens used in a search is capped to MAX_SEARCH_TOKENS for performance when querying the DB"
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input[name=field6]");
+ input.setUserInput("a b c d e f g h i j .");
+ });
+
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(
+ browser.autoCompletePopup.matchCount,
+ 2,
+ "Only the first MAX_SEARCH_TOKENS tokens should be used for DB queries"
+ );
+
+ await unfocusAndWaitForPopupClose(browser);
+
+ info(
+ "Check that the number of tokens used in a search is not capped to MAX_SEARCH_TOKENS when using a previousResult"
+ );
+
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(
+ browser.autoCompletePopup.matchCount,
+ 1,
+ "All search tokens should be used with previous results"
+ );
+ }
+ );
+});
+
+add_task(async function test_can_search_escape_marker() {
+ await FormHistory.update({
+ op: "add",
+ fieldname: "field7",
+ value: "/* Further reading */ test",
+ timesUsed: 1,
+ firstUsed: gNow,
+ lastUsed: gNow,
+ });
+
+ const url = `data:text/html,<input type="text" name="field7">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+ input.setUserInput("/* Further reading */ t");
+ });
+
+ await focusAndWaitForPopupOpen(browser);
+
+ Assert.equal(browser.autoCompletePopup.matchCount, 1);
+ }
+ );
+});
diff --git a/toolkit/components/satchel/test/browser/browser_popup_mouseover.js b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
index 2b293ab983..3b18e814f2 100644
--- a/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
+++ b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
@@ -34,11 +34,8 @@ add_task(async function test() {
await BrowserTestUtils.waitForCondition(() => {
return autoCompletePopup.popupOpen;
});
- const listItemElems = itemsBox.querySelectorAll(
- ".autocomplete-richlistitem"
- );
Assert.equal(
- listItemElems.length,
+ autoCompletePopup.matchCount,
mockHistory.length,
"ensure result length"
);
@@ -57,6 +54,9 @@ add_task(async function test() {
);
// mouseover the second item
+ const listItemElems = itemsBox.querySelectorAll(
+ ".autocomplete-richlistitem"
+ );
EventUtils.synthesizeMouseAtCenter(listItemElems[1], {
type: "mouseover",
});
diff --git a/toolkit/components/satchel/test/browser/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/browser/formhistory_autocomplete.sqlite
new file mode 100644
index 0000000000..724cff73f6
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/formhistory_autocomplete.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js
deleted file mode 100644
index e64d34ea50..0000000000
--- a/toolkit/components/satchel/test/unit/test_autocomplete.js
+++ /dev/null
@@ -1,388 +0,0 @@
-/* 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/. */
-
-"use strict";
-
-var fac;
-
-var numRecords, timeGroupingSize, now;
-
-const DEFAULT_EXPIRE_DAYS = 180;
-
-function padLeft(number, length) {
- let str = number + "";
- while (str.length < length) {
- str = "0" + str;
- }
- return str;
-}
-
-function getFormExpiryDays() {
- if (Services.prefs.prefHasUserValue("browser.formfill.expire_days")) {
- return Services.prefs.getIntPref("browser.formfill.expire_days");
- }
- return DEFAULT_EXPIRE_DAYS;
-}
-
-function run_test() {
- // ===== test init =====
- let testfile = do_get_file("formhistory_autocomplete.sqlite");
- let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
-
- // Cleanup from any previous tests or failures.
- let destFile = profileDir.clone();
- destFile.append("formhistory.sqlite");
- if (destFile.exists()) {
- destFile.remove(false);
- }
-
- testfile.copyTo(profileDir, "formhistory.sqlite");
-
- fac = Cc["@mozilla.org/satchel/form-history-autocomplete;1"].getService(
- Ci.nsIFormHistoryAutoComplete
- );
-
- timeGroupingSize =
- Services.prefs.getIntPref("browser.formfill.timeGroupingSize") *
- 1000 *
- 1000;
-
- run_next_test();
-}
-
-add_test(function test0() {
- let maxTimeGroupings = Services.prefs.getIntPref(
- "browser.formfill.maxTimeGroupings"
- );
- let bucketSize = Services.prefs.getIntPref("browser.formfill.bucketSize");
-
- // ===== Tests with constant timesUsed and varying lastUsed date =====
- // insert 2 records per bucket to check alphabetical sort within
- now = 1000 * Date.now();
- numRecords = Math.ceil(maxTimeGroupings / bucketSize) * 2;
-
- let changes = [];
- for (let i = 0; i < numRecords; i += 2) {
- let useDate = now - (i / 2) * bucketSize * timeGroupingSize;
-
- changes.push({
- op: "add",
- fieldname: "field1",
- value: "value" + padLeft(numRecords - 1 - i, 2),
- timesUsed: 1,
- firstUsed: useDate,
- lastUsed: useDate,
- });
- changes.push({
- op: "add",
- fieldname: "field1",
- value: "value" + padLeft(numRecords - 2 - i, 2),
- timesUsed: 1,
- firstUsed: useDate,
- lastUsed: useDate,
- });
- }
-
- updateFormHistory(changes, run_next_test);
-});
-
-add_test(function test1() {
- do_log_info("Check initial state is as expected");
-
- countEntries(null, null, function () {
- countEntries("field1", null, function (count) {
- Assert.ok(count > 0);
- run_next_test();
- });
- });
-});
-
-add_test(function test2() {
- do_log_info("Check search contains all entries");
-
- fac.autoCompleteSearchAsync("field1", "", null, null, false, {
- onSearchCompletion(aResults) {
- Assert.equal(numRecords, aResults.matchCount);
- run_next_test();
- },
- });
-});
-
-add_test(function test3() {
- do_log_info("Check search result ordering with empty search term");
-
- let lastFound = numRecords;
- fac.autoCompleteSearchAsync("field1", "", null, null, false, {
- onSearchCompletion(aResults) {
- for (let i = 0; i < numRecords; i += 2) {
- Assert.equal(
- parseInt(aResults.getValueAt(i + 1).substr(5), 10),
- --lastFound
- );
- Assert.equal(
- parseInt(aResults.getValueAt(i).substr(5), 10),
- --lastFound
- );
- }
- run_next_test();
- },
- });
-});
-
-add_test(function test4() {
- do_log_info('Check search result ordering with "v"');
-
- let lastFound = numRecords;
- fac.autoCompleteSearchAsync("field1", "v", null, null, false, {
- onSearchCompletion(aResults) {
- for (let i = 0; i < numRecords; i += 2) {
- Assert.equal(
- parseInt(aResults.getValueAt(i + 1).substr(5), 10),
- --lastFound
- );
- Assert.equal(
- parseInt(aResults.getValueAt(i).substr(5), 10),
- --lastFound
- );
- }
- run_next_test();
- },
- });
-});
-
-const timesUsedSamples = 20;
-
-add_test(function test5() {
- do_log_info("Begin tests with constant use dates and varying timesUsed");
-
- let changes = [];
- for (let i = 0; i < timesUsedSamples; i++) {
- let timesUsed = timesUsedSamples - i;
- let change = {
- op: "add",
- fieldname: "field2",
- value: "value" + (timesUsedSamples - 1 - i),
- timesUsed: timesUsed * timeGroupingSize,
- firstUsed: now,
- lastUsed: now,
- };
- changes.push(change);
- }
- updateFormHistory(changes, run_next_test);
-});
-
-add_test(function test6() {
- do_log_info("Check search result ordering with empty search term");
-
- let lastFound = timesUsedSamples;
- fac.autoCompleteSearchAsync("field2", "", null, null, false, {
- onSearchCompletion(aResults) {
- for (let i = 0; i < timesUsedSamples; i++) {
- Assert.equal(
- parseInt(aResults.getValueAt(i).substr(5), 10),
- --lastFound
- );
- }
- run_next_test();
- },
- });
-});
-
-add_test(function test7() {
- do_log_info('Check search result ordering with "v"');
-
- let lastFound = timesUsedSamples;
- fac.autoCompleteSearchAsync("field2", "v", null, null, false, {
- onSearchCompletion(aResults) {
- for (let i = 0; i < timesUsedSamples; i++) {
- Assert.equal(
- parseInt(aResults.getValueAt(i).substr(5), 10),
- --lastFound
- );
- }
- run_next_test();
- },
- });
-});
-
-add_test(function test8() {
- do_log_info(
- 'Check that "senior citizen" entries get a bonus (browser.formfill.agedBonus)'
- );
-
- let agedDate =
- 1000 * (Date.now() - getFormExpiryDays() * 24 * 60 * 60 * 1000);
-
- let changes = [];
- changes.push({
- op: "add",
- fieldname: "field3",
- value: "old but not senior",
- timesUsed: 100,
- firstUsed: agedDate + 60 * 1000 * 1000,
- lastUsed: now,
- });
- changes.push({
- op: "add",
- fieldname: "field3",
- value: "senior citizen",
- timesUsed: 100,
- firstUsed: agedDate - 60 * 1000 * 1000,
- lastUsed: now,
- });
- updateFormHistory(changes, run_next_test);
-});
-
-add_test(function test9() {
- fac.autoCompleteSearchAsync("field3", "", null, null, false, {
- onSearchCompletion(aResults) {
- Assert.equal(aResults.getValueAt(0), "senior citizen");
- Assert.equal(aResults.getValueAt(1), "old but not senior");
- run_next_test();
- },
- });
-});
-
-add_test(function test10() {
- do_log_info("Check entries that are really old or in the future");
-
- let changes = [];
- changes.push({
- op: "add",
- fieldname: "field4",
- value: "date of 0",
- timesUsed: 1,
- firstUsed: 0,
- lastUsed: 0,
- });
- changes.push({
- op: "add",
- fieldname: "field4",
- value: "in the future 1",
- timesUsed: 1,
- firstUsed: 0,
- lastUsed: now * 2,
- });
- changes.push({
- op: "add",
- fieldname: "field4",
- value: "in the future 2",
- timesUsed: 1,
- firstUsed: now * 2,
- lastUsed: now * 2,
- });
- updateFormHistory(changes, run_next_test);
-});
-
-add_test(function test11() {
- fac.autoCompleteSearchAsync("field4", "", null, null, false, {
- onSearchCompletion(aResults) {
- Assert.equal(aResults.matchCount, 3);
- run_next_test();
- },
- });
-});
-
-var syncValues = ["sync1", "sync1a", "sync2", "sync3"];
-
-add_test(function test12() {
- do_log_info("Check old synchronous api");
-
- let changes = [];
- for (let value of syncValues) {
- changes.push({ op: "add", fieldname: "field5", value });
- }
- updateFormHistory(changes, run_next_test);
-});
-
-add_test(function test_token_limit_DB() {
- function test_token_limit_previousResult(previousResult) {
- do_log_info(
- "Check that the number of tokens used in a search is not capped to " +
- "MAX_SEARCH_TOKENS when using a previousResult"
- );
- // This provide more accuracy since performance is less of an issue.
- // Search for a string where the first 10 tokens match the previous value but the 11th does not
- // when re-using a previous result.
- fac.autoCompleteSearchAsync(
- "field_token_cap",
- "a b c d e f g h i j .",
- null,
- previousResult,
- false,
- {
- onSearchCompletion(aResults) {
- Assert.equal(
- aResults.matchCount,
- 0,
- "All search tokens should be used with previous results"
- );
- run_next_test();
- },
- }
- );
- }
-
- do_log_info(
- "Check that the number of tokens used in a search is capped to MAX_SEARCH_TOKENS " +
- "for performance when querying the DB"
- );
- let changes = [];
- changes.push({
- op: "add",
- fieldname: "field_token_cap",
- // value with 15 unique tokens
- value: "a b c d e f g h i j k l m n o",
- timesUsed: 1,
- firstUsed: 0,
- lastUsed: 0,
- });
- updateFormHistory(changes, () => {
- // Search for a string where the first 10 tokens match the value above but the 11th does not
- // (which would prevent the result from being returned if the 11th term was used).
- fac.autoCompleteSearchAsync(
- "field_token_cap",
- "a b c d e f g h i j .",
- null,
- null,
- false,
- {
- onSearchCompletion(aResults) {
- Assert.equal(
- aResults.matchCount,
- 1,
- "Only the first MAX_SEARCH_TOKENS tokens " +
- "should be used for DB queries"
- );
- test_token_limit_previousResult(aResults);
- },
- }
- );
- });
-});
-
-add_test(async function can_search_escape_marker() {
- await promiseUpdate({
- op: "add",
- fieldname: "field1",
- value: "/* Further reading */ test",
- timesUsed: 1,
- firstUsed: now,
- lastUsed: now,
- });
-
- fac.autoCompleteSearchAsync(
- "field1",
- "/* Further reading */ t",
- null,
- null,
- false,
- {
- onSearchCompletion(aResults) {
- Assert.equal(1, aResults.matchCount);
- run_next_test();
- },
- }
- );
-});
diff --git a/toolkit/components/satchel/test/unit/test_previous_result.js b/toolkit/components/satchel/test/unit/test_previous_result.js
deleted file mode 100644
index a782832db7..0000000000
--- a/toolkit/components/satchel/test/unit/test_previous_result.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* 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/. */
-
-var aaaListener = {
- onSearchResult(search, result) {
- Assert.equal(result.searchString, "aaa");
- do_test_finished();
- },
-};
-
-var aaListener = {
- onSearchResult(search, result) {
- Assert.equal(result.searchString, "aa");
- search.startSearch("aaa", "", result, aaaListener);
- },
-};
-
-function run_test() {
- do_test_pending();
- let search = Cc[
- "@mozilla.org/autocomplete/search;1?name=form-history"
- ].getService(Ci.nsIAutoCompleteSearch);
- search.startSearch("aa", "", null, aaListener);
-}
diff --git a/toolkit/components/satchel/test/unit/xpcshell.toml b/toolkit/components/satchel/test/unit/xpcshell.toml
index 68a379e74d..0151f9252c 100644
--- a/toolkit/components/satchel/test/unit/xpcshell.toml
+++ b/toolkit/components/satchel/test/unit/xpcshell.toml
@@ -15,29 +15,36 @@ support-files = [
]
["test_async_expire.js"]
-
-["test_autocomplete.js"]
+lineno = "17"
["test_db_access_denied.js"]
skip-if = ["os != 'linux'"] # simulates insufficiant file permissions
+lineno = "20"
["test_db_corrupt.js"]
+lineno = "24"
["test_db_update_v4.js"]
+lineno = "27"
["test_db_update_v4b.js"]
+lineno = "30"
["test_db_update_v5.js"]
skip-if = ["condprof"] # Bug 1769154 - not supported
+lineno = "33"
["test_db_update_v999a.js"]
+lineno = "37"
["test_db_update_v999b.js"]
+lineno = "40"
["test_history_api.js"]
+lineno = "43"
["test_history_sources.js"]
+lineno = "46"
["test_notify.js"]
-
-["test_previous_result.js"]
+lineno = "49"