From def92d1b8e9d373e2f6f27c366d578d97d8960c6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 15 May 2024 05:34:50 +0200 Subject: Merging upstream version 126.0. Signed-off-by: Daniel Baumann --- toolkit/components/satchel/FillHelpers.sys.mjs | 48 ++ .../components/satchel/FormAutoComplete.sys.mjs | 693 --------------------- .../components/satchel/FormHandlerChild.sys.mjs | 6 +- .../satchel/FormHistoryAutoComplete.sys.mjs | 660 ++++++++++++++++++++ toolkit/components/satchel/components.conf | 8 +- toolkit/components/satchel/jar.mn | 10 + .../satchel/megalist/MegalistChild.sys.mjs | 17 + .../satchel/megalist/MegalistParent.sys.mjs | 27 + .../satchel/megalist/MegalistViewModel.sys.mjs | 291 +++++++++ .../satchel/megalist/aggregator/Aggregator.sys.mjs | 78 +++ .../megalist/aggregator/DefaultAggregator.sys.mjs | 17 + .../datasources/AddressesDataSource.sys.mjs | 258 ++++++++ .../datasources/BankCardDataSource.sys.mjs | 339 ++++++++++ .../aggregator/datasources/DataSourceBase.sys.mjs | 291 +++++++++ .../aggregator/datasources/LoginDataSource.sys.mjs | 472 ++++++++++++++ .../satchel/megalist/aggregator/moz.build | 17 + .../satchel/megalist/content/MegalistView.mjs | 477 ++++++++++++++ .../satchel/megalist/content/VirtualizedList.mjs | 136 ++++ .../satchel/megalist/content/megalist.css | 208 +++++++ .../satchel/megalist/content/megalist.ftl | 126 ++++ .../satchel/megalist/content/megalist.html | 78 +++ .../satchel/megalist/content/search-input.mjs | 36 ++ .../megalist/content/tests/chrome/chrome.toml | 3 + .../tests/chrome/test_virtualized_list.html | 125 ++++ toolkit/components/satchel/megalist/moz.build | 20 + toolkit/components/satchel/moz.build | 10 +- .../components/satchel/nsFormFillController.cpp | 35 +- toolkit/components/satchel/nsFormFillController.h | 8 +- toolkit/components/satchel/nsIFormAutoComplete.idl | 44 -- .../components/satchel/nsIFormFillController.idl | 14 + .../satchel/nsIFormHistoryAutoComplete.idl | 31 + .../satchel/test/test_capture_limit.html | 2 +- .../satchel/test/unit/test_autocomplete.js | 4 +- 33 files changed, 3819 insertions(+), 770 deletions(-) delete mode 100644 toolkit/components/satchel/FormAutoComplete.sys.mjs create mode 100644 toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs create mode 100644 toolkit/components/satchel/jar.mn create mode 100644 toolkit/components/satchel/megalist/MegalistChild.sys.mjs create mode 100644 toolkit/components/satchel/megalist/MegalistParent.sys.mjs create mode 100644 toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs create mode 100644 toolkit/components/satchel/megalist/aggregator/moz.build create mode 100644 toolkit/components/satchel/megalist/content/MegalistView.mjs create mode 100644 toolkit/components/satchel/megalist/content/VirtualizedList.mjs create mode 100644 toolkit/components/satchel/megalist/content/megalist.css create mode 100644 toolkit/components/satchel/megalist/content/megalist.ftl create mode 100644 toolkit/components/satchel/megalist/content/megalist.html create mode 100644 toolkit/components/satchel/megalist/content/search-input.mjs create mode 100644 toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml create mode 100644 toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html create mode 100644 toolkit/components/satchel/megalist/moz.build delete mode 100644 toolkit/components/satchel/nsIFormAutoComplete.idl create mode 100644 toolkit/components/satchel/nsIFormHistoryAutoComplete.idl (limited to 'toolkit/components/satchel') diff --git a/toolkit/components/satchel/FillHelpers.sys.mjs b/toolkit/components/satchel/FillHelpers.sys.mjs index 88a248adba..fd335f271e 100644 --- a/toolkit/components/satchel/FillHelpers.sys.mjs +++ b/toolkit/components/satchel/FillHelpers.sys.mjs @@ -39,3 +39,51 @@ export function showConfirmation( const anchor = browser.ownerDocument.getElementById(anchorId); anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {}); } + +let fillRequestId = 0; + +/** + * Send a message encoded in the comment from an autocomplete item + * to the parent. + * + * @param {string} actorName name of the actor to send to + * @param {object} autocompleteInput current nsIAutoCompleteInput + * @param {string} comment serialized JSON comment containing fillMessageName and + * fillMessageData to send to the actor + */ +export async function sendFillRequestToParent( + actorName, + autocompleteInput, + comment +) { + if (!comment) { + return; + } + + const { fillMessageName, fillMessageData } = JSON.parse(comment); + if (!fillMessageName) { + return; + } + + fillRequestId++; + const currentFillRequestId = fillRequestId; + const actor = + autocompleteInput.focusedInput.ownerGlobal?.windowGlobalChild?.getActor( + actorName + ); + const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {}); + + // skip fill if another fill operation started during await + if (currentFillRequestId != fillRequestId) { + return; + } + + if (typeof value !== "string") { + return; + } + + // If the parent returned a string to fill, we must do it here because + // nsAutoCompleteController.cpp already finished it's work before we finished await. + autocompleteInput.textValue = value; + autocompleteInput.selectTextRange(value.length, value.length); +} diff --git a/toolkit/components/satchel/FormAutoComplete.sys.mjs b/toolkit/components/satchel/FormAutoComplete.sys.mjs deleted file mode 100644 index 1cae8b07c1..0000000000 --- a/toolkit/components/satchel/FormAutoComplete.sys.mjs +++ /dev/null @@ -1,693 +0,0 @@ -/* vim: set ts=4 sts=4 sw=4 et tw=80: */ -/* 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 { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs"; - -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - FormScenarios: "resource://gre/modules/FormScenarios.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 nsFormAutoComplete 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, nsFormAutoComplete 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. - * Entries are Form History entries, they can be removed. - * Fixed entries are "appended" to entries, they are used for datalist items, - * search suggestions and extra items from integrations. - * External entries are meant for integrations, like Firefox Relay. - * Internally entries and fixed entries are kept separated so we can - * reuse and filter them. - * - * @implements {nsIAutoCompleteResult} - */ -export class FormAutoCompleteResult { - constructor(client, entries, fieldName, searchString) { - this.client = client; - this.entries = entries; - this.fieldName = fieldName; - this.searchString = searchString; - } - - QueryInterface = ChromeUtils.generateQI([ - "nsIAutoCompleteResult", - "nsISupportsWeakReference", - ]); - - // private - client = null; - entries = null; - fieldName = null; - #fixedEntries = []; - externalEntries = []; - - set fixedEntries(value) { - this.#fixedEntries = value; - 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) - * to reflect the order that is specified in the fixed list. - */ - removeDuplicateHistoryEntries() { - this.entries = this.entries.filter(entry => - this.#fixedEntries.every( - fixed => entry.text != (fixed.label || fixed.value) - ) - ); - } - - getAt(index) { - for (const group of [ - this.entries, - this.#fixedEntries, - this.externalEntries, - ]) { - if (index < group.length) { - return group[index]; - } - index -= group.length; - } - - throw Components.Exception( - "Index out of range.", - Cr.NS_ERROR_ILLEGAL_VALUE - ); - } - - // Allow autoCompleteSearch to get at the JS object so it can - // modify some readonly properties for internal use. - get wrappedJSObject() { - return this; - } - - // Interfaces from idl... - searchString = ""; - errorDescription = ""; - - get defaultIndex() { - return this.matchCount ? 0 : -1; - } - - get searchResult() { - return this.matchCount - ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS - : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; - } - - get matchCount() { - return ( - this.entries.length + - this.#fixedEntries.length + - this.externalEntries.length - ); - } - - getValueAt(index) { - const item = this.getAt(index); - return item.text || item.value; - } - - getLabelAt(index) { - const item = this.getAt(index); - return item.text || item.label || item.value; - } - - getCommentAt(index) { - return this.getAt(index).comment ?? ""; - } - - getStyleAt(index) { - const itemStyle = this.getAt(index).style; - if (itemStyle) { - return itemStyle; - } - - if (index >= 0) { - if (index < this.entries.length) { - return "fromhistory"; - } - - if (index > 0 && index == this.entries.length) { - return "datalist-first"; - } - } - return ""; - } - - getImageAt(_index) { - return ""; - } - - getFinalCompleteValueAt(index) { - return this.getValueAt(index); - } - - isRemovableAt(index) { - return this.#isFormHistoryEntry(index) || this.getAt(index).removable; - } - - removeValueAt(index) { - if (this.#isFormHistoryEntry(index)) { - const [removedEntry] = this.entries.splice(index, 1); - this.client.remove(removedEntry.text, removedEntry.guid); - } - } - - #isFormHistoryEntry(index) { - return index >= 0 && index < this.entries.length; - } -} - -export class FormAutoComplete { - 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"); - } - - classID = Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"); - QueryInterface = ChromeUtils.generateQI([ - "nsIFormAutoComplete", - "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; - - fillRequestId = 0; - - 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; - } - dump("FormAutoComplete: " + message + "\n"); - Services.console.logStringMessage("FormAutoComplete: " + 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 -- nsIFormAutoCompleteObserver 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 FormAutoCompleteResult( - 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": { - await this.sendFillRequestToFormHistoryParent(subject, data); - break; - } - } - } - - async sendFillRequestToFormHistoryParent(input, comment) { - if (!comment) { - return; - } - - if (!input || input != formFillController.controller?.input) { - return; - } - - const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}"); - if (!fillMessageName) { - return; - } - - this.fillRequestId++; - const fillRequestId = this.fillRequestId; - const actor = - input.focusedInput.ownerGlobal.windowGlobalChild.getActor("FormHistory"); - const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {}); - - // skip fill if another fill operation started during await - if (fillRequestId != this.fillRequestId) { - return; - } - - if (typeof value !== "string") { - return; - } - - // If FormHistoryParent returned a string to fill, we must do it here because - // nsAutoCompleteController.cpp already finished it's work before we finished await. - input.textValue = value; - input.selectTextRange(value.length, value.length); - } -} diff --git a/toolkit/components/satchel/FormHandlerChild.sys.mjs b/toolkit/components/satchel/FormHandlerChild.sys.mjs index 6b1af3dbc3..526066e46e 100644 --- a/toolkit/components/satchel/FormHandlerChild.sys.mjs +++ b/toolkit/components/satchel/FormHandlerChild.sys.mjs @@ -44,13 +44,13 @@ export class FormHandlerChild extends JSWindowActorChild { } // handle form-removal-after-fetch - processFormRemovalAfterFetch(params) {} + processFormRemovalAfterFetch(_params) {} // handle iframe-pagehide - processIframePagehide(params) {} + processIframePagehide(_params) {} // handle page-navigation - processPageNavigation(params) {} + processPageNavigation(_params) {} /** * Dispatch the CustomEvent form-submission-detected also transfer diff --git a/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs new file mode 100644 index 0000000000..2799d2e955 --- /dev/null +++ b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs @@ -0,0 +1,660 @@ +/* vim: set ts=4 sts=4 sw=4 et tw=80: */ +/* 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 { + GenericAutocompleteItem, + sendFillRequestToParent, +} from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormScenarios: "resource://gre/modules/FormScenarios.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. + * Entries are Form History entries, they can be removed. + * Fixed entries are "appended" to entries, they are used for datalist items, + * search suggestions and extra items from integrations. + * External entries are meant for integrations, like Firefox Relay. + * Internally entries and fixed entries are kept separated so we can + * reuse and filter them. + * + * @implements {nsIAutoCompleteResult} + */ +export class FormHistoryAutoCompleteResult { + constructor(client, entries, fieldName, searchString) { + this.client = client; + this.entries = entries; + this.fieldName = fieldName; + this.searchString = searchString; + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompleteResult", + "nsISupportsWeakReference", + ]); + + // private + client = null; + entries = null; + fieldName = null; + #fixedEntries = []; + externalEntries = []; + + set fixedEntries(value) { + this.#fixedEntries = value; + 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) + * to reflect the order that is specified in the fixed list. + */ + removeDuplicateHistoryEntries() { + this.entries = this.entries.filter(entry => + this.#fixedEntries.every( + fixed => entry.text != (fixed.label || fixed.value) + ) + ); + } + + getAt(index) { + for (const group of [ + this.entries, + this.#fixedEntries, + this.externalEntries, + ]) { + if (index < group.length) { + return group[index]; + } + index -= group.length; + } + + throw Components.Exception( + "Index out of range.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Allow autoCompleteSearch to get at the JS object so it can + // modify some readonly properties for internal use. + get wrappedJSObject() { + return this; + } + + // Interfaces from idl... + searchString = ""; + errorDescription = ""; + + get defaultIndex() { + return this.matchCount ? 0 : -1; + } + + get searchResult() { + return this.matchCount + ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS + : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + + get matchCount() { + return ( + this.entries.length + + this.#fixedEntries.length + + this.externalEntries.length + ); + } + + getValueAt(index) { + const item = this.getAt(index); + return item.text || item.value; + } + + getLabelAt(index) { + const item = this.getAt(index); + return item.text || item.label || item.value; + } + + getCommentAt(index) { + return this.getAt(index).comment ?? ""; + } + + getStyleAt(index) { + const itemStyle = this.getAt(index).style; + if (itemStyle) { + return itemStyle; + } + + if (index >= 0) { + if (index < this.entries.length) { + return "fromhistory"; + } + + if (index > 0 && index == this.entries.length) { + return "datalist-first"; + } + } + return ""; + } + + getImageAt(_index) { + return ""; + } + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + isRemovableAt(index) { + return this.#isFormHistoryEntry(index) || this.getAt(index).removable; + } + + removeValueAt(index) { + if (this.#isFormHistoryEntry(index)) { + const [removedEntry] = this.entries.splice(index, 1); + this.client.remove(removedEntry.text, removedEntry.guid); + } + } + + #isFormHistoryEntry(index) { + return index >= 0 && index < this.entries.length; + } +} + +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"); + } + + classID = Components.ID("{23530265-31d1-4ee9-864c-c081975fb7bc}"); + QueryInterface = ChromeUtils.generateQI([ + "nsIFormHistoryAutoComplete", + "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": { + if (subject && subject == formFillController.controller?.input) { + await sendFillRequestToParent("FormHistory", subject, data); + } + break; + } + } + } +} diff --git a/toolkit/components/satchel/components.conf b/toolkit/components/satchel/components.conf index d843b869d6..d5a670efd9 100644 --- a/toolkit/components/satchel/components.conf +++ b/toolkit/components/satchel/components.conf @@ -18,10 +18,10 @@ Classes = [ }, { - 'cid': '{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}', - 'contract_ids': ['@mozilla.org/satchel/form-autocomplete;1'], - 'esModule': 'resource://gre/modules/FormAutoComplete.sys.mjs', - 'constructor': 'FormAutoComplete', + 'cid': '{23530265-31d1-4ee9-864c-c081975fb7bc}', + 'contract_ids': ['@mozilla.org/satchel/form-history-autocomplete;1'], + 'esModule': 'resource://gre/modules/FormHistoryAutoComplete.sys.mjs', + 'constructor': 'FormHistoryAutoComplete', }, { 'cid': '{3a0012eb-007f-4bb8-aa81-a07385f77a25}', diff --git a/toolkit/components/satchel/jar.mn b/toolkit/components/satchel/jar.mn new file mode 100644 index 0000000000..a3f250f2e8 --- /dev/null +++ b/toolkit/components/satchel/jar.mn @@ -0,0 +1,10 @@ +# 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/. + +toolkit.jar: + 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/MegalistChild.sys.mjs b/toolkit/components/satchel/megalist/MegalistChild.sys.mjs new file mode 100644 index 0000000000..cd17798c95 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistChild.sys.mjs @@ -0,0 +1,17 @@ +/* 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/. */ + +export class MegalistChild extends JSWindowActorChild { + receiveMessage(message) { + // Forward message to the View + const win = this.document.defaultView; + const ev = new win.CustomEvent("MessageFromViewModel", { + detail: message, + }); + win.dispatchEvent(ev); + } + + // Prevent TypeError: Property 'handleEvent' is not callable. + handleEvent() {} +} diff --git a/toolkit/components/satchel/megalist/MegalistParent.sys.mjs b/toolkit/components/satchel/megalist/MegalistParent.sys.mjs new file mode 100644 index 0000000000..de04af7ea6 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistParent.sys.mjs @@ -0,0 +1,27 @@ +/* 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 { MegalistViewModel } from "resource://gre/modules/megalist/MegalistViewModel.sys.mjs"; + +/** + * MegalistParent integrates MegalistViewModel into Parent/Child model. + */ +export class MegalistParent extends JSWindowActorParent { + #viewModel; + + actorCreated() { + this.#viewModel = new MegalistViewModel((...args) => + this.sendAsyncMessage(...args) + ); + } + + didDestroy() { + this.#viewModel.willDestroy(); + this.#viewModel = null; + } + + receiveMessage(message) { + return this.#viewModel?.handleViewMessage(message); + } +} diff --git a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs new file mode 100644 index 0000000000..f11a8a3198 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs @@ -0,0 +1,291 @@ +/* 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 { DefaultAggregator } from "resource://gre/modules/megalist/aggregator/DefaultAggregator.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +/** + * View Model for Megalist. + * + * Responsible for filtering, grouping, moving selection, editing. + * Refers to the same MegalistAggregator in the parent process to access data. + * Paired to exactly one MegalistView in the child process to present to the user. + * Receives user commands from MegalistView. + * + * There can be multiple snapshots of the same line displayed in different contexts. + * + * snapshotId - an id for a snapshot of a line used between View Model and View. + */ +export class MegalistViewModel { + /** + * + * View Model prepares snapshots in the parent process to be displayed + * by the View in the child process. View gets the firstSnapshotId + length of the + * list. Making it a very short message for each time we filter or refresh data. + * + * View requests line data by providing snapshotId = firstSnapshotId + index. + * + */ + #firstSnapshotId = 0; + #snapshots = []; + #selectedIndex = 0; + #searchText = ""; + #messageToView; + static #aggregator = new DefaultAggregator(); + + constructor(messageToView) { + this.#messageToView = messageToView; + MegalistViewModel.#aggregator.attachViewModel(this); + } + + willDestroy() { + MegalistViewModel.#aggregator.detachViewModel(this); + } + + refreshAllLinesOnScreen() { + this.#rebuildSnapshots(); + } + + refreshSingleLineOnScreen(line) { + if (this.#searchText) { + // Data is filtered, which may require rebuilding the whole list + //@sg check if current filter would affected by this line + //@sg throttle refresh operation + this.#rebuildSnapshots(); + } else { + const snapshotIndex = this.#snapshots.indexOf(line); + if (snapshotIndex >= 0) { + const snapshotId = snapshotIndex + this.#firstSnapshotId; + this.#sendSnapshotToView(snapshotId, line); + } + } + } + + /** + * + * Send snapshot of necessary line data across parent-child boundary. + * + * @param {number} snapshotId + * @param {object} snapshotData + */ + async #sendSnapshotToView(snapshotId, snapshotData) { + if (!snapshotData) { + return; + } + + // Only usable set of fields is sent over to the View. + // Line object may contain other data used by the Data Source. + const snapshot = { + label: snapshotData.label, + value: await snapshotData.value, + }; + if ("template" in snapshotData) { + snapshot.template = snapshotData.template; + } + if ("start" in snapshotData) { + snapshot.start = snapshotData.start; + } + if ("end" in snapshotData) { + snapshot.end = snapshotData.end; + } + if ("commands" in snapshotData) { + snapshot.commands = snapshotData.commands; + } + if ("valueIcon" in snapshotData) { + snapshot.valueIcon = snapshotData.valueIcon; + } + if ("href" in snapshotData) { + snapshot.href = snapshotData.href; + } + if (snapshotData.stickers) { + for (const sticker of snapshotData.stickers) { + snapshot.stickers ??= []; + snapshot.stickers.push(sticker); + } + } + + this.#messageToView("Snapshot", { snapshotId, snapshot }); + } + + receiveRequestSnapshot({ snapshotId }) { + const snapshotIndex = snapshotId - this.#firstSnapshotId; + const snapshot = this.#snapshots[snapshotIndex]; + if (!snapshot) { + // Ignore request for unknown line index or outdated list + return; + } + + if (snapshot.lineIsReady()) { + this.#sendSnapshotToView(snapshotId, snapshot); + } + } + + handleViewMessage({ name, data }) { + const handlerName = `receive${name}`; + if (!(handlerName in this)) { + throw new Error(`Received unknown message "${name}"`); + } + return this[handlerName](data); + } + + receiveRefresh() { + this.#rebuildSnapshots(); + } + + #rebuildSnapshots() { + // Remember current selection to attempt to restore it later + const prevSelected = this.#snapshots[this.#selectedIndex]; + + // Rebuild snapshots + this.#firstSnapshotId += this.#snapshots.length; + this.#snapshots = Array.from( + MegalistViewModel.#aggregator.enumerateLines(this.#searchText) + ); + + // Update snapshots on screen + this.#messageToView("ShowSnapshots", { + firstSnapshotId: this.#firstSnapshotId, + count: this.#snapshots.length, + }); + + // Restore selection + const usedToBeSelectedNewIndex = this.#snapshots.findIndex( + snapshot => snapshot == prevSelected + ); + if (usedToBeSelectedNewIndex >= 0) { + this.#selectSnapshotByIndex(usedToBeSelectedNewIndex); + } else { + // Make sure selection is within visible lines + this.#selectSnapshotByIndex( + Math.min(this.#selectedIndex, this.#snapshots.length - 1) + ); + } + } + + receiveUpdateFilter({ searchText } = { searchText: "" }) { + if (this.#searchText != searchText) { + this.#searchText = searchText; + this.#messageToView("MegalistUpdateFilter", { searchText }); + this.#rebuildSnapshots(); + } + } + + async receiveCommand({ commandId, snapshotId, value } = {}) { + 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; + if (!mustVerify || (await this.#verifyUser())) { + // TODO:Enter the prompt message and pref for #verifyUser() + await snapshot[`execute${commandId}`]?.(value); + } + } + } + + receiveSelectSnapshot({ snapshotId }) { + const index = snapshotId - this.#firstSnapshotId; + if (index >= 0) { + this.#selectSnapshotByIndex(index); + } + } + + receiveSelectNextSnapshot() { + this.#selectSnapshotByIndex(this.#selectedIndex + 1); + } + + receiveSelectPreviousSnapshot() { + this.#selectSnapshotByIndex(this.#selectedIndex - 1); + } + + receiveSelectNextGroup() { + let i = this.#selectedIndex + 1; + while (i < this.#snapshots.length - 1 && !this.#snapshots[i].start) { + i += 1; + } + this.#selectSnapshotByIndex(i); + } + + receiveSelectPreviousGroup() { + let i = this.#selectedIndex - 1; + while (i >= 0 && !this.#snapshots[i].start) { + i -= 1; + } + this.#selectSnapshotByIndex(i); + } + + #selectSnapshotByIndex(index) { + if (index >= 0 && index < this.#snapshots.length) { + this.#selectedIndex = index; + const selectedIndex = this.#selectedIndex; + this.#messageToView("UpdateSelection", { selectedIndex }); + } + } + + async #verifyUser(promptMessage, prefName) { + if (!this.getOSAuthEnabled(prefName)) { + promptMessage = false; + } + let result = await lazy.OSKeyStore.ensureLoggedIn(promptMessage); + return result.authenticated; + } + + /** + * Get the decrypted value for a string pref. + * + * @param {string} prefName -> The pref whose value is needed. + * @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set. + * @returns {string} + */ + #getSecurePref(prefName, safeDefaultValue) { + try { + let encryptedValue = Services.prefs.getStringPref(prefName, ""); + return this._crypto.decrypt(encryptedValue); + } catch { + return safeDefaultValue; + } + } + + /** + * Set the pref to the encrypted form of the value. + * + * @param {string} prefName -> The pref whose value is to be set. + * @param {string} value -> The value to be set in its encryoted form. + */ + #setSecurePref(prefName, value) { + let encryptedValue = this._crypto.encrypt(value); + Services.prefs.setStringPref(prefName, encryptedValue); + } + + /** + * Get whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The name of the pref (creditcards or addresses) + * @returns {boolean} + */ + getOSAuthEnabled(prefName) { + return this.#getSecurePref(prefName, "") !== "opt out"; + } + + /** + * Set whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The pref to encrypt. + * @param {boolean} enable -> Whether the pref is to be enabled. + */ + setOSAuthEnabled(prefName, enable) { + if (enable) { + Services.prefs.clearUserPref(prefName); + } else { + this.#setSecurePref(prefName, "opt out"); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs new file mode 100644 index 0000000000..e101fadd16 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +/** + * Connects multiple Data Sources with multiple View Models. + * Aggregator owns Data Sources. + * Aggregator weakly refers to View Models. + */ +export class Aggregator { + #sources = []; + #attachedViewModels = []; + + attachViewModel(viewModel) { + // Weak reference the View Model so we do not keep it in memory forever + this.#attachedViewModels.push(new WeakRef(viewModel)); + } + + detachViewModel(viewModel) { + for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) { + const knownViewModel = this.#attachedViewModels[i].deref(); + if (viewModel == knownViewModel || !knownViewModel) { + this.#attachedViewModels.splice(i, 1); + } + } + } + + /** + * Run action on each of the alive attached view models. + * Remove dead consumers. + * + * @param {Function} action to perform on each alive consumer + */ + forEachViewModel(action) { + for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) { + const viewModel = this.#attachedViewModels[i].deref(); + if (viewModel) { + action(viewModel); + } else { + this.#attachedViewModels.splice(i, 1); + } + } + } + + *enumerateLines(searchText) { + for (let source of this.#sources) { + yield* source.enumerateLines(searchText); + } + } + + /** + * + * @param {Function} createSourceFn (aggregatorApi) used to create Data Source. + * aggregatorApi is the way for Data Source to push data + * to the Aggregator. + */ + addSource(createSourceFn) { + const api = this.#apiForDataSource(); + const source = createSourceFn(api); + this.#sources.push(source); + } + + /** + * Exposes interface for a datasource to communicate with Aggregator. + */ + #apiForDataSource() { + const aggregator = this; + return { + refreshSingleLineOnScreen(line) { + aggregator.forEachViewModel(vm => vm.refreshSingleLineOnScreen(line)); + }, + + refreshAllLinesOnScreen() { + aggregator.forEachViewModel(vm => vm.refreshAllLinesOnScreen()); + }, + }; + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs new file mode 100644 index 0000000000..cf3a78a6a4 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs @@ -0,0 +1,17 @@ +/* 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 { Aggregator } from "resource://gre/modules/megalist/aggregator/Aggregator.sys.mjs"; +import { AddressesDataSource } from "resource://gre/modules/megalist/aggregator/datasources/AddressesDataSource.sys.mjs"; +import { BankCardDataSource } from "resource://gre/modules/megalist/aggregator/datasources/BankCardDataSource.sys.mjs"; +import { LoginDataSource } from "resource://gre/modules/megalist/aggregator/datasources/LoginDataSource.sys.mjs"; + +export class DefaultAggregator extends Aggregator { + constructor() { + super(); + this.addSource(aggregatorApi => new AddressesDataSource(aggregatorApi)); + this.addSource(aggregatorApi => new BankCardDataSource(aggregatorApi)); + this.addSource(aggregatorApi => new LoginDataSource(aggregatorApi)); + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs new file mode 100644 index 0000000000..f00df0b40b --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs @@ -0,0 +1,258 @@ +/* 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 { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs"; + +async function updateAddress(address, field, value) { + try { + const newAddress = { + ...address, + [field]: value ?? "", + }; + + formAutofillStorage.INTERNAL_FIELDS.forEach( + name => delete newAddress[name] + ); + formAutofillStorage.addresses.VALID_COMPUTED_FIELDS.forEach( + name => delete newAddress[name] + ); + + if (address.guid) { + await formAutofillStorage.addresses.update(address.guid, newAddress); + } else { + await formAutofillStorage.addresses.add(newAddress); + } + } catch (error) { + //todo + console.error("failed to modify address", error); + return false; + } + + return true; +} + +/** + * Data source for Addresses. + * + */ + +export class AddressesDataSource extends DataSourceBase { + #namePrototype; + #organizationPrototype; + #streetAddressPrototype; + #addressLevelOnePrototype; + #addressLevelTwoPrototype; + #addressLevelThreePrototype; + #postalCodePrototype; + #countryPrototype; + #phonePrototype; + #emailPrototype; + + #addressesDisabledMessage; + #enabled; + #header; + + 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]; + }, + }, + commands: { + value: [copyCommand, editCommand, "-", deleteCommand], + }, + executeEdit: { + value() { + this.editingValue = this.record[key] ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + async value(value) { + if (await updateAddress(this.record, key, value)) { + this.executeCancel(); + } + }, + }, + ...options, + }); + } + + this.#namePrototype = prototypeLine(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(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(); + } + ); + } + + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref( + "extensions.formautofill.addresses.enabled" + ); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + await formAutofillStorage.initialize(); + const addresses = await formAutofillStorage.addresses.getAll(); + this.beforeReloadingDataSource(); + addresses.forEach(address => { + const lineId = `${address.name}:${address.tel}`; + + this.addOrUpdateLine(address, lineId + "0", this.#namePrototype); + this.addOrUpdateLine(address, lineId + "1", this.#organizationPrototype); + this.addOrUpdateLine(address, lineId + "2", this.#streetAddressPrototype); + this.addOrUpdateLine( + address, + lineId + "3", + this.#addressLevelThreePrototype + ); + this.addOrUpdateLine( + address, + lineId + "4", + this.#addressLevelTwoPrototype + ); + this.addOrUpdateLine( + address, + lineId + "5", + this.#addressLevelOnePrototype + ); + this.addOrUpdateLine(address, lineId + "6", this.#postalCodePrototype); + this.addOrUpdateLine(address, lineId + "7", this.#countryPrototype); + this.addOrUpdateLine(address, lineId + "8", this.#phonePrototype); + this.addOrUpdateLine(address, lineId + "9", this.#emailPrototype); + }); + this.afterReloadingDataSource(); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { total: 0, count: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords(searchText, stats, address => + [ + "name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", + ].some(key => address[key]?.toUpperCase().includes(searchText)) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "addresses-count" + : "addresses-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + this.#header.value = this.#addressesDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "formautofill-storage-changed" || + message == "extensions.formautofill.addresses.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs new file mode 100644 index 0000000000..06266a7979 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs @@ -0,0 +1,339 @@ +/* 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 { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs"; +import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs"; +import { OSKeyStore } from "resource://gre/modules/OSKeyStore.sys.mjs"; + +async function decryptCard(card) { + if (card["cc-number-encrypted"] && !card["cc-number-decrypted"]) { + try { + card["cc-number-decrypted"] = await OSKeyStore.decrypt( + card["cc-number-encrypted"], + false + ); + card["cc-number"] = card["cc-number-decrypted"]; + } catch (e) { + console.error(e); + } + } +} + +async function updateCard(card, field, value) { + try { + await decryptCard(card); + const newCard = { + ...card, + [field]: value ?? "", + }; + formAutofillStorage.INTERNAL_FIELDS.forEach(name => delete newCard[name]); + formAutofillStorage.creditCards.VALID_COMPUTED_FIELDS.forEach( + name => delete newCard[name] + ); + delete newCard["cc-number-decrypted"]; + CreditCardRecord.normalizeFields(newCard); + + if (card.guid) { + await formAutofillStorage.creditCards.update(card.guid, newCard); + } else { + await formAutofillStorage.creditCards.add(newCard); + } + } catch (error) { + //todo + console.error("failed to modify credit card", error); + return false; + } + + return true; +} + +/** + * Data source for Bank Cards. + * + * Each card is represented by 3 lines: card number, expiration date and holder name. + * + * Protypes are used to reduce memory need because for different records + * similar lines will differ in values only. + */ +export class BankCardDataSource extends DataSourceBase { + #cardNumberPrototype; + #expirationPrototype; + #holderNamePrototype; + #cardsDisabledMessage; + #enabled; + #header; + + 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; + } + + 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", + }; + return ( + "chrome://formautofill/content/" + + (typeToImage[this.record["cc-type"]] ?? + "icon-credit-card-generic.svg") + ); + }, + }, + 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; + }, + }, + executeReveal: { + value() { + this.concealed = false; + this.refreshOnScreen(); + }, + }, + executeConceal: { + value() { + this.concealed = true; + this.refreshOnScreen(); + }, + }, + executeCopy: { + async value() { + await decryptCard(this.record); + this.copyToClipboard(this.record["cc-number-decrypted"]); + }, + }, + executeEdit: { + async value() { + await decryptCard(this.record); + this.editingValue = this.record["cc-number-decrypted"] ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + async value(value) { + if (updateCard(this.record, "cc-number", value)) { + this.executeCancel(); + } + }, + }, + }); + this.#expirationPrototype = this.prototypeDataLine({ + label: { value: expirationLabel }, + value: { + get() { + return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`; + }, + }, + commands: { + value: [copyCommand, editCommand, "-", deleteCommand], + }, + }); + this.#holderNamePrototype = this.prototypeDataLine({ + label: { value: holderLabel }, + end: { value: true }, + value: { + get() { + return this.editingValue ?? this.record["cc-name"]; + }, + }, + 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(); + } + }, + }, + }); + + Services.obs.addObserver(this, "formautofill-storage-changed"); + Services.prefs.addObserver( + "extensions.formautofill.creditCards.enabled", + this + ); + this.#reloadDataSource(); + } + ); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { count: 0, total: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords( + searchText, + stats, + card => + (card["cc-number-decrypted"] || card["cc-number"]) + .toUpperCase() + .includes(searchText) || + `${card["cc-exp-month"]}/${card["cc-exp-year"]}` + .toUpperCase() + .includes(searchText) || + card["cc-name"].toUpperCase().includes(searchText) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "payments-count" + : "payments-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + /** + * Sync lines array with the actual data source. + * This function reads all cards from the storage, adds or updates lines and + * removes lines for the removed cards. + */ + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref( + "extensions.formautofill.creditCards.enabled" + ); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + await formAutofillStorage.initialize(); + const cards = await formAutofillStorage.creditCards.getAll(); + this.beforeReloadingDataSource(); + cards.forEach(card => { + const lineId = `${card["cc-name"]}:${card.guid}`; + + this.addOrUpdateLine(card, lineId + "0", this.#cardNumberPrototype); + this.addOrUpdateLine(card, lineId + "1", this.#expirationPrototype); + this.addOrUpdateLine(card, lineId + "2", this.#holderNamePrototype); + }); + this.afterReloadingDataSource(); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + //todo: user can enable credit cards by activating header line + this.#header.value = this.#cardsDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "formautofill-storage-changed" || + message == "extensions.formautofill.creditCards.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs new file mode 100644 index 0000000000..49be733aef --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs @@ -0,0 +1,291 @@ +/* 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 { BinarySearch } from "resource://gre/modules/BinarySearch.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +/** + * Create a function to format messages. + * + * @param {...any} ftlFiles to be used for formatting messages + * @returns {Function} a function that can be used to format messsages + */ +function createFormatMessages(...ftlFiles) { + const strings = new Localization(ftlFiles); + + return async (...ids) => { + for (const i in ids) { + if (typeof ids[i] == "string") { + ids[i] = { id: ids[i] }; + } + } + + const messages = await strings.formatMessages(ids); + return messages.map(message => { + if (message.attributes) { + return message.attributes.reduce( + (result, { name, value }) => ({ ...result, [name]: value }), + {} + ); + } + return message.value; + }); + }; +} + +/** + * Base datasource class + */ +export class DataSourceBase { + #aggregatorApi; + + constructor(aggregatorApi) { + this.#aggregatorApi = aggregatorApi; + } + + // proxy consumer api functions to datasource interface + + refreshSingleLineOnScreen(line) { + this.#aggregatorApi.refreshSingleLineOnScreen(line); + } + + refreshAllLinesOnScreen() { + this.#aggregatorApi.refreshAllLinesOnScreen(); + } + + formatMessages = createFormatMessages("preview/megalist.ftl"); + + /** + * Prototype for the each line. + * See this link for details: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties#props + */ + #linePrototype = { + /** + * Reference to the Data Source that owns this line. + */ + source: this, + + /** + * Each line has a reference to the actual data record. + */ + record: { writable: true }, + + /** + * Is line ready to be displayed? + * Used by the View Model. + * + * @returns {boolean} true if line can be sent to the view. + * false if line is not ready to be displayed. In this case + * data source will start pulling value from the underlying + * storage and will push data to screen when it's ready. + */ + lineIsReady() { + return true; + }, + + copyToClipboard(text) { + lazy.ClipboardHelper.copyString(text, lazy.ClipboardHelper.Sensitive); + }, + + openLinkInTab(url) { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + browser.addWebTab(url, { inBackground: false }); + }, + + /** + * Simple version of Copy command. Line still needs to add "Copy" command. + * Override if copied value != displayed value. + */ + executeCopy() { + this.copyToClipboard(this.value); + }, + + executeOpen() { + this.openLinkInTab(this.href); + }, + + executeEditInProgress(value) { + this.editingValue = value; + this.refreshOnScreen(); + }, + + executeCancel() { + delete this.editingValue; + this.refreshOnScreen(); + }, + + get template() { + return "editingValue" in this ? "editingLineTemplate" : undefined; + }, + + refreshOnScreen() { + this.source.refreshSingleLineOnScreen(this); + }, + }; + + /** + * Creates collapsible section header line. + * + * @param {string} label for the section + * @returns {object} section header line + */ + createHeaderLine(label) { + const toggleCommand = { id: "Toggle", label: "" }; + const result = { + label, + value: "", + collapsed: false, + start: true, + end: true, + source: this, + + /** + * Use different templates depending on the collapsed state. + */ + get template() { + return this.collapsed + ? "collapsedSectionTemplate" + : "expandedSectionTemplate"; + }, + + lineIsReady: () => true, + + commands: [toggleCommand], + + executeToggle() { + this.collapsed = !this.collapsed; + this.source.refreshAllLinesOnScreen(); + }, + }; + + this.formatMessages("command-toggle").then(([toggleLabel]) => { + toggleCommand.label = toggleLabel; + }); + + return result; + } + + /** + * Create a prototype to be used for data lines, + * provides common set of features like Copy command. + * + * @param {object} properties to customize data line + * @returns {object} data line prototype + */ + prototypeDataLine(properties) { + return Object.create(this.#linePrototype, properties); + } + + lines = []; + #collator = new Intl.Collator(); + #linesToForget; + + /** + * Code to run before reloading data source. + * It will start tracking which lines are no longer at the source so + * afterReloadingDataSource() can remove them. + */ + beforeReloadingDataSource() { + this.#linesToForget = new Set(this.lines); + } + + /** + * Code to run after reloading data source. + * It will forget lines that are no longer at the source and refresh screen. + */ + afterReloadingDataSource() { + if (this.#linesToForget.size) { + for (let i = this.lines.length; i >= 0; i--) { + if (this.#linesToForget.has(this.lines[i])) { + this.lines.splice(i, 1); + } + } + } + + this.#linesToForget = null; + this.refreshAllLinesOnScreen(); + } + + /** + * Add or update line associated with the record. + * + * @param {object} record with which line is associated + * @param {*} id sortable line id + * @param {*} fieldPrototype to be used when creating a line. + */ + addOrUpdateLine(record, id, fieldPrototype) { + let [found, index] = BinarySearch.search( + (target, value) => this.#collator.compare(target, value.id), + this.lines, + id + ); + + if (found) { + this.#linesToForget.delete(this.lines[index]); + } else { + const line = Object.create(fieldPrototype, { id: { value: id } }); + this.lines.splice(index, 0, line); + } + this.lines[index].record = record; + return this.lines[index]; + } + + *enumerateLinesForMatchingRecords(searchText, stats, match) { + stats.total = 0; + stats.count = 0; + + if (searchText) { + let i = 0; + while (i < this.lines.length) { + const currentRecord = this.lines[i].record; + stats.total += 1; + + if (match(currentRecord)) { + // Record matches, yield all it's lines + while ( + i < this.lines.length && + currentRecord == this.lines[i].record + ) { + yield this.lines[i]; + i += 1; + } + stats.count += 1; + } else { + // Record does not match, skip until the next one + while ( + i < this.lines.length && + currentRecord == this.lines[i].record + ) { + i += 1; + } + } + } + } else { + // No search text is provided - send all lines out, count records + let currentRecord; + for (const line of this.lines) { + yield line; + + if (line.record != currentRecord) { + stats.total += 1; + currentRecord = line.record; + } + } + stats.count = stats.total; + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs new file mode 100644 index 0000000000..324bc4d141 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs @@ -0,0 +1,472 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs"; +import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { LoginCSVImport } from "resource://gre/modules/LoginCSVImport.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BREACH_ALERTS_ENABLED", + "signon.management.page.breach-alerts.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "VULNERABLE_PASSWORDS_ENABLED", + "signon.management.page.vulnerable-passwords.enabled", + false +); + +/** + * Data source for Logins. + * + * Each login is represented by 3 lines: origin, username and password. + * + * Protypes are used to reduce memory need because for different records + * similar lines will differ in values only. + */ +export class LoginDataSource extends DataSourceBase { + #originPrototype; + #usernamePrototype; + #passwordPrototype; + #loginsDisabledMessage; + #enabled; + #header; + + 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.#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; + }, + }, + valueIcon: { + get() { + return `page-icon:${this.record.origin}`; + }, + }, + href: { + get() { + return this.record.origin; + }, + }, + commands: { + value: [ + { id: "Open", label: openCommandLabel }, + copyCommand, + "-", + deleteCommand, + ], + }, + executeCopy: { + value() { + this.copyToClipboard(this.record.origin); + }, + }, + }); + this.#usernamePrototype = this.prototypeDataLine({ + label: { value: usernameLabel }, + value: { + get() { + return this.editingValue ?? this.record.username; + }, + }, + commands: { value: [copyCommand, editCommand, "-", deleteCommand] }, + executeEdit: { + value() { + this.editingValue = this.record.username ?? ""; + this.refreshOnScreen(); + }, + }, + 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: 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; + }, + }, + executeReveal: { + value() { + this.concealed = false; + this.refreshOnScreen(); + }, + }, + executeConceal: { + value() { + this.concealed = true; + this.refreshOnScreen(); + }, + }, + executeCopy: { + value() { + this.copyToClipboard(this.record.password); + }, + }, + executeEdit: { + value() { + this.editingValue = this.record.password ?? ""; + this.refreshOnScreen(); + }, + }, + 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(); + }, + }, + }); + + 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; + let { result, path } = await this.openFilePickerDialog( + title, + buttonLabel, + [ + { + title: csvTitle, + extensionPattern: "*.csv", + }, + { + title: tsvTitle, + extensionPattern: "*.tsv", + }, + ], + browser.ownerGlobal + ); + + if (result != Ci.nsIFilePicker.returnCancel) { + let summary; + try { + summary = await LoginCSVImport.importFromCSV(path); + } catch (e) { + // TODO: Display error for import + } + if (summary) { + // TODO: Display successful import summary + } + } + } + + async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) { + return new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen); + for (const appendFilter of appendFilters) { + fp.appendFilter(appendFilter.title, appendFilter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.okButtonLabel = okButtonLabel; + fp.open(async result => { + resolve({ result, path: fp.file.path }); + }); + }); + } + + #openPreferences() { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + browser.ownerGlobal.openPreferences("privacy-logins"); + } + + #getHelp() { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + const SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-manager-remember-delete-edit-logins"; + browser.ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", { + relatedToCurrent: true, + }); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { count: 0, total: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords( + searchText, + stats, + login => + login.displayOrigin.toUpperCase().includes(searchText) || + login.username.toUpperCase().includes(searchText) || + login.password.toUpperCase().includes(searchText) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "passwords-count" + : "passwords-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + /** + * Sync lines array with the actual data source. + * This function reads all logins from the storage, adds or updates lines and + * removes lines for the removed logins. + */ + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref("signon.rememberSignons"); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + const logins = await LoginHelper.getAllUserFacingLogins(); + this.beforeReloadingDataSource(); + + const breachesMap = lazy.BREACH_ALERTS_ENABLED + ? await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins) + : new Map(); + + logins.forEach(login => { + // Similar domains will be grouped together + // www. will have least effect on the sorting + const parts = login.displayOrigin.split("."); + + // Exclude TLD domain + //todo support eTLD and use public suffix here https://publicsuffix.org + if (parts.length > 1) { + parts.length -= 1; + } + const domain = parts.reverse().join("."); + const lineId = `${domain}:${login.username}:${login.guid}`; + + let originLine = this.addOrUpdateLine( + login, + lineId + "0", + this.#originPrototype + ); + this.addOrUpdateLine(login, lineId + "1", this.#usernamePrototype); + let passwordLine = this.addOrUpdateLine( + login, + lineId + "2", + 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); + } + }); + + this.afterReloadingDataSource(); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + //todo: user can enable passwords by activating Passwords header line + this.#header.value = this.#loginsDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "passwordmgr-storage-changed" || + message == "signon.rememberSignons" || + message == "signon.management.page.breach-alerts.enabled" || + message == "signon.management.page.vulnerable-passwords.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/moz.build b/toolkit/components/satchel/megalist/aggregator/moz.build new file mode 100644 index 0000000000..f244ade794 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES["megalist/aggregator"] += [ + "Aggregator.sys.mjs", + "DefaultAggregator.sys.mjs", +] + +EXTRA_JS_MODULES["megalist/aggregator/datasources"] += [ + "datasources/AddressesDataSource.sys.mjs", + "datasources/BankCardDataSource.sys.mjs", + "datasources/DataSourceBase.sys.mjs", + "datasources/LoginDataSource.sys.mjs", +] diff --git a/toolkit/components/satchel/megalist/content/MegalistView.mjs b/toolkit/components/satchel/megalist/content/MegalistView.mjs new file mode 100644 index 0000000000..44a0198692 --- /dev/null +++ b/toolkit/components/satchel/megalist/content/MegalistView.mjs @@ -0,0 +1,477 @@ +/* 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"; + +// 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. + */ +class MostRecentMap { + constructor(maxSize) { + this.#maxSize = maxSize; + } + + get(id) { + const data = this.#map.get(id); + if (data) { + this.#keepAlive(id, data); + } + return data; + } + + has(id) { + this.#map.has(id); + } + + set(id, data) { + this.#keepAlive(id, data); + this.#enforceLimits(); + } + + clear() { + this.#map.clear(); + } + + #maxSize; + #map = new Map(); + + #keepAlive(id, data) { + // Re-insert data to the map so it will be less likely to be evicted + this.#map.delete(id); + this.#map.set(id, data); + } + + #enforceLimits() { + // Maps preserve order in which data was inserted, + // we use that fact to remove oldest data from it. + while (this.#map.size > this.#maxSize) { + this.#map.delete(this.#map.keys().next().value); + } + } +} + +/** + * MegalistView presents data pushed to it by the MegalistViewModel and + * notify MegalistViewModel of user commands. + */ +export class MegalistView extends MozLitElement { + static keyToMessage = { + ArrowUp: "SelectPreviousSnapshot", + ArrowDown: "SelectNextSnapshot", + PageUp: "SelectPreviousGroup", + PageDown: "SelectNextGroup", + Escape: "UpdateFilter", + }; + static LINE_HEIGHT = 64; + + constructor() { + super(); + this.selectedIndex = 0; + this.searchText = ""; + + window.addEventListener("MessageFromViewModel", ev => + this.#onMessageFromViewModel(ev) + ); + } + + static get properties() { + return { + listLength: { type: Number }, + selectedIndex: { type: Number }, + searchText: { type: String }, + }; + } + + /** + * View shows list of snapshots of lines stored in the View Model. + * View Model provides the first snapshot id in the list and list length. + * It's safe to combine firstSnapshotId+index to identify specific snapshot + * in the list. When the list changes, View Model will provide a new + * list with new first snapshot id (even if the content is the same). + */ + #firstSnapshotId = 0; + + /** + * Cache 120 most recently used lines. + * View lives in child and View Model in parent processes. + * By caching a few lines we reduce the need to send data between processes. + * This improves performance in nearby scrolling scenarios. + * 7680 is 8K vertical screen resolution. + * Typical line is under 1/4KB long, making around 30KB cache requirement. + */ + #snapshotById = new MostRecentMap(7680 / MegalistView.LINE_HEIGHT); + + #templates = {}; + + connectedCallback() { + super.connectedCallback(); + this.ownerDocument.addEventListener("keydown", e => this.#handleKeydown(e)); + for (const template of this.ownerDocument.getElementsByTagName( + "template" + )) { + this.#templates[template.id] = template.content.firstElementChild; + } + this.#messageToViewModel("Refresh"); + } + + createLineElement(index) { + if (index < 0 || index >= this.listLength) { + return null; + } + + const snapshotId = this.#firstSnapshotId + index; + const lineElement = this.#templates.lineElement.cloneNode(true); + lineElement.dataset.id = snapshotId; + lineElement.addEventListener("dblclick", e => { + this.#messageToViewModel("Command"); + e.preventDefault(); + }); + + const data = this.#snapshotById.get(snapshotId); + if (data !== "Loading") { + if (data) { + this.#applyData(snapshotId, data, lineElement); + } else { + // Put placeholder for this snapshot data to avoid requesting it again + this.#snapshotById.set(snapshotId, "Loading"); + + // Ask for snapshot data from the View Model. + // Note: we could have optimized it further by asking for a range of + // indices because any scroll in virtualized list can only add + // a continuous range at the top or bottom of the visible area. + // However, this optimization is not necessary at the moment as + // we typically will request under a 100 of lines at a time. + // If we feel like making this improvement, we need to enhance + // VirtualizedList to request a range of new elements instead. + this.#messageToViewModel("RequestSnapshot", { snapshotId }); + } + } + + return lineElement; + } + + /** + * Find snapshot element on screen and populate it with data + */ + receiveSnapshot({ snapshotId, snapshot }) { + this.#snapshotById.set(snapshotId, snapshot); + + const lineElement = this.shadowRoot.querySelector( + `.line[data-id="${snapshotId}"]` + ); + if (lineElement) { + this.#applyData(snapshotId, snapshot, lineElement); + } + } + + #applyData(snapshotId, snapshotData, lineElement) { + let elementToFocus; + const template = + this.#templates[snapshotData.template] ?? this.#templates.lineTemplate; + + const lineContent = template.cloneNode(true); + lineContent.querySelector(".label").textContent = snapshotData.label; + + const valueElement = lineContent.querySelector(".value"); + if (valueElement) { + const valueText = lineContent.querySelector("span"); + if (valueText) { + valueText.textContent = snapshotData.value; + } else { + const valueInput = lineContent.querySelector("input"); + if (valueInput) { + valueInput.value = snapshotData.value; + valueInput.addEventListener("keydown", e => { + switch (e.code) { + case "Enter": + this.#messageToViewModel("Command", { + snapshotId, + commandId: "Save", + value: valueInput.value, + }); + break; + case "Escape": + this.#messageToViewModel("Command", { + snapshotId, + commandId: "Cancel", + }); + break; + default: + return; + } + e.preventDefault(); + e.stopPropagation(); + }); + valueInput.addEventListener("input", () => { + // Update local cache so we don't override editing value + // while user scrolls up or down a little. + const snapshotDataInChild = this.#snapshotById.get(snapshotId); + if (snapshotDataInChild) { + snapshotDataInChild.value = valueInput.value; + } + this.#messageToViewModel("Command", { + snapshotId, + commandId: "EditInProgress", + value: valueInput.value, + }); + }); + elementToFocus = valueInput; + } else { + valueElement.textContent = snapshotData.value; + } + } + + if (snapshotData.valueIcon) { + const valueIcon = valueElement.querySelector(".icon"); + if (valueIcon) { + valueIcon.src = snapshotData.valueIcon; + } + } + + if (snapshotData.href) { + const linkElement = this.ownerDocument.createElement("a"); + linkElement.className = valueElement.className; + linkElement.href = snapshotData.href; + linkElement.replaceChildren(...valueElement.children); + valueElement.replaceWith(linkElement); + } + + if (snapshotData.stickers?.length) { + const stickersElement = lineContent.querySelector(".stickers"); + for (const sticker of snapshotData.stickers) { + const stickerElement = this.ownerDocument.createElement("span"); + stickerElement.textContent = sticker.label; + stickerElement.className = sticker.type; + stickersElement.appendChild(stickerElement); + } + } + } + + lineElement.querySelector(".content").replaceWith(lineContent); + lineElement.classList.toggle("start", !!snapshotData.start); + lineElement.classList.toggle("end", !!snapshotData.end); + elementToFocus?.focus(); + } + + #messageToViewModel(messageName, data) { + window.windowGlobalChild + .getActor("Megalist") + .sendAsyncMessage(messageName, data); + } + + #onMessageFromViewModel({ detail }) { + const functionName = `receive${detail.name}`; + if (!(functionName in this)) { + throw new Error(`Received unknown message "${detail.name}"`); + } + this[functionName](detail.data); + } + + receiveUpdateSelection({ selectedIndex }) { + this.selectedIndex = selectedIndex; + } + + receiveShowSnapshots({ firstSnapshotId, count }) { + this.#firstSnapshotId = firstSnapshotId; + this.listLength = count; + + // Each new display list starts with the new first snapshot id + // so we can forget previously known data. + this.#snapshotById.clear(); + this.shadowRoot.querySelector("virtualized-list").requestRefresh(); + this.requestUpdate(); + } + + receiveMegalistUpdateFilter({ searchText }) { + this.searchText = searchText; + this.requestUpdate(); + } + + #handleInputChange(e) { + const searchText = e.target.value; + this.#messageToViewModel("UpdateFilter", { searchText }); + } + + #handleKeydown(e) { + const message = MegalistView.keyToMessage[e.code]; + if (message) { + this.#messageToViewModel(message); + e.preventDefault(); + } else if (e.code == "Enter") { + // Do not handle Enter at the virtualized list level when line menu is open + if ( + this.shadowRoot.querySelector( + ".line.selected > .menuButton > .menuPopup" + ) + ) { + return; + } + + if (e.altKey) { + // Execute default command1 + this.#messageToViewModel("Command"); + } else { + // Show line level menu + this.shadowRoot + .querySelector(".line.selected > .menuButton > button") + ?.click(); + } + e.preventDefault(); + } else if (e.ctrlKey && e.key == "c" && !this.searchText.length) { + this.#messageToViewModel("Command", { commandId: "Copy" }); + e.preventDefault(); + } + } + + #handleClick(e) { + const lineElement = e.composedTarget.closest(".line"); + if (!lineElement) { + return; + } + + const snapshotId = Number(lineElement.dataset.id); + const snapshotData = this.#snapshotById.get(snapshotId); + if (!snapshotData) { + return; + } + + this.#messageToViewModel("SelectSnapshot", { snapshotId }); + const menuButton = e.composedTarget.closest(".menuButton"); + if (menuButton) { + this.#handleMenuButtonClick(menuButton, snapshotId, snapshotData); + } + + e.preventDefault(); + } + + #handleMenuButtonClick(menuButton, snapshotId, snapshotData) { + if (!snapshotData.commands?.length) { + return; + } + + const popup = this.ownerDocument.createElement("div"); + popup.className = "menuPopup"; + popup.addEventListener( + "keydown", + e => { + function focusInternal(next, wrapSelector) { + let element = e.composedTarget; + do { + element = element[next]; + } while (element && element.tagName != "BUTTON"); + + // If we can't find next/prev button, focus the first/last one + element ??= + e.composedTarget.parentElement.querySelector(wrapSelector); + element?.focus(); + } + + function focusNext() { + focusInternal("nextElementSibling", "button"); + } + + function focusPrev() { + focusInternal("previousElementSibling", "button:last-of-type"); + } + + switch (e.code) { + case "Escape": + popup.remove(); + break; + case "Tab": + if (e.shiftKey) { + focusPrev(); + } else { + focusNext(); + } + break; + case "ArrowUp": + focusPrev(); + break; + case "ArrowDown": + focusNext(); + break; + default: + return; + } + + e.preventDefault(); + e.stopPropagation(); + }, + { capture: true } + ); + popup.addEventListener( + "blur", + e => { + if ( + 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(); + } + }, + { capture: true } + ); + + for (const command of snapshotData.commands) { + if (command == "-") { + const separator = this.ownerDocument.createElement("div"); + separator.className = "separator"; + popup.appendChild(separator); + continue; + } + + const menuItem = this.ownerDocument.createElement("button"); + menuItem.textContent = command.label; + menuItem.addEventListener("click", e => { + this.#messageToViewModel("Command", { + snapshotId, + commandId: command.id, + }); + popup.remove(); + e.preventDefault(); + }); + popup.appendChild(menuItem); + } + + menuButton.querySelector("button").after(popup); + popup.querySelector("button")?.focus(); + } + + render() { + return html` + +
+ this.#handleInputChange(e)} + > + + this.createLineElement(index)} + @click=${e => this.#handleClick(e)} + > + +
+ `; + } +} + +customElements.define("megalist-view", MegalistView); diff --git a/toolkit/components/satchel/megalist/content/VirtualizedList.mjs b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs new file mode 100644 index 0000000000..7903a189eb --- /dev/null +++ b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs @@ -0,0 +1,136 @@ +/* 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/. */ + +/** + * Virtualized List can efficiently show billions of lines provided + * that all of them have the same height. + * + * Caller is responsible for setting createLineElement(index) function to + * create elements as they are scrolled into the view. + */ +class VirtualizedList extends HTMLElement { + lineHeight = 64; + #lineCount = 0; + + get lineCount() { + return this.#lineCount; + } + + set lineCount(value) { + this.#lineCount = value; + this.#rebuildVisibleLines(); + } + + #selectedIndex = 0; + + get selectedIndex() { + return this.#selectedIndex; + } + + set selectedIndex(value) { + this.#selectedIndex = value; + if (this.#container) { + this.updateLineSelection(true); + } + } + + #container; + + connectedCallback() { + this.#container = this.ownerDocument.createElement("ul"); + this.#container.classList.add("lines-container"); + this.appendChild(this.#container); + + this.#rebuildVisibleLines(); + this.addEventListener("scroll", () => this.#rebuildVisibleLines()); + } + + requestRefresh() { + this.#container.replaceChildren(); + this.#rebuildVisibleLines(); + } + + updateLineSelection(scrollIntoView) { + const lineElements = this.#container.querySelectorAll(".line"); + let selectedElement; + + for (let lineElement of lineElements) { + let isSelected = Number(lineElement.dataset.index) === this.selectedIndex; + if (isSelected) { + selectedElement = lineElement; + } + lineElement.classList.toggle("selected", isSelected); + } + + if (scrollIntoView) { + if (selectedElement) { + selectedElement.scrollIntoView({ block: "nearest" }); + } else { + let selectedTop = this.selectedIndex * this.lineHeight; + if (this.scrollTop > selectedTop) { + this.scrollTop = selectedTop; + } else { + this.scrollTop = selectedTop - this.clientHeight + this.lineHeight; + } + } + } + } + + #rebuildVisibleLines() { + if (!this.isConnected || !this.createLineElement) { + return; + } + + this.#container.style.height = `${this.lineHeight * this.lineCount}px`; + + let firstLineIndex = Math.floor(this.scrollTop / this.lineHeight); + let visibleLineCount = Math.ceil(this.clientHeight / this.lineHeight); + let lastLineIndex = firstLineIndex + visibleLineCount; + let extraLines = Math.ceil(visibleLineCount / 2); // They are present in DOM, but not visible + + firstLineIndex = Math.max(0, firstLineIndex - extraLines); + lastLineIndex = Math.min(this.lineCount, lastLineIndex + extraLines); + + let previousChild = null; + let visibleLines = new Map(); + + for (let child of Array.from(this.#container.children)) { + let index = Number(child.dataset.index); + if (index < firstLineIndex || index > lastLineIndex) { + child.remove(); + } else { + visibleLines.set(index, child); + } + } + + for (let index = firstLineIndex; index <= lastLineIndex; index++) { + let child = visibleLines.get(index); + if (!child) { + child = this.createLineElement(index); + + if (!child) { + // Friday fix :-) + //todo: figure out what was on that Friday and how can we fix it + continue; + } + + child.style.top = `${index * this.lineHeight}px`; + child.dataset.index = index; + + if (previousChild) { + previousChild.after(child); + } else if (this.#container.firstElementChild?.offsetTop > top) { + this.#container.firstElementChild.before(child); + } else { + this.#container.appendChild(child); + } + } + previousChild = child; + } + + this.updateLineSelection(false); + } +} + +customElements.define("virtualized-list", VirtualizedList); diff --git a/toolkit/components/satchel/megalist/content/megalist.css b/toolkit/components/satchel/megalist/content/megalist.css new file mode 100644 index 0000000000..b442a7b60d --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.css @@ -0,0 +1,208 @@ +/* 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/. */ + +/* Bug 1869845 - Styles in this file are still experimental! */ + +.container { + display: flex; + flex-direction: column; + justify-content: center; + max-height: 100vh; + + > search-input { + margin: 20px; + } +} + +virtualized-list { + position: relative; + overflow: auto; + margin: 20px; + + .lines-container { + padding-inline-start: unset; + } +} + +.line { + display: flex; + align-items: stretch; + position: absolute; + width: 100%; + user-select: none; + box-sizing: border-box; + height: 64px; + + background-color: var(--in-content-box-background-odd); + border-inline: 1px solid var(--in-content-border-color); + + color: var(--in-content-text-color); + + &.start { + border-block-start: 1px solid var(--in-content-border-color); + border-start-start-radius: 8px; + border-start-end-radius: 8px; + } + + &.end { + border-block-end: 1px solid var(--in-content-border-color); + border-end-start-radius: 8px; + border-end-end-radius: 8px; + height: 54px; + } + + > .menuButton { + position: relative; + visibility: hidden; + + > button { + border: none; + margin-inline-start: 2px; + padding: 2px; + background-color: transparent; + /* Fix: too lazy to load the svg */ + width: 32px; + color: unset; + } + + > .menuPopup { + position: absolute; + inset-inline-end: 0; + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + z-index: 1; + background-color: var(--in-content-table-background); + padding: 4px; + + > .separator { + border-block-start: 1px solid var(--in-content-border-color); + margin: 4px 0; + } + + > button { + text-align: start; + border-style: none; + padding: 12px; + margin-block-end: 2px; + width: 100%; + text-wrap: nowrap; + } + } + } + + > .content { + flex-grow: 1; + + > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-inline-start: 10px; + + &:last-child { + padding-block-end: 10px; + } + } + + > .icon { + margin-inline-start: 4px; + width: 16px; + height: 16px; + -moz-context-properties: fill; + fill: currentColor; + } + + > .label { + color: var(--text-color-deemphasized); + padding-block: 2px 4px; + } + + > .value { + user-select: text; + + > .icon { + -moz-context-properties: fill; + fill: currentColor; + width: auto; + height: 16px; + margin-inline: 4px; + vertical-align: text-bottom; + } + + > .icon:not([src]) { + display: none; + } + + &:is(a) { + color: currentColor; + } + } + + > .stickers { + text-align: end; + margin-block-start: 2px; + + > span { + padding: 2px; + margin-inline-end: 2px; + } + + /* Hard-coded colors will be addressed in FXCM-1013 */ + > span.risk { + background-color: slateblue; + border: 1px solid darkslateblue; + color: whitesmoke; + } + + > span.warning { + background-color: firebrick; + border: 1px solid maroon; + color: whitesmoke; + } + } + + &.section { + font-size: larger; + + > .label { + display: inline-block; + margin: 0; + color: unset; + } + + > .value { + margin-inline-end: 8px; + text-align: end; + font-size: smaller; + color: var(--text-color-deemphasized); + user-select: unset; + } + } + } + + &.selected { + color: var(--in-content-item-selected-text); + background-color: var(--in-content-item-selected); + + > .menuButton { + visibility: inherit; + } + } + + &:hover { + color: var(--in-content-item-hover-text); + background-color: var(--in-content-item-hover); + + > .menuButton { + visibility: visible; + } + } +} + +.search { + padding: 8px; + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + box-sizing: border-box; + width: 100%; +} diff --git a/toolkit/components/satchel/megalist/content/megalist.ftl b/toolkit/components/satchel/megalist/content/megalist.ftl new file mode 100644 index 0000000000..69d085a7c5 --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.ftl @@ -0,0 +1,126 @@ +# 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/. + +filter-placeholder = + .placeholder = Search Your Data + .key = F + +## Commands + +command-copy = Copy +command-reveal = Reveal +command-conceal = Conceal +command-toggle = Toggle +command-open = Open +command-delete = Remove record +command-edit = Edit +command-save = Save +command-cancel = Cancel + +## Passwords + +passwords-section-label = Passwords +passwords-disabled = Passwords are disabled + +passwords-command-create = Add Password +passwords-command-import = Import from a File… +passwords-command-export = Export Passwords… +passwords-command-remove-all = Remove All Passwords… +passwords-command-settings = Settings +passwords-command-help = Help + +passwords-import-file-picker-title = Import Passwords +passwords-import-file-picker-import-button = Import + +# A description for the .csv file format that may be shown as the file type +# filter by the operating system. +passwords-import-file-picker-csv-filter-title = + { PLATFORM() -> + [macos] CSV Document + *[other] CSV File + } +# A description for the .tsv file format that may be shown as the file type +# filter by the operating system. TSV is short for 'tab separated values'. +passwords-import-file-picker-tsv-filter-title = + { PLATFORM() -> + [macos] TSV Document + *[other] TSV File + } + +# Variables +# $count (number) - Number of passwords +passwords-count = + { $count -> + [one] { $count } password + *[other] { $count } passwords + } + +# Variables +# $count (number) - Number of filtered passwords +# $total (number) - Total number of passwords +passwords-filtered-count = + { $total -> + [one] { $count } of { $total } password + *[other] { $count } of { $total } passwords + } + +passwords-origin-label = Website address +passwords-username-label = Username +passwords-password-label = Password + +## Payments + +payments-command-create = Add Payment Method + +payments-section-label = Payment methods +payments-disabled = Payments methods are disabled + +# Variables +# $count (number) - Number of payment methods +payments-count = + { $count -> + [one] { $count } payment method + *[other] { $count } payment methods + } + +# Variables +# $count (number) - Number of filtered payment methods +# $total (number) - Total number of payment methods +payments-filtered-count = + { $total -> + [one] { $count } of { $total } payment method + *[other] { $count } of { $total } payment methods + } + +card-number-label = Card Number +card-expiration-label = Expires on +card-holder-label = Name on Card + +## Addresses + +addresses-command-create = Add Address + +addresses-section-label = Addresses +addresses-disabled = Addresses are disabled + +# Variables +# $count (number) - Number of addresses +addresses-count = + { $count -> + [one] { $count } address + *[other] { $count } addresses + } + +# Variables +# $count (number) - Number of filtered addresses +# $total (number) - Total number of addresses +addresses-filtered-count = + { $total -> + [one] { $count } of { $total } address + *[other] { $count } of { $total } addresses + } + +address-name-label = Name +address-phone-label = Phone +address-email-label = Email diff --git a/toolkit/components/satchel/megalist/content/megalist.html b/toolkit/components/satchel/megalist/content/megalist.html new file mode 100644 index 0000000000..6ff3f089fc --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toolkit/components/satchel/megalist/content/search-input.mjs b/toolkit/components/satchel/megalist/content/search-input.mjs new file mode 100644 index 0000000000..e30d13ef2a --- /dev/null +++ b/toolkit/components/satchel/megalist/content/search-input.mjs @@ -0,0 +1,36 @@ +/* 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` + + `; + } +} + +customElements.define("search-input", SearchInput); diff --git a/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml new file mode 100644 index 0000000000..2d7fd6bccd --- /dev/null +++ b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_virtualized_list.html"] diff --git a/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html new file mode 100644 index 0000000000..65ddbcc40b --- /dev/null +++ b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html @@ -0,0 +1,125 @@ + + + + + VirtualizedList Tests + + + + + + + + + +

+
+ +
+
+
+
+ + diff --git a/toolkit/components/satchel/megalist/moz.build b/toolkit/components/satchel/megalist/moz.build new file mode 100644 index 0000000000..266281a9a8 --- /dev/null +++ b/toolkit/components/satchel/megalist/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +MOCHITEST_CHROME_MANIFESTS += ["content/tests/chrome/chrome.toml"] + +DIRS += [ + "aggregator", +] + +EXTRA_JS_MODULES["megalist"] += [ + "MegalistViewModel.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "MegalistChild.sys.mjs", + "MegalistParent.sys.mjs", +] diff --git a/toolkit/components/satchel/moz.build b/toolkit/components/satchel/moz.build index 4b6d08cdbf..fc5cabecd7 100644 --- a/toolkit/components/satchel/moz.build +++ b/toolkit/components/satchel/moz.build @@ -12,8 +12,8 @@ XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] XPIDL_SOURCES += [ - "nsIFormAutoComplete.idl", "nsIFormFillController.idl", + "nsIFormHistoryAutoComplete.idl", ] XPIDL_MODULE = "satchel" @@ -26,10 +26,16 @@ LOCAL_INCLUDES += [ "../build", ] +JAR_MANIFESTS += ["jar.mn"] + +DIRS += [ + "megalist", +] + EXTRA_JS_MODULES += [ "FillHelpers.sys.mjs", - "FormAutoComplete.sys.mjs", "FormHistory.sys.mjs", + "FormHistoryAutoComplete.sys.mjs", "FormHistoryStartup.sys.mjs", "FormScenarios.sys.mjs", "integrations/FirefoxRelay.sys.mjs", diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp index 1bcbde08df..61d23d157c 100644 --- a/toolkit/components/satchel/nsFormFillController.cpp +++ b/toolkit/components/satchel/nsFormFillController.cpp @@ -23,7 +23,7 @@ #include "mozilla/Services.h" #include "mozilla/StaticPrefs_ui.h" #include "nsCRT.h" -#include "nsIFormAutoComplete.h" +#include "nsIFormHistoryAutoComplete.h" #include "nsString.h" #include "nsPIDOMWindow.h" #include "nsIAutoCompleteResult.h" @@ -47,12 +47,13 @@ using mozilla::LogLevel; static mozilla::LazyLogModule sLogger("satchel"); -static nsIFormAutoComplete* GetFormAutoComplete() { - static nsCOMPtr sInstance; +static nsIFormHistoryAutoComplete* GetFormHistoryAutoComplete() { + static nsCOMPtr sInstance; static bool sInitialized = false; if (!sInitialized) { nsresult rv; - sInstance = do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv); + sInstance = + do_GetService("@mozilla.org/satchel/form-history-autocomplete;1", &rv); if (NS_SUCCEEDED(rv)) { ClearOnShutdown(&sInstance); @@ -64,14 +65,14 @@ static nsIFormAutoComplete* GetFormAutoComplete() { NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mLoginManagerAC, mFocusedPopup, mPopups, mLastListener, - mLastFormAutoComplete) + mLastFormHistoryAutoComplete) NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsFormFillController) NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIFormFillController) NS_INTERFACE_MAP_ENTRY(nsIFormFillController) NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteInput) NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteSearch) - NS_INTERFACE_MAP_ENTRY(nsIFormAutoCompleteObserver) + NS_INTERFACE_MAP_ENTRY(nsIFormFillCompleteObserver) NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) NS_INTERFACE_MAP_ENTRY(nsIObserver) NS_INTERFACE_MAP_ENTRY(nsIMutationObserver) @@ -705,13 +706,13 @@ nsFormFillController::StartSearch(const nsAString& aSearchString, MaybeObserveDataListMutations(); } - auto formAutoComplete = GetFormAutoComplete(); - NS_ENSURE_TRUE(formAutoComplete, NS_ERROR_FAILURE); + auto* formHistoryAutoComplete = GetFormHistoryAutoComplete(); + NS_ENSURE_TRUE(formHistoryAutoComplete, NS_ERROR_FAILURE); - formAutoComplete->AutoCompleteSearchAsync(aSearchParam, aSearchString, - mFocusedInput, aPreviousResult, - addDataList, this); - mLastFormAutoComplete = formAutoComplete; + formHistoryAutoComplete->AutoCompleteSearchAsync( + aSearchParam, aSearchString, mFocusedInput, aPreviousResult, + addDataList, this); + mLastFormHistoryAutoComplete = formHistoryAutoComplete; } return NS_OK; @@ -760,10 +761,10 @@ void nsFormFillController::RevalidateDataList() { NS_IMETHODIMP nsFormFillController::StopSearch() { // Make sure to stop and clear this, otherwise the controller will prevent - // mLastFormAutoComplete from being deleted. - if (mLastFormAutoComplete) { - mLastFormAutoComplete->StopAutoCompleteSearch(); - mLastFormAutoComplete = nullptr; + // mLastFormHistoryAutoComplete from being deleted. + if (mLastFormHistoryAutoComplete) { + mLastFormHistoryAutoComplete->StopAutoCompleteSearch(); + mLastFormHistoryAutoComplete = nullptr; } if (mLoginManagerAC) { @@ -773,7 +774,7 @@ nsFormFillController::StopSearch() { } //////////////////////////////////////////////////////////////////////// -//// nsIFormAutoCompleteObserver +//// nsIFormFillCompleteObserver NS_IMETHODIMP nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult* aResult) { diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h index eef6addb7a..239c293352 100644 --- a/toolkit/components/satchel/nsFormFillController.h +++ b/toolkit/components/satchel/nsFormFillController.h @@ -13,7 +13,7 @@ #include "nsIAutoCompleteController.h" #include "nsIAutoCompletePopup.h" #include "nsIDOMEventListener.h" -#include "nsIFormAutoComplete.h" +#include "nsIFormHistoryAutoComplete.h" #include "nsCOMPtr.h" #include "nsStubMutationObserver.h" #include "nsTHashMap.h" @@ -37,7 +37,7 @@ class HTMLInputElement; class nsFormFillController final : public nsIFormFillController, public nsIAutoCompleteInput, public nsIAutoCompleteSearch, - public nsIFormAutoCompleteObserver, + public nsIFormFillCompleteObserver, public nsIDOMEventListener, public nsIObserver, public nsMultiMutationObserver { @@ -46,7 +46,7 @@ class nsFormFillController final : public nsIFormFillController, NS_DECL_NSIFORMFILLCONTROLLER NS_DECL_NSIAUTOCOMPLETESEARCH NS_DECL_NSIAUTOCOMPLETEINPUT - NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER + NS_DECL_NSIFORMFILLCOMPLETEOBSERVER NS_DECL_NSIDOMEVENTLISTENER NS_DECL_NSIOBSERVER NS_DECL_NSIMUTATIONOBSERVER @@ -122,7 +122,7 @@ class nsFormFillController final : public nsIFormFillController, nsCOMPtr mLastListener; // This is cleared by StopSearch(). - nsCOMPtr mLastFormAutoComplete; + nsCOMPtr mLastFormHistoryAutoComplete; nsString mLastSearchString; nsTHashMap, bool> mPwmgrInputs; diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormAutoComplete.idl deleted file mode 100644 index cc40872dd3..0000000000 --- a/toolkit/components/satchel/nsIFormAutoComplete.idl +++ /dev/null @@ -1,44 +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/. */ - - -#include "nsISupports.idl" - -interface nsIAutoCompleteResult; -interface nsIFormAutoCompleteObserver; -interface nsIPropertyBag2; - -webidl HTMLInputElement; - -[scriptable, uuid(bfd9b82b-0ab3-4b6b-9e54-aa961ff4b732)] -interface nsIFormAutoComplete: nsISupports { - /** - * Generate results for a form input autocomplete menu asynchronously. - */ - void autoCompleteSearchAsync(in AString aInputName, - in AString aSearchString, - in HTMLInputElement aField, - in nsIAutoCompleteResult aPreviousResult, - in bool aAddDatalist, - in nsIFormAutoCompleteObserver aListener); - - /** - * If a search is in progress, stop it. Otherwise, do nothing. This is used - * to cancel an existing search, for example, in preparation for a new search. - */ - void stopAutoCompleteSearch(); -}; - -[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)] -interface nsIFormAutoCompleteObserver : nsISupports -{ - /* - * Called when a search is complete and the results are ready even if the - * result set is empty. If the search is cancelled or a new search is - * started, this is not called. - * - * @param result - The search result object - */ - [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result); -}; diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl index 25bd2d6738..24d9bf8193 100644 --- a/toolkit/components/satchel/nsIFormFillController.idl +++ b/toolkit/components/satchel/nsIFormFillController.idl @@ -5,6 +5,7 @@ #include "nsISupports.idl" interface nsIAutoCompletePopup; +interface nsIAutoCompleteResult; webidl Document; webidl Element; @@ -67,3 +68,16 @@ interface nsIFormFillController : nsISupports */ [can_run_script] void showPopup(); }; + +[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)] +interface nsIFormFillCompleteObserver : nsISupports +{ + /* + * Called when a search is complete and the results are ready even if the + * result set is empty. If the search is cancelled or a new search is + * started, this is not called. + * + * @param result - The search result object + */ + [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result); +}; diff --git a/toolkit/components/satchel/nsIFormHistoryAutoComplete.idl b/toolkit/components/satchel/nsIFormHistoryAutoComplete.idl new file mode 100644 index 0000000000..279b09f51e --- /dev/null +++ b/toolkit/components/satchel/nsIFormHistoryAutoComplete.idl @@ -0,0 +1,31 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +interface nsIAutoCompleteResult; +interface nsIFormFillCompleteObserver; +interface nsIPropertyBag2; + +webidl HTMLInputElement; + +[scriptable, uuid(bfd9b82b-0ab3-4b6b-9e54-aa961ff4b732)] +interface nsIFormHistoryAutoComplete: nsISupports { + /** + * Generate results for a form input autocomplete menu asynchronously. + */ + void autoCompleteSearchAsync(in AString aInputName, + in AString aSearchString, + in HTMLInputElement aField, + in nsIAutoCompleteResult aPreviousResult, + in boolean aAddDatalist, + in nsIFormFillCompleteObserver aListener); + + /** + * If a search is in progress, stop it. Otherwise, do nothing. This is used + * to cancel an existing search, for example, in preparation for a new search. + */ + void stopAutoCompleteSearch(); +}; diff --git a/toolkit/components/satchel/test/test_capture_limit.html b/toolkit/components/satchel/test/test_capture_limit.html index 8591544016..61b0a98418 100644 --- a/toolkit/components/satchel/test/test_capture_limit.html +++ b/toolkit/components/satchel/test/test_capture_limit.html @@ -21,7 +21,7 @@ add_setup(async () => { }); add_task(async function captureLimit() { - // Capture no more than 100 fields per submit. See FormHistoryChild.jsm. + // Capture no more than 100 fields per submit. See FormHistoryChild.sys.mjs. const inputsCount = 100 + 2; const form = document.getElementById("form1"); for (let i = 1; i <= inputsCount; i++) { diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js index 13f66eb0f2..e64d34ea50 100644 --- a/toolkit/components/satchel/test/unit/test_autocomplete.js +++ b/toolkit/components/satchel/test/unit/test_autocomplete.js @@ -39,8 +39,8 @@ function run_test() { testfile.copyTo(profileDir, "formhistory.sqlite"); - fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].getService( - Ci.nsIFormAutoComplete + fac = Cc["@mozilla.org/satchel/form-history-autocomplete;1"].getService( + Ci.nsIFormHistoryAutoComplete ); timeGroupingSize = -- cgit v1.2.3