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