diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /toolkit/components/satchel | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip |
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel')
31 files changed, 3148 insertions, 99 deletions
diff --git a/toolkit/components/satchel/FillHelpers.sys.mjs b/toolkit/components/satchel/FillHelpers.sys.mjs index 88a248adba..fd335f271e 100644 --- a/toolkit/components/satchel/FillHelpers.sys.mjs +++ b/toolkit/components/satchel/FillHelpers.sys.mjs @@ -39,3 +39,51 @@ export function showConfirmation( const anchor = browser.ownerDocument.getElementById(anchorId); anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {}); } + +let fillRequestId = 0; + +/** + * Send a message encoded in the comment from an autocomplete item + * to the parent. + * + * @param {string} actorName name of the actor to send to + * @param {object} autocompleteInput current nsIAutoCompleteInput + * @param {string} comment serialized JSON comment containing fillMessageName and + * fillMessageData to send to the actor + */ +export async function sendFillRequestToParent( + actorName, + autocompleteInput, + comment +) { + if (!comment) { + return; + } + + const { fillMessageName, fillMessageData } = JSON.parse(comment); + if (!fillMessageName) { + return; + } + + fillRequestId++; + const currentFillRequestId = fillRequestId; + const actor = + autocompleteInput.focusedInput.ownerGlobal?.windowGlobalChild?.getActor( + actorName + ); + const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {}); + + // skip fill if another fill operation started during await + if (currentFillRequestId != fillRequestId) { + return; + } + + if (typeof value !== "string") { + return; + } + + // If the parent returned a string to fill, we must do it here because + // nsAutoCompleteController.cpp already finished it's work before we finished await. + autocompleteInput.textValue = value; + autocompleteInput.selectTextRange(value.length, value.length); +} diff --git a/toolkit/components/satchel/FormHandlerChild.sys.mjs b/toolkit/components/satchel/FormHandlerChild.sys.mjs index 6b1af3dbc3..526066e46e 100644 --- a/toolkit/components/satchel/FormHandlerChild.sys.mjs +++ b/toolkit/components/satchel/FormHandlerChild.sys.mjs @@ -44,13 +44,13 @@ export class FormHandlerChild extends JSWindowActorChild { } // handle form-removal-after-fetch - processFormRemovalAfterFetch(params) {} + processFormRemovalAfterFetch(_params) {} // handle iframe-pagehide - processIframePagehide(params) {} + processIframePagehide(_params) {} // handle page-navigation - processPageNavigation(params) {} + processPageNavigation(_params) {} /** * Dispatch the CustomEvent form-submission-detected also transfer diff --git a/toolkit/components/satchel/FormAutoComplete.sys.mjs b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs index 1cae8b07c1..2799d2e955 100644 --- a/toolkit/components/satchel/FormAutoComplete.sys.mjs +++ b/toolkit/components/satchel/FormHistoryAutoComplete.sys.mjs @@ -3,7 +3,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs"; +import { + GenericAutocompleteItem, + sendFillRequestToParent, +} from "resource://gre/modules/FillHelpers.sys.mjs"; const lazy = {}; @@ -33,10 +36,10 @@ function isAutocompleteDisabled(aField) { * figuring out the most appropriate message manager to use, * and what things to send. * - * It is assumed that nsFormAutoComplete will only ever use + * 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, nsFormAutoComplete might call remove() any number of + * However, FormHistoryAutoComplete might call remove() any number of * times with the same instance of the client. * * @param {object} clientInfo @@ -193,7 +196,7 @@ export class FormHistoryClient { * * @implements {nsIAutoCompleteResult} */ -export class FormAutoCompleteResult { +export class FormHistoryAutoCompleteResult { constructor(client, entries, fieldName, searchString) { this.client = client; this.entries = entries; @@ -395,7 +398,7 @@ export class FormAutoCompleteResult { } } -export class FormAutoComplete { +export class FormHistoryAutoComplete { constructor() { // Preferences. Add observer so we get notified of changes. this._prefBranch = Services.prefs.getBranch("browser.formfill."); @@ -407,9 +410,9 @@ export class FormAutoComplete { Services.obs.addObserver(this, "autocomplete-will-enter-text"); } - classID = Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"); + classID = Components.ID("{23530265-31d1-4ee9-864c-c081975fb7bc}"); QueryInterface = ChromeUtils.generateQI([ - "nsIFormAutoComplete", + "nsIFormHistoryAutoComplete", "nsISupportsWeakReference", ]); @@ -420,8 +423,6 @@ export class FormAutoComplete { // one is already pending, the existing one is cancelled. #pendingClient = null; - fillRequestId = 0; - observer = { _self: null, @@ -465,8 +466,7 @@ export class FormAutoComplete { if (!this._debug) { return; } - dump("FormAutoComplete: " + message + "\n"); - Services.console.logStringMessage("FormAutoComplete: " + message); + Services.console.logStringMessage("FormHistoryAutoComplete: " + message); } /* @@ -478,7 +478,7 @@ export class FormAutoComplete { * aField -- HTMLInputElement being autocompleted (may be null if from chrome) * aPreviousResult -- previous search result, if any. * aAddDataList -- add results from list=datalist for aField. - * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult + * aListener -- nsIFormHistoryAutoCompleteObserver that listens for the nsIAutoCompleteResult * that may be returned asynchronously. */ autoCompleteSearchAsync( @@ -507,7 +507,7 @@ export class FormAutoComplete { } // If we have datalist results, they become our "empty" result. - const result = new FormAutoCompleteResult( + const result = new FormHistoryAutoCompleteResult( client, [], aInputName, @@ -650,44 +650,11 @@ export class FormAutoComplete { async observe(subject, topic, data) { switch (topic) { case "autocomplete-will-enter-text": { - await this.sendFillRequestToFormHistoryParent(subject, data); + if (subject && subject == formFillController.controller?.input) { + await sendFillRequestToParent("FormHistory", subject, data); + } break; } } } - - async sendFillRequestToFormHistoryParent(input, comment) { - if (!comment) { - return; - } - - if (!input || input != formFillController.controller?.input) { - return; - } - - const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}"); - if (!fillMessageName) { - return; - } - - this.fillRequestId++; - const fillRequestId = this.fillRequestId; - const actor = - input.focusedInput.ownerGlobal.windowGlobalChild.getActor("FormHistory"); - const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {}); - - // skip fill if another fill operation started during await - if (fillRequestId != this.fillRequestId) { - return; - } - - if (typeof value !== "string") { - return; - } - - // If FormHistoryParent returned a string to fill, we must do it here because - // nsAutoCompleteController.cpp already finished it's work before we finished await. - input.textValue = value; - input.selectTextRange(value.length, value.length); - } } diff --git a/toolkit/components/satchel/components.conf b/toolkit/components/satchel/components.conf index d843b869d6..d5a670efd9 100644 --- a/toolkit/components/satchel/components.conf +++ b/toolkit/components/satchel/components.conf @@ -18,10 +18,10 @@ Classes = [ }, { - 'cid': '{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}', - 'contract_ids': ['@mozilla.org/satchel/form-autocomplete;1'], - 'esModule': 'resource://gre/modules/FormAutoComplete.sys.mjs', - 'constructor': 'FormAutoComplete', + 'cid': '{23530265-31d1-4ee9-864c-c081975fb7bc}', + 'contract_ids': ['@mozilla.org/satchel/form-history-autocomplete;1'], + 'esModule': 'resource://gre/modules/FormHistoryAutoComplete.sys.mjs', + 'constructor': 'FormHistoryAutoComplete', }, { 'cid': '{3a0012eb-007f-4bb8-aa81-a07385f77a25}', diff --git a/toolkit/components/satchel/jar.mn b/toolkit/components/satchel/jar.mn new file mode 100644 index 0000000000..a3f250f2e8 --- /dev/null +++ b/toolkit/components/satchel/jar.mn @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: + content/global/megalist/megalist.css (megalist/content/megalist.css) + content/global/megalist/megalist.html (megalist/content/megalist.html) + content/global/megalist/MegalistView.mjs (megalist/content/MegalistView.mjs) + content/global/megalist/search-input.mjs (megalist/content/search-input.mjs) + content/global/megalist/VirtualizedList.mjs (megalist/content/VirtualizedList.mjs) diff --git a/toolkit/components/satchel/megalist/MegalistChild.sys.mjs b/toolkit/components/satchel/megalist/MegalistChild.sys.mjs new file mode 100644 index 0000000000..cd17798c95 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistChild.sys.mjs @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export class MegalistChild extends JSWindowActorChild { + receiveMessage(message) { + // Forward message to the View + const win = this.document.defaultView; + const ev = new win.CustomEvent("MessageFromViewModel", { + detail: message, + }); + win.dispatchEvent(ev); + } + + // Prevent TypeError: Property 'handleEvent' is not callable. + handleEvent() {} +} diff --git a/toolkit/components/satchel/megalist/MegalistParent.sys.mjs b/toolkit/components/satchel/megalist/MegalistParent.sys.mjs new file mode 100644 index 0000000000..de04af7ea6 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistParent.sys.mjs @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MegalistViewModel } from "resource://gre/modules/megalist/MegalistViewModel.sys.mjs"; + +/** + * MegalistParent integrates MegalistViewModel into Parent/Child model. + */ +export class MegalistParent extends JSWindowActorParent { + #viewModel; + + actorCreated() { + this.#viewModel = new MegalistViewModel((...args) => + this.sendAsyncMessage(...args) + ); + } + + didDestroy() { + this.#viewModel.willDestroy(); + this.#viewModel = null; + } + + receiveMessage(message) { + return this.#viewModel?.handleViewMessage(message); + } +} diff --git a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs new file mode 100644 index 0000000000..f11a8a3198 --- /dev/null +++ b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs @@ -0,0 +1,291 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { DefaultAggregator } from "resource://gre/modules/megalist/aggregator/DefaultAggregator.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +/** + * View Model for Megalist. + * + * Responsible for filtering, grouping, moving selection, editing. + * Refers to the same MegalistAggregator in the parent process to access data. + * Paired to exactly one MegalistView in the child process to present to the user. + * Receives user commands from MegalistView. + * + * There can be multiple snapshots of the same line displayed in different contexts. + * + * snapshotId - an id for a snapshot of a line used between View Model and View. + */ +export class MegalistViewModel { + /** + * + * View Model prepares snapshots in the parent process to be displayed + * by the View in the child process. View gets the firstSnapshotId + length of the + * list. Making it a very short message for each time we filter or refresh data. + * + * View requests line data by providing snapshotId = firstSnapshotId + index. + * + */ + #firstSnapshotId = 0; + #snapshots = []; + #selectedIndex = 0; + #searchText = ""; + #messageToView; + static #aggregator = new DefaultAggregator(); + + constructor(messageToView) { + this.#messageToView = messageToView; + MegalistViewModel.#aggregator.attachViewModel(this); + } + + willDestroy() { + MegalistViewModel.#aggregator.detachViewModel(this); + } + + refreshAllLinesOnScreen() { + this.#rebuildSnapshots(); + } + + refreshSingleLineOnScreen(line) { + if (this.#searchText) { + // Data is filtered, which may require rebuilding the whole list + //@sg check if current filter would affected by this line + //@sg throttle refresh operation + this.#rebuildSnapshots(); + } else { + const snapshotIndex = this.#snapshots.indexOf(line); + if (snapshotIndex >= 0) { + const snapshotId = snapshotIndex + this.#firstSnapshotId; + this.#sendSnapshotToView(snapshotId, line); + } + } + } + + /** + * + * Send snapshot of necessary line data across parent-child boundary. + * + * @param {number} snapshotId + * @param {object} snapshotData + */ + async #sendSnapshotToView(snapshotId, snapshotData) { + if (!snapshotData) { + return; + } + + // Only usable set of fields is sent over to the View. + // Line object may contain other data used by the Data Source. + const snapshot = { + label: snapshotData.label, + value: await snapshotData.value, + }; + if ("template" in snapshotData) { + snapshot.template = snapshotData.template; + } + if ("start" in snapshotData) { + snapshot.start = snapshotData.start; + } + if ("end" in snapshotData) { + snapshot.end = snapshotData.end; + } + if ("commands" in snapshotData) { + snapshot.commands = snapshotData.commands; + } + if ("valueIcon" in snapshotData) { + snapshot.valueIcon = snapshotData.valueIcon; + } + if ("href" in snapshotData) { + snapshot.href = snapshotData.href; + } + if (snapshotData.stickers) { + for (const sticker of snapshotData.stickers) { + snapshot.stickers ??= []; + snapshot.stickers.push(sticker); + } + } + + this.#messageToView("Snapshot", { snapshotId, snapshot }); + } + + receiveRequestSnapshot({ snapshotId }) { + const snapshotIndex = snapshotId - this.#firstSnapshotId; + const snapshot = this.#snapshots[snapshotIndex]; + if (!snapshot) { + // Ignore request for unknown line index or outdated list + return; + } + + if (snapshot.lineIsReady()) { + this.#sendSnapshotToView(snapshotId, snapshot); + } + } + + handleViewMessage({ name, data }) { + const handlerName = `receive${name}`; + if (!(handlerName in this)) { + throw new Error(`Received unknown message "${name}"`); + } + return this[handlerName](data); + } + + receiveRefresh() { + this.#rebuildSnapshots(); + } + + #rebuildSnapshots() { + // Remember current selection to attempt to restore it later + const prevSelected = this.#snapshots[this.#selectedIndex]; + + // Rebuild snapshots + this.#firstSnapshotId += this.#snapshots.length; + this.#snapshots = Array.from( + MegalistViewModel.#aggregator.enumerateLines(this.#searchText) + ); + + // Update snapshots on screen + this.#messageToView("ShowSnapshots", { + firstSnapshotId: this.#firstSnapshotId, + count: this.#snapshots.length, + }); + + // Restore selection + const usedToBeSelectedNewIndex = this.#snapshots.findIndex( + snapshot => snapshot == prevSelected + ); + if (usedToBeSelectedNewIndex >= 0) { + this.#selectSnapshotByIndex(usedToBeSelectedNewIndex); + } else { + // Make sure selection is within visible lines + this.#selectSnapshotByIndex( + Math.min(this.#selectedIndex, this.#snapshots.length - 1) + ); + } + } + + receiveUpdateFilter({ searchText } = { searchText: "" }) { + if (this.#searchText != searchText) { + this.#searchText = searchText; + this.#messageToView("MegalistUpdateFilter", { searchText }); + this.#rebuildSnapshots(); + } + } + + async receiveCommand({ commandId, snapshotId, value } = {}) { + const index = snapshotId + ? snapshotId - this.#firstSnapshotId + : this.#selectedIndex; + const snapshot = this.#snapshots[index]; + if (snapshot) { + commandId = commandId ?? snapshot.commands[0]?.id; + const mustVerify = snapshot.commands.find(c => c.id == commandId)?.verify; + if (!mustVerify || (await this.#verifyUser())) { + // TODO:Enter the prompt message and pref for #verifyUser() + await snapshot[`execute${commandId}`]?.(value); + } + } + } + + receiveSelectSnapshot({ snapshotId }) { + const index = snapshotId - this.#firstSnapshotId; + if (index >= 0) { + this.#selectSnapshotByIndex(index); + } + } + + receiveSelectNextSnapshot() { + this.#selectSnapshotByIndex(this.#selectedIndex + 1); + } + + receiveSelectPreviousSnapshot() { + this.#selectSnapshotByIndex(this.#selectedIndex - 1); + } + + receiveSelectNextGroup() { + let i = this.#selectedIndex + 1; + while (i < this.#snapshots.length - 1 && !this.#snapshots[i].start) { + i += 1; + } + this.#selectSnapshotByIndex(i); + } + + receiveSelectPreviousGroup() { + let i = this.#selectedIndex - 1; + while (i >= 0 && !this.#snapshots[i].start) { + i -= 1; + } + this.#selectSnapshotByIndex(i); + } + + #selectSnapshotByIndex(index) { + if (index >= 0 && index < this.#snapshots.length) { + this.#selectedIndex = index; + const selectedIndex = this.#selectedIndex; + this.#messageToView("UpdateSelection", { selectedIndex }); + } + } + + async #verifyUser(promptMessage, prefName) { + if (!this.getOSAuthEnabled(prefName)) { + promptMessage = false; + } + let result = await lazy.OSKeyStore.ensureLoggedIn(promptMessage); + return result.authenticated; + } + + /** + * Get the decrypted value for a string pref. + * + * @param {string} prefName -> The pref whose value is needed. + * @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set. + * @returns {string} + */ + #getSecurePref(prefName, safeDefaultValue) { + try { + let encryptedValue = Services.prefs.getStringPref(prefName, ""); + return this._crypto.decrypt(encryptedValue); + } catch { + return safeDefaultValue; + } + } + + /** + * Set the pref to the encrypted form of the value. + * + * @param {string} prefName -> The pref whose value is to be set. + * @param {string} value -> The value to be set in its encryoted form. + */ + #setSecurePref(prefName, value) { + let encryptedValue = this._crypto.encrypt(value); + Services.prefs.setStringPref(prefName, encryptedValue); + } + + /** + * Get whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The name of the pref (creditcards or addresses) + * @returns {boolean} + */ + getOSAuthEnabled(prefName) { + return this.#getSecurePref(prefName, "") !== "opt out"; + } + + /** + * Set whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The pref to encrypt. + * @param {boolean} enable -> Whether the pref is to be enabled. + */ + setOSAuthEnabled(prefName, enable) { + if (enable) { + Services.prefs.clearUserPref(prefName); + } else { + this.#setSecurePref(prefName, "opt out"); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs new file mode 100644 index 0000000000..e101fadd16 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Connects multiple Data Sources with multiple View Models. + * Aggregator owns Data Sources. + * Aggregator weakly refers to View Models. + */ +export class Aggregator { + #sources = []; + #attachedViewModels = []; + + attachViewModel(viewModel) { + // Weak reference the View Model so we do not keep it in memory forever + this.#attachedViewModels.push(new WeakRef(viewModel)); + } + + detachViewModel(viewModel) { + for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) { + const knownViewModel = this.#attachedViewModels[i].deref(); + if (viewModel == knownViewModel || !knownViewModel) { + this.#attachedViewModels.splice(i, 1); + } + } + } + + /** + * Run action on each of the alive attached view models. + * Remove dead consumers. + * + * @param {Function} action to perform on each alive consumer + */ + forEachViewModel(action) { + for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) { + const viewModel = this.#attachedViewModels[i].deref(); + if (viewModel) { + action(viewModel); + } else { + this.#attachedViewModels.splice(i, 1); + } + } + } + + *enumerateLines(searchText) { + for (let source of this.#sources) { + yield* source.enumerateLines(searchText); + } + } + + /** + * + * @param {Function} createSourceFn (aggregatorApi) used to create Data Source. + * aggregatorApi is the way for Data Source to push data + * to the Aggregator. + */ + addSource(createSourceFn) { + const api = this.#apiForDataSource(); + const source = createSourceFn(api); + this.#sources.push(source); + } + + /** + * Exposes interface for a datasource to communicate with Aggregator. + */ + #apiForDataSource() { + const aggregator = this; + return { + refreshSingleLineOnScreen(line) { + aggregator.forEachViewModel(vm => vm.refreshSingleLineOnScreen(line)); + }, + + refreshAllLinesOnScreen() { + aggregator.forEachViewModel(vm => vm.refreshAllLinesOnScreen()); + }, + }; + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs new file mode 100644 index 0000000000..cf3a78a6a4 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Aggregator } from "resource://gre/modules/megalist/aggregator/Aggregator.sys.mjs"; +import { AddressesDataSource } from "resource://gre/modules/megalist/aggregator/datasources/AddressesDataSource.sys.mjs"; +import { BankCardDataSource } from "resource://gre/modules/megalist/aggregator/datasources/BankCardDataSource.sys.mjs"; +import { LoginDataSource } from "resource://gre/modules/megalist/aggregator/datasources/LoginDataSource.sys.mjs"; + +export class DefaultAggregator extends Aggregator { + constructor() { + super(); + this.addSource(aggregatorApi => new AddressesDataSource(aggregatorApi)); + this.addSource(aggregatorApi => new BankCardDataSource(aggregatorApi)); + this.addSource(aggregatorApi => new LoginDataSource(aggregatorApi)); + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs new file mode 100644 index 0000000000..f00df0b40b --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs"; + +async function updateAddress(address, field, value) { + try { + const newAddress = { + ...address, + [field]: value ?? "", + }; + + formAutofillStorage.INTERNAL_FIELDS.forEach( + name => delete newAddress[name] + ); + formAutofillStorage.addresses.VALID_COMPUTED_FIELDS.forEach( + name => delete newAddress[name] + ); + + if (address.guid) { + await formAutofillStorage.addresses.update(address.guid, newAddress); + } else { + await formAutofillStorage.addresses.add(newAddress); + } + } catch (error) { + //todo + console.error("failed to modify address", error); + return false; + } + + return true; +} + +/** + * Data source for Addresses. + * + */ + +export class AddressesDataSource extends DataSourceBase { + #namePrototype; + #organizationPrototype; + #streetAddressPrototype; + #addressLevelOnePrototype; + #addressLevelTwoPrototype; + #addressLevelThreePrototype; + #postalCodePrototype; + #countryPrototype; + #phonePrototype; + #emailPrototype; + + #addressesDisabledMessage; + #enabled; + #header; + + constructor(...args) { + super(...args); + this.formatMessages( + "addresses-section-label", + "address-name-label", + "address-phone-label", + "address-email-label", + "command-copy", + "addresses-disabled", + "command-delete", + "command-edit", + "addresses-command-create" + ).then( + ([ + headerLabel, + nameLabel, + phoneLabel, + emailLabel, + copyLabel, + addressesDisabled, + deleteLabel, + editLabel, + createLabel, + ]) => { + const copyCommand = { id: "Copy", label: copyLabel }; + const editCommand = { id: "Edit", label: editLabel }; + const deleteCommand = { id: "Delete", label: deleteLabel }; + this.#addressesDisabledMessage = addressesDisabled; + this.#header = this.createHeaderLine(headerLabel); + this.#header.commands.push({ id: "Create", label: createLabel }); + + let self = this; + + function prototypeLine(label, key, options = {}) { + return self.prototypeDataLine({ + label: { value: label }, + value: { + get() { + return this.editingValue ?? this.record[key]; + }, + }, + commands: { + value: [copyCommand, editCommand, "-", deleteCommand], + }, + executeEdit: { + value() { + this.editingValue = this.record[key] ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + async value(value) { + if (await updateAddress(this.record, key, value)) { + this.executeCancel(); + } + }, + }, + ...options, + }); + } + + this.#namePrototype = prototypeLine(nameLabel, "name", { + start: { value: true }, + }); + this.#organizationPrototype = prototypeLine( + "Organization", + "organization" + ); + this.#streetAddressPrototype = prototypeLine( + "Street Address", + "street-address" + ); + this.#addressLevelThreePrototype = prototypeLine( + "Neighbourhood", + "address-level3" + ); + this.#addressLevelTwoPrototype = prototypeLine( + "City", + "address-level2" + ); + this.#addressLevelOnePrototype = prototypeLine( + "Province", + "address-level1" + ); + this.#postalCodePrototype = prototypeLine("Postal Code", "postal-code"); + this.#countryPrototype = prototypeLine("Country", "country"); + this.#phonePrototype = prototypeLine(phoneLabel, "tel"); + this.#emailPrototype = prototypeLine(emailLabel, "email", { + end: { value: true }, + }); + + Services.obs.addObserver(this, "formautofill-storage-changed"); + Services.prefs.addObserver( + "extensions.formautofill.addresses.enabled", + this + ); + this.#reloadDataSource(); + } + ); + } + + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref( + "extensions.formautofill.addresses.enabled" + ); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + await formAutofillStorage.initialize(); + const addresses = await formAutofillStorage.addresses.getAll(); + this.beforeReloadingDataSource(); + addresses.forEach(address => { + const lineId = `${address.name}:${address.tel}`; + + this.addOrUpdateLine(address, lineId + "0", this.#namePrototype); + this.addOrUpdateLine(address, lineId + "1", this.#organizationPrototype); + this.addOrUpdateLine(address, lineId + "2", this.#streetAddressPrototype); + this.addOrUpdateLine( + address, + lineId + "3", + this.#addressLevelThreePrototype + ); + this.addOrUpdateLine( + address, + lineId + "4", + this.#addressLevelTwoPrototype + ); + this.addOrUpdateLine( + address, + lineId + "5", + this.#addressLevelOnePrototype + ); + this.addOrUpdateLine(address, lineId + "6", this.#postalCodePrototype); + this.addOrUpdateLine(address, lineId + "7", this.#countryPrototype); + this.addOrUpdateLine(address, lineId + "8", this.#phonePrototype); + this.addOrUpdateLine(address, lineId + "9", this.#emailPrototype); + }); + this.afterReloadingDataSource(); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { total: 0, count: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords(searchText, stats, address => + [ + "name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", + ].some(key => address[key]?.toUpperCase().includes(searchText)) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "addresses-count" + : "addresses-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + this.#header.value = this.#addressesDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "formautofill-storage-changed" || + message == "extensions.formautofill.addresses.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs new file mode 100644 index 0000000000..06266a7979 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs @@ -0,0 +1,339 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs"; +import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs"; +import { OSKeyStore } from "resource://gre/modules/OSKeyStore.sys.mjs"; + +async function decryptCard(card) { + if (card["cc-number-encrypted"] && !card["cc-number-decrypted"]) { + try { + card["cc-number-decrypted"] = await OSKeyStore.decrypt( + card["cc-number-encrypted"], + false + ); + card["cc-number"] = card["cc-number-decrypted"]; + } catch (e) { + console.error(e); + } + } +} + +async function updateCard(card, field, value) { + try { + await decryptCard(card); + const newCard = { + ...card, + [field]: value ?? "", + }; + formAutofillStorage.INTERNAL_FIELDS.forEach(name => delete newCard[name]); + formAutofillStorage.creditCards.VALID_COMPUTED_FIELDS.forEach( + name => delete newCard[name] + ); + delete newCard["cc-number-decrypted"]; + CreditCardRecord.normalizeFields(newCard); + + if (card.guid) { + await formAutofillStorage.creditCards.update(card.guid, newCard); + } else { + await formAutofillStorage.creditCards.add(newCard); + } + } catch (error) { + //todo + console.error("failed to modify credit card", error); + return false; + } + + return true; +} + +/** + * Data source for Bank Cards. + * + * Each card is represented by 3 lines: card number, expiration date and holder name. + * + * Protypes are used to reduce memory need because for different records + * similar lines will differ in values only. + */ +export class BankCardDataSource extends DataSourceBase { + #cardNumberPrototype; + #expirationPrototype; + #holderNamePrototype; + #cardsDisabledMessage; + #enabled; + #header; + + constructor(...args) { + super(...args); + // Wait for Fluent to provide strings before loading data + this.formatMessages( + "payments-section-label", + "card-number-label", + "card-expiration-label", + "card-holder-label", + "command-copy", + "command-reveal", + "command-conceal", + "payments-disabled", + "command-delete", + "command-edit", + "payments-command-create" + ).then( + ([ + headerLabel, + numberLabel, + expirationLabel, + holderLabel, + copyCommandLabel, + revealCommandLabel, + concealCommandLabel, + cardsDisabled, + deleteCommandLabel, + editCommandLabel, + cardsCreateCommandLabel, + ]) => { + const copyCommand = { id: "Copy", label: copyCommandLabel }; + const editCommand = { + id: "Edit", + label: editCommandLabel, + verify: true, + }; + const deleteCommand = { + id: "Delete", + label: deleteCommandLabel, + verify: true, + }; + this.#cardsDisabledMessage = cardsDisabled; + this.#header = this.createHeaderLine(headerLabel); + this.#header.commands.push({ + id: "Create", + label: cardsCreateCommandLabel, + }); + this.#cardNumberPrototype = this.prototypeDataLine({ + label: { value: numberLabel }, + concealed: { value: true, writable: true }, + start: { value: true }, + value: { + async get() { + if (this.editingValue !== undefined) { + return this.editingValue; + } + + if (this.concealed) { + return ( + "••••••••" + + this.record["cc-number"].replaceAll("*", "").substr(-4) + ); + } + + await decryptCard(this.record); + return this.record["cc-number-decrypted"]; + }, + }, + valueIcon: { + get() { + const typeToImage = { + amex: "third-party/cc-logo-amex.png", + cartebancaire: "third-party/cc-logo-cartebancaire.png", + diners: "third-party/cc-logo-diners.svg", + discover: "third-party/cc-logo-discover.png", + jcb: "third-party/cc-logo-jcb.svg", + mastercard: "third-party/cc-logo-mastercard.svg", + mir: "third-party/cc-logo-mir.svg", + unionpay: "third-party/cc-logo-unionpay.svg", + visa: "third-party/cc-logo-visa.svg", + }; + return ( + "chrome://formautofill/content/" + + (typeToImage[this.record["cc-type"]] ?? + "icon-credit-card-generic.svg") + ); + }, + }, + commands: { + get() { + const commands = [ + { id: "Conceal", label: concealCommandLabel }, + { ...copyCommand, verify: true }, + editCommand, + "-", + deleteCommand, + ]; + if (this.concealed) { + commands[0] = { + id: "Reveal", + label: revealCommandLabel, + verify: true, + }; + } + return commands; + }, + }, + executeReveal: { + value() { + this.concealed = false; + this.refreshOnScreen(); + }, + }, + executeConceal: { + value() { + this.concealed = true; + this.refreshOnScreen(); + }, + }, + executeCopy: { + async value() { + await decryptCard(this.record); + this.copyToClipboard(this.record["cc-number-decrypted"]); + }, + }, + executeEdit: { + async value() { + await decryptCard(this.record); + this.editingValue = this.record["cc-number-decrypted"] ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + async value(value) { + if (updateCard(this.record, "cc-number", value)) { + this.executeCancel(); + } + }, + }, + }); + this.#expirationPrototype = this.prototypeDataLine({ + label: { value: expirationLabel }, + value: { + get() { + return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`; + }, + }, + commands: { + value: [copyCommand, editCommand, "-", deleteCommand], + }, + }); + this.#holderNamePrototype = this.prototypeDataLine({ + label: { value: holderLabel }, + end: { value: true }, + value: { + get() { + return this.editingValue ?? this.record["cc-name"]; + }, + }, + commands: { + value: [copyCommand, editCommand, "-", deleteCommand], + }, + executeEdit: { + value() { + this.editingValue = this.record["cc-name"] ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + async value(value) { + if (updateCard(this.record, "cc-name", value)) { + this.executeCancel(); + } + }, + }, + }); + + Services.obs.addObserver(this, "formautofill-storage-changed"); + Services.prefs.addObserver( + "extensions.formautofill.creditCards.enabled", + this + ); + this.#reloadDataSource(); + } + ); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { count: 0, total: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords( + searchText, + stats, + card => + (card["cc-number-decrypted"] || card["cc-number"]) + .toUpperCase() + .includes(searchText) || + `${card["cc-exp-month"]}/${card["cc-exp-year"]}` + .toUpperCase() + .includes(searchText) || + card["cc-name"].toUpperCase().includes(searchText) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "payments-count" + : "payments-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + /** + * Sync lines array with the actual data source. + * This function reads all cards from the storage, adds or updates lines and + * removes lines for the removed cards. + */ + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref( + "extensions.formautofill.creditCards.enabled" + ); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + await formAutofillStorage.initialize(); + const cards = await formAutofillStorage.creditCards.getAll(); + this.beforeReloadingDataSource(); + cards.forEach(card => { + const lineId = `${card["cc-name"]}:${card.guid}`; + + this.addOrUpdateLine(card, lineId + "0", this.#cardNumberPrototype); + this.addOrUpdateLine(card, lineId + "1", this.#expirationPrototype); + this.addOrUpdateLine(card, lineId + "2", this.#holderNamePrototype); + }); + this.afterReloadingDataSource(); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + //todo: user can enable credit cards by activating header line + this.#header.value = this.#cardsDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "formautofill-storage-changed" || + message == "extensions.formautofill.creditCards.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs new file mode 100644 index 0000000000..49be733aef --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs @@ -0,0 +1,291 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { BinarySearch } from "resource://gre/modules/BinarySearch.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +/** + * Create a function to format messages. + * + * @param {...any} ftlFiles to be used for formatting messages + * @returns {Function} a function that can be used to format messsages + */ +function createFormatMessages(...ftlFiles) { + const strings = new Localization(ftlFiles); + + return async (...ids) => { + for (const i in ids) { + if (typeof ids[i] == "string") { + ids[i] = { id: ids[i] }; + } + } + + const messages = await strings.formatMessages(ids); + return messages.map(message => { + if (message.attributes) { + return message.attributes.reduce( + (result, { name, value }) => ({ ...result, [name]: value }), + {} + ); + } + return message.value; + }); + }; +} + +/** + * Base datasource class + */ +export class DataSourceBase { + #aggregatorApi; + + constructor(aggregatorApi) { + this.#aggregatorApi = aggregatorApi; + } + + // proxy consumer api functions to datasource interface + + refreshSingleLineOnScreen(line) { + this.#aggregatorApi.refreshSingleLineOnScreen(line); + } + + refreshAllLinesOnScreen() { + this.#aggregatorApi.refreshAllLinesOnScreen(); + } + + formatMessages = createFormatMessages("preview/megalist.ftl"); + + /** + * Prototype for the each line. + * See this link for details: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties#props + */ + #linePrototype = { + /** + * Reference to the Data Source that owns this line. + */ + source: this, + + /** + * Each line has a reference to the actual data record. + */ + record: { writable: true }, + + /** + * Is line ready to be displayed? + * Used by the View Model. + * + * @returns {boolean} true if line can be sent to the view. + * false if line is not ready to be displayed. In this case + * data source will start pulling value from the underlying + * storage and will push data to screen when it's ready. + */ + lineIsReady() { + return true; + }, + + copyToClipboard(text) { + lazy.ClipboardHelper.copyString(text, lazy.ClipboardHelper.Sensitive); + }, + + openLinkInTab(url) { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + browser.addWebTab(url, { inBackground: false }); + }, + + /** + * Simple version of Copy command. Line still needs to add "Copy" command. + * Override if copied value != displayed value. + */ + executeCopy() { + this.copyToClipboard(this.value); + }, + + executeOpen() { + this.openLinkInTab(this.href); + }, + + executeEditInProgress(value) { + this.editingValue = value; + this.refreshOnScreen(); + }, + + executeCancel() { + delete this.editingValue; + this.refreshOnScreen(); + }, + + get template() { + return "editingValue" in this ? "editingLineTemplate" : undefined; + }, + + refreshOnScreen() { + this.source.refreshSingleLineOnScreen(this); + }, + }; + + /** + * Creates collapsible section header line. + * + * @param {string} label for the section + * @returns {object} section header line + */ + createHeaderLine(label) { + const toggleCommand = { id: "Toggle", label: "" }; + const result = { + label, + value: "", + collapsed: false, + start: true, + end: true, + source: this, + + /** + * Use different templates depending on the collapsed state. + */ + get template() { + return this.collapsed + ? "collapsedSectionTemplate" + : "expandedSectionTemplate"; + }, + + lineIsReady: () => true, + + commands: [toggleCommand], + + executeToggle() { + this.collapsed = !this.collapsed; + this.source.refreshAllLinesOnScreen(); + }, + }; + + this.formatMessages("command-toggle").then(([toggleLabel]) => { + toggleCommand.label = toggleLabel; + }); + + return result; + } + + /** + * Create a prototype to be used for data lines, + * provides common set of features like Copy command. + * + * @param {object} properties to customize data line + * @returns {object} data line prototype + */ + prototypeDataLine(properties) { + return Object.create(this.#linePrototype, properties); + } + + lines = []; + #collator = new Intl.Collator(); + #linesToForget; + + /** + * Code to run before reloading data source. + * It will start tracking which lines are no longer at the source so + * afterReloadingDataSource() can remove them. + */ + beforeReloadingDataSource() { + this.#linesToForget = new Set(this.lines); + } + + /** + * Code to run after reloading data source. + * It will forget lines that are no longer at the source and refresh screen. + */ + afterReloadingDataSource() { + if (this.#linesToForget.size) { + for (let i = this.lines.length; i >= 0; i--) { + if (this.#linesToForget.has(this.lines[i])) { + this.lines.splice(i, 1); + } + } + } + + this.#linesToForget = null; + this.refreshAllLinesOnScreen(); + } + + /** + * Add or update line associated with the record. + * + * @param {object} record with which line is associated + * @param {*} id sortable line id + * @param {*} fieldPrototype to be used when creating a line. + */ + addOrUpdateLine(record, id, fieldPrototype) { + let [found, index] = BinarySearch.search( + (target, value) => this.#collator.compare(target, value.id), + this.lines, + id + ); + + if (found) { + this.#linesToForget.delete(this.lines[index]); + } else { + const line = Object.create(fieldPrototype, { id: { value: id } }); + this.lines.splice(index, 0, line); + } + this.lines[index].record = record; + return this.lines[index]; + } + + *enumerateLinesForMatchingRecords(searchText, stats, match) { + stats.total = 0; + stats.count = 0; + + if (searchText) { + let i = 0; + while (i < this.lines.length) { + const currentRecord = this.lines[i].record; + stats.total += 1; + + if (match(currentRecord)) { + // Record matches, yield all it's lines + while ( + i < this.lines.length && + currentRecord == this.lines[i].record + ) { + yield this.lines[i]; + i += 1; + } + stats.count += 1; + } else { + // Record does not match, skip until the next one + while ( + i < this.lines.length && + currentRecord == this.lines[i].record + ) { + i += 1; + } + } + } + } else { + // No search text is provided - send all lines out, count records + let currentRecord; + for (const line of this.lines) { + yield line; + + if (line.record != currentRecord) { + stats.total += 1; + currentRecord = line.record; + } + } + stats.count = stats.total; + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs new file mode 100644 index 0000000000..324bc4d141 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs @@ -0,0 +1,472 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs"; +import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs"; +import { LoginCSVImport } from "resource://gre/modules/LoginCSVImport.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "BREACH_ALERTS_ENABLED", + "signon.management.page.breach-alerts.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "VULNERABLE_PASSWORDS_ENABLED", + "signon.management.page.vulnerable-passwords.enabled", + false +); + +/** + * Data source for Logins. + * + * Each login is represented by 3 lines: origin, username and password. + * + * Protypes are used to reduce memory need because for different records + * similar lines will differ in values only. + */ +export class LoginDataSource extends DataSourceBase { + #originPrototype; + #usernamePrototype; + #passwordPrototype; + #loginsDisabledMessage; + #enabled; + #header; + + constructor(...args) { + super(...args); + // Wait for Fluent to provide strings before loading data + this.formatMessages( + "passwords-section-label", + "passwords-origin-label", + "passwords-username-label", + "passwords-password-label", + "command-open", + "command-copy", + "command-reveal", + "command-conceal", + "passwords-disabled", + "command-delete", + "command-edit", + "passwords-command-create", + "passwords-command-import", + "passwords-command-export", + "passwords-command-remove-all", + "passwords-command-settings", + "passwords-command-help", + "passwords-import-file-picker-title", + "passwords-import-file-picker-import-button", + "passwords-import-file-picker-csv-filter-title", + "passwords-import-file-picker-tsv-filter-title" + ).then( + ([ + headerLabel, + originLabel, + usernameLabel, + passwordLabel, + openCommandLabel, + copyCommandLabel, + revealCommandLabel, + concealCommandLabel, + passwordsDisabled, + deleteCommandLabel, + editCommandLabel, + passwordsCreateCommandLabel, + passwordsImportCommandLabel, + passwordsExportCommandLabel, + passwordsRemoveAllCommandLabel, + passwordsSettingsCommandLabel, + passwordsHelpCommandLabel, + passwordsImportFilePickerTitle, + passwordsImportFilePickerImportButton, + passwordsImportFilePickerCsvFilterTitle, + passwordsImportFilePickerTsvFilterTitle, + ]) => { + const copyCommand = { id: "Copy", label: copyCommandLabel }; + const editCommand = { id: "Edit", label: editCommandLabel }; + const deleteCommand = { id: "Delete", label: deleteCommandLabel }; + this.breachedSticker = { type: "warning", label: "BREACH" }; + this.vulnerableSticker = { type: "risk", label: "🤮 Vulnerable" }; + this.#loginsDisabledMessage = passwordsDisabled; + this.#header = this.createHeaderLine(headerLabel); + this.#header.commands.push( + { id: "Create", label: passwordsCreateCommandLabel }, + { id: "Import", label: passwordsImportCommandLabel }, + { id: "Export", label: passwordsExportCommandLabel }, + { id: "RemoveAll", label: passwordsRemoveAllCommandLabel }, + { id: "Settings", label: passwordsSettingsCommandLabel }, + { id: "Help", label: passwordsHelpCommandLabel } + ); + this.#header.executeImport = async () => { + await this.#importFromFile( + passwordsImportFilePickerTitle, + passwordsImportFilePickerImportButton, + passwordsImportFilePickerCsvFilterTitle, + passwordsImportFilePickerTsvFilterTitle + ); + }; + this.#header.executeSettings = () => { + this.#openPreferences(); + }; + this.#header.executeHelp = () => { + this.#getHelp(); + }; + + this.#originPrototype = this.prototypeDataLine({ + label: { value: originLabel }, + start: { value: true }, + value: { + get() { + return this.record.displayOrigin; + }, + }, + valueIcon: { + get() { + return `page-icon:${this.record.origin}`; + }, + }, + href: { + get() { + return this.record.origin; + }, + }, + commands: { + value: [ + { id: "Open", label: openCommandLabel }, + copyCommand, + "-", + deleteCommand, + ], + }, + executeCopy: { + value() { + this.copyToClipboard(this.record.origin); + }, + }, + }); + this.#usernamePrototype = this.prototypeDataLine({ + label: { value: usernameLabel }, + value: { + get() { + return this.editingValue ?? this.record.username; + }, + }, + commands: { value: [copyCommand, editCommand, "-", deleteCommand] }, + executeEdit: { + value() { + this.editingValue = this.record.username ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + value(value) { + try { + const modifiedLogin = this.record.clone(); + modifiedLogin.username = value; + Services.logins.modifyLogin(this.record, modifiedLogin); + } catch (error) { + //todo + console.error("failed to modify login", error); + } + this.executeCancel(); + }, + }, + }); + this.#passwordPrototype = this.prototypeDataLine({ + label: { value: passwordLabel }, + concealed: { value: true, writable: true }, + end: { value: true }, + value: { + get() { + return ( + this.editingValue ?? + (this.concealed ? "••••••••" : this.record.password) + ); + }, + }, + commands: { + get() { + const commands = [ + { id: "Conceal", label: concealCommandLabel }, + { + id: "Copy", + label: copyCommandLabel, + verify: true, + }, + editCommand, + "-", + deleteCommand, + ]; + if (this.concealed) { + commands[0] = { + id: "Reveal", + label: revealCommandLabel, + verify: true, + }; + } + return commands; + }, + }, + executeReveal: { + value() { + this.concealed = false; + this.refreshOnScreen(); + }, + }, + executeConceal: { + value() { + this.concealed = true; + this.refreshOnScreen(); + }, + }, + executeCopy: { + value() { + this.copyToClipboard(this.record.password); + }, + }, + executeEdit: { + value() { + this.editingValue = this.record.password ?? ""; + this.refreshOnScreen(); + }, + }, + executeSave: { + value(value) { + try { + const modifiedLogin = this.record.clone(); + modifiedLogin.password = value; + Services.logins.modifyLogin(this.record, modifiedLogin); + } catch (error) { + //todo + console.error("failed to modify login", error); + } + this.executeCancel(); + }, + }, + }); + + Services.obs.addObserver(this, "passwordmgr-storage-changed"); + Services.prefs.addObserver("signon.rememberSignons", this); + Services.prefs.addObserver( + "signon.management.page.breach-alerts.enabled", + this + ); + Services.prefs.addObserver( + "signon.management.page.vulnerable-passwords.enabled", + this + ); + this.#reloadDataSource(); + } + ); + } + + async #importFromFile(title, buttonLabel, csvTitle, tsvTitle) { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + let { result, path } = await this.openFilePickerDialog( + title, + buttonLabel, + [ + { + title: csvTitle, + extensionPattern: "*.csv", + }, + { + title: tsvTitle, + extensionPattern: "*.tsv", + }, + ], + browser.ownerGlobal + ); + + if (result != Ci.nsIFilePicker.returnCancel) { + let summary; + try { + summary = await LoginCSVImport.importFromCSV(path); + } catch (e) { + // TODO: Display error for import + } + if (summary) { + // TODO: Display successful import summary + } + } + } + + async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) { + return new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen); + for (const appendFilter of appendFilters) { + fp.appendFilter(appendFilter.title, appendFilter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.okButtonLabel = okButtonLabel; + fp.open(async result => { + resolve({ result, path: fp.file.path }); + }); + }); + } + + #openPreferences() { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + browser.ownerGlobal.openPreferences("privacy-logins"); + } + + #getHelp() { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + const browser = BrowserWindowTracker.getTopWindow().gBrowser; + const SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-manager-remember-delete-edit-logins"; + browser.ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", { + relatedToCurrent: true, + }); + } + + /** + * Enumerate all the lines provided by this data source. + * + * @param {string} searchText used to filter data + */ + *enumerateLines(searchText) { + if (this.#enabled === undefined) { + // Async Fluent API makes it possible to have data source waiting + // for the localized strings, which can be detected by undefined in #enabled. + return; + } + + yield this.#header; + if (this.#header.collapsed || !this.#enabled) { + return; + } + + const stats = { count: 0, total: 0 }; + searchText = searchText.toUpperCase(); + yield* this.enumerateLinesForMatchingRecords( + searchText, + stats, + login => + login.displayOrigin.toUpperCase().includes(searchText) || + login.username.toUpperCase().includes(searchText) || + login.password.toUpperCase().includes(searchText) + ); + + this.formatMessages({ + id: + stats.count == stats.total + ? "passwords-count" + : "passwords-filtered-count", + args: stats, + }).then(([headerLabel]) => { + this.#header.value = headerLabel; + }); + } + + /** + * Sync lines array with the actual data source. + * This function reads all logins from the storage, adds or updates lines and + * removes lines for the removed logins. + */ + async #reloadDataSource() { + this.#enabled = Services.prefs.getBoolPref("signon.rememberSignons"); + if (!this.#enabled) { + this.#reloadEmptyDataSource(); + return; + } + + const logins = await LoginHelper.getAllUserFacingLogins(); + this.beforeReloadingDataSource(); + + const breachesMap = lazy.BREACH_ALERTS_ENABLED + ? await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins) + : new Map(); + + logins.forEach(login => { + // Similar domains will be grouped together + // www. will have least effect on the sorting + const parts = login.displayOrigin.split("."); + + // Exclude TLD domain + //todo support eTLD and use public suffix here https://publicsuffix.org + if (parts.length > 1) { + parts.length -= 1; + } + const domain = parts.reverse().join("."); + const lineId = `${domain}:${login.username}:${login.guid}`; + + let originLine = this.addOrUpdateLine( + login, + lineId + "0", + this.#originPrototype + ); + this.addOrUpdateLine(login, lineId + "1", this.#usernamePrototype); + let passwordLine = this.addOrUpdateLine( + login, + lineId + "2", + this.#passwordPrototype + ); + + let breachIndex = + originLine.stickers?.findIndex(s => s === this.breachedSticker) ?? -1; + let breach = breachesMap.get(login.guid); + if (breach && breachIndex < 0) { + originLine.stickers ??= []; + originLine.stickers.push(this.breachedSticker); + } else if (!breach && breachIndex >= 0) { + originLine.stickers.splice(breachIndex, 1); + } + + const vulnerable = lazy.VULNERABLE_PASSWORDS_ENABLED + ? lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID([ + login, + ]).size + : 0; + + let vulnerableIndex = + passwordLine.stickers?.findIndex(s => s === this.vulnerableSticker) ?? + -1; + if (vulnerable && vulnerableIndex < 0) { + passwordLine.stickers ??= []; + passwordLine.stickers.push(this.vulnerableSticker); + } else if (!vulnerable && vulnerableIndex >= 0) { + passwordLine.stickers.splice(vulnerableIndex, 1); + } + }); + + this.afterReloadingDataSource(); + } + + #reloadEmptyDataSource() { + this.lines.length = 0; + //todo: user can enable passwords by activating Passwords header line + this.#header.value = this.#loginsDisabledMessage; + this.refreshAllLinesOnScreen(); + } + + observe(_subj, topic, message) { + if ( + topic == "passwordmgr-storage-changed" || + message == "signon.rememberSignons" || + message == "signon.management.page.breach-alerts.enabled" || + message == "signon.management.page.vulnerable-passwords.enabled" + ) { + this.#reloadDataSource(); + } + } +} diff --git a/toolkit/components/satchel/megalist/aggregator/moz.build b/toolkit/components/satchel/megalist/aggregator/moz.build new file mode 100644 index 0000000000..f244ade794 --- /dev/null +++ b/toolkit/components/satchel/megalist/aggregator/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES["megalist/aggregator"] += [ + "Aggregator.sys.mjs", + "DefaultAggregator.sys.mjs", +] + +EXTRA_JS_MODULES["megalist/aggregator/datasources"] += [ + "datasources/AddressesDataSource.sys.mjs", + "datasources/BankCardDataSource.sys.mjs", + "datasources/DataSourceBase.sys.mjs", + "datasources/LoginDataSource.sys.mjs", +] diff --git a/toolkit/components/satchel/megalist/content/MegalistView.mjs b/toolkit/components/satchel/megalist/content/MegalistView.mjs new file mode 100644 index 0000000000..44a0198692 --- /dev/null +++ b/toolkit/components/satchel/megalist/content/MegalistView.mjs @@ -0,0 +1,477 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/megalist/VirtualizedList.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/megalist/search-input.mjs"; + +/** + * Map with limit on how many entries it can have. + * When over limit entries are added, oldest one are removed. + */ +class MostRecentMap { + constructor(maxSize) { + this.#maxSize = maxSize; + } + + get(id) { + const data = this.#map.get(id); + if (data) { + this.#keepAlive(id, data); + } + return data; + } + + has(id) { + this.#map.has(id); + } + + set(id, data) { + this.#keepAlive(id, data); + this.#enforceLimits(); + } + + clear() { + this.#map.clear(); + } + + #maxSize; + #map = new Map(); + + #keepAlive(id, data) { + // Re-insert data to the map so it will be less likely to be evicted + this.#map.delete(id); + this.#map.set(id, data); + } + + #enforceLimits() { + // Maps preserve order in which data was inserted, + // we use that fact to remove oldest data from it. + while (this.#map.size > this.#maxSize) { + this.#map.delete(this.#map.keys().next().value); + } + } +} + +/** + * MegalistView presents data pushed to it by the MegalistViewModel and + * notify MegalistViewModel of user commands. + */ +export class MegalistView extends MozLitElement { + static keyToMessage = { + ArrowUp: "SelectPreviousSnapshot", + ArrowDown: "SelectNextSnapshot", + PageUp: "SelectPreviousGroup", + PageDown: "SelectNextGroup", + Escape: "UpdateFilter", + }; + static LINE_HEIGHT = 64; + + constructor() { + super(); + this.selectedIndex = 0; + this.searchText = ""; + + window.addEventListener("MessageFromViewModel", ev => + this.#onMessageFromViewModel(ev) + ); + } + + static get properties() { + return { + listLength: { type: Number }, + selectedIndex: { type: Number }, + searchText: { type: String }, + }; + } + + /** + * View shows list of snapshots of lines stored in the View Model. + * View Model provides the first snapshot id in the list and list length. + * It's safe to combine firstSnapshotId+index to identify specific snapshot + * in the list. When the list changes, View Model will provide a new + * list with new first snapshot id (even if the content is the same). + */ + #firstSnapshotId = 0; + + /** + * Cache 120 most recently used lines. + * View lives in child and View Model in parent processes. + * By caching a few lines we reduce the need to send data between processes. + * This improves performance in nearby scrolling scenarios. + * 7680 is 8K vertical screen resolution. + * Typical line is under 1/4KB long, making around 30KB cache requirement. + */ + #snapshotById = new MostRecentMap(7680 / MegalistView.LINE_HEIGHT); + + #templates = {}; + + connectedCallback() { + super.connectedCallback(); + this.ownerDocument.addEventListener("keydown", e => this.#handleKeydown(e)); + for (const template of this.ownerDocument.getElementsByTagName( + "template" + )) { + this.#templates[template.id] = template.content.firstElementChild; + } + this.#messageToViewModel("Refresh"); + } + + createLineElement(index) { + if (index < 0 || index >= this.listLength) { + return null; + } + + const snapshotId = this.#firstSnapshotId + index; + const lineElement = this.#templates.lineElement.cloneNode(true); + lineElement.dataset.id = snapshotId; + lineElement.addEventListener("dblclick", e => { + this.#messageToViewModel("Command"); + e.preventDefault(); + }); + + const data = this.#snapshotById.get(snapshotId); + if (data !== "Loading") { + if (data) { + this.#applyData(snapshotId, data, lineElement); + } else { + // Put placeholder for this snapshot data to avoid requesting it again + this.#snapshotById.set(snapshotId, "Loading"); + + // Ask for snapshot data from the View Model. + // Note: we could have optimized it further by asking for a range of + // indices because any scroll in virtualized list can only add + // a continuous range at the top or bottom of the visible area. + // However, this optimization is not necessary at the moment as + // we typically will request under a 100 of lines at a time. + // If we feel like making this improvement, we need to enhance + // VirtualizedList to request a range of new elements instead. + this.#messageToViewModel("RequestSnapshot", { snapshotId }); + } + } + + return lineElement; + } + + /** + * Find snapshot element on screen and populate it with data + */ + receiveSnapshot({ snapshotId, snapshot }) { + this.#snapshotById.set(snapshotId, snapshot); + + const lineElement = this.shadowRoot.querySelector( + `.line[data-id="${snapshotId}"]` + ); + if (lineElement) { + this.#applyData(snapshotId, snapshot, lineElement); + } + } + + #applyData(snapshotId, snapshotData, lineElement) { + let elementToFocus; + const template = + this.#templates[snapshotData.template] ?? this.#templates.lineTemplate; + + const lineContent = template.cloneNode(true); + lineContent.querySelector(".label").textContent = snapshotData.label; + + const valueElement = lineContent.querySelector(".value"); + if (valueElement) { + const valueText = lineContent.querySelector("span"); + if (valueText) { + valueText.textContent = snapshotData.value; + } else { + const valueInput = lineContent.querySelector("input"); + if (valueInput) { + valueInput.value = snapshotData.value; + valueInput.addEventListener("keydown", e => { + switch (e.code) { + case "Enter": + this.#messageToViewModel("Command", { + snapshotId, + commandId: "Save", + value: valueInput.value, + }); + break; + case "Escape": + this.#messageToViewModel("Command", { + snapshotId, + commandId: "Cancel", + }); + break; + default: + return; + } + e.preventDefault(); + e.stopPropagation(); + }); + valueInput.addEventListener("input", () => { + // Update local cache so we don't override editing value + // while user scrolls up or down a little. + const snapshotDataInChild = this.#snapshotById.get(snapshotId); + if (snapshotDataInChild) { + snapshotDataInChild.value = valueInput.value; + } + this.#messageToViewModel("Command", { + snapshotId, + commandId: "EditInProgress", + value: valueInput.value, + }); + }); + elementToFocus = valueInput; + } else { + valueElement.textContent = snapshotData.value; + } + } + + if (snapshotData.valueIcon) { + const valueIcon = valueElement.querySelector(".icon"); + if (valueIcon) { + valueIcon.src = snapshotData.valueIcon; + } + } + + if (snapshotData.href) { + const linkElement = this.ownerDocument.createElement("a"); + linkElement.className = valueElement.className; + linkElement.href = snapshotData.href; + linkElement.replaceChildren(...valueElement.children); + valueElement.replaceWith(linkElement); + } + + if (snapshotData.stickers?.length) { + const stickersElement = lineContent.querySelector(".stickers"); + for (const sticker of snapshotData.stickers) { + const stickerElement = this.ownerDocument.createElement("span"); + stickerElement.textContent = sticker.label; + stickerElement.className = sticker.type; + stickersElement.appendChild(stickerElement); + } + } + } + + lineElement.querySelector(".content").replaceWith(lineContent); + lineElement.classList.toggle("start", !!snapshotData.start); + lineElement.classList.toggle("end", !!snapshotData.end); + elementToFocus?.focus(); + } + + #messageToViewModel(messageName, data) { + window.windowGlobalChild + .getActor("Megalist") + .sendAsyncMessage(messageName, data); + } + + #onMessageFromViewModel({ detail }) { + const functionName = `receive${detail.name}`; + if (!(functionName in this)) { + throw new Error(`Received unknown message "${detail.name}"`); + } + this[functionName](detail.data); + } + + receiveUpdateSelection({ selectedIndex }) { + this.selectedIndex = selectedIndex; + } + + receiveShowSnapshots({ firstSnapshotId, count }) { + this.#firstSnapshotId = firstSnapshotId; + this.listLength = count; + + // Each new display list starts with the new first snapshot id + // so we can forget previously known data. + this.#snapshotById.clear(); + this.shadowRoot.querySelector("virtualized-list").requestRefresh(); + this.requestUpdate(); + } + + receiveMegalistUpdateFilter({ searchText }) { + this.searchText = searchText; + this.requestUpdate(); + } + + #handleInputChange(e) { + const searchText = e.target.value; + this.#messageToViewModel("UpdateFilter", { searchText }); + } + + #handleKeydown(e) { + const message = MegalistView.keyToMessage[e.code]; + if (message) { + this.#messageToViewModel(message); + e.preventDefault(); + } else if (e.code == "Enter") { + // Do not handle Enter at the virtualized list level when line menu is open + if ( + this.shadowRoot.querySelector( + ".line.selected > .menuButton > .menuPopup" + ) + ) { + return; + } + + if (e.altKey) { + // Execute default command1 + this.#messageToViewModel("Command"); + } else { + // Show line level menu + this.shadowRoot + .querySelector(".line.selected > .menuButton > button") + ?.click(); + } + e.preventDefault(); + } else if (e.ctrlKey && e.key == "c" && !this.searchText.length) { + this.#messageToViewModel("Command", { commandId: "Copy" }); + e.preventDefault(); + } + } + + #handleClick(e) { + const lineElement = e.composedTarget.closest(".line"); + if (!lineElement) { + return; + } + + const snapshotId = Number(lineElement.dataset.id); + const snapshotData = this.#snapshotById.get(snapshotId); + if (!snapshotData) { + return; + } + + this.#messageToViewModel("SelectSnapshot", { snapshotId }); + const menuButton = e.composedTarget.closest(".menuButton"); + if (menuButton) { + this.#handleMenuButtonClick(menuButton, snapshotId, snapshotData); + } + + e.preventDefault(); + } + + #handleMenuButtonClick(menuButton, snapshotId, snapshotData) { + if (!snapshotData.commands?.length) { + return; + } + + const popup = this.ownerDocument.createElement("div"); + popup.className = "menuPopup"; + popup.addEventListener( + "keydown", + e => { + function focusInternal(next, wrapSelector) { + let element = e.composedTarget; + do { + element = element[next]; + } while (element && element.tagName != "BUTTON"); + + // If we can't find next/prev button, focus the first/last one + element ??= + e.composedTarget.parentElement.querySelector(wrapSelector); + element?.focus(); + } + + function focusNext() { + focusInternal("nextElementSibling", "button"); + } + + function focusPrev() { + focusInternal("previousElementSibling", "button:last-of-type"); + } + + switch (e.code) { + case "Escape": + popup.remove(); + break; + case "Tab": + if (e.shiftKey) { + focusPrev(); + } else { + focusNext(); + } + break; + case "ArrowUp": + focusPrev(); + break; + case "ArrowDown": + focusNext(); + break; + default: + return; + } + + e.preventDefault(); + e.stopPropagation(); + }, + { capture: true } + ); + popup.addEventListener( + "blur", + e => { + if ( + e.composedTarget?.closest(".menuPopup") != + e.relatedTarget?.closest(".menuPopup") + ) { + // TODO: this triggers on macOS before "click" event. Due to this, + // we are not receiving the command. + popup.remove(); + } + }, + { capture: true } + ); + + for (const command of snapshotData.commands) { + if (command == "-") { + const separator = this.ownerDocument.createElement("div"); + separator.className = "separator"; + popup.appendChild(separator); + continue; + } + + const menuItem = this.ownerDocument.createElement("button"); + menuItem.textContent = command.label; + menuItem.addEventListener("click", e => { + this.#messageToViewModel("Command", { + snapshotId, + commandId: command.id, + }); + popup.remove(); + e.preventDefault(); + }); + popup.appendChild(menuItem); + } + + menuButton.querySelector("button").after(popup); + popup.querySelector("button")?.focus(); + } + + render() { + return html` + <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> + `; + } +} + +customElements.define("megalist-view", MegalistView); diff --git a/toolkit/components/satchel/megalist/content/VirtualizedList.mjs b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs new file mode 100644 index 0000000000..7903a189eb --- /dev/null +++ b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Virtualized List can efficiently show billions of lines provided + * that all of them have the same height. + * + * Caller is responsible for setting createLineElement(index) function to + * create elements as they are scrolled into the view. + */ +class VirtualizedList extends HTMLElement { + lineHeight = 64; + #lineCount = 0; + + get lineCount() { + return this.#lineCount; + } + + set lineCount(value) { + this.#lineCount = value; + this.#rebuildVisibleLines(); + } + + #selectedIndex = 0; + + get selectedIndex() { + return this.#selectedIndex; + } + + set selectedIndex(value) { + this.#selectedIndex = value; + if (this.#container) { + this.updateLineSelection(true); + } + } + + #container; + + connectedCallback() { + this.#container = this.ownerDocument.createElement("ul"); + this.#container.classList.add("lines-container"); + this.appendChild(this.#container); + + this.#rebuildVisibleLines(); + this.addEventListener("scroll", () => this.#rebuildVisibleLines()); + } + + requestRefresh() { + this.#container.replaceChildren(); + this.#rebuildVisibleLines(); + } + + updateLineSelection(scrollIntoView) { + const lineElements = this.#container.querySelectorAll(".line"); + let selectedElement; + + for (let lineElement of lineElements) { + let isSelected = Number(lineElement.dataset.index) === this.selectedIndex; + if (isSelected) { + selectedElement = lineElement; + } + lineElement.classList.toggle("selected", isSelected); + } + + if (scrollIntoView) { + if (selectedElement) { + selectedElement.scrollIntoView({ block: "nearest" }); + } else { + let selectedTop = this.selectedIndex * this.lineHeight; + if (this.scrollTop > selectedTop) { + this.scrollTop = selectedTop; + } else { + this.scrollTop = selectedTop - this.clientHeight + this.lineHeight; + } + } + } + } + + #rebuildVisibleLines() { + if (!this.isConnected || !this.createLineElement) { + return; + } + + this.#container.style.height = `${this.lineHeight * this.lineCount}px`; + + let firstLineIndex = Math.floor(this.scrollTop / this.lineHeight); + let visibleLineCount = Math.ceil(this.clientHeight / this.lineHeight); + let lastLineIndex = firstLineIndex + visibleLineCount; + let extraLines = Math.ceil(visibleLineCount / 2); // They are present in DOM, but not visible + + firstLineIndex = Math.max(0, firstLineIndex - extraLines); + lastLineIndex = Math.min(this.lineCount, lastLineIndex + extraLines); + + let previousChild = null; + let visibleLines = new Map(); + + for (let child of Array.from(this.#container.children)) { + let index = Number(child.dataset.index); + if (index < firstLineIndex || index > lastLineIndex) { + child.remove(); + } else { + visibleLines.set(index, child); + } + } + + for (let index = firstLineIndex; index <= lastLineIndex; index++) { + let child = visibleLines.get(index); + if (!child) { + child = this.createLineElement(index); + + if (!child) { + // Friday fix :-) + //todo: figure out what was on that Friday and how can we fix it + continue; + } + + child.style.top = `${index * this.lineHeight}px`; + child.dataset.index = index; + + if (previousChild) { + previousChild.after(child); + } else if (this.#container.firstElementChild?.offsetTop > top) { + this.#container.firstElementChild.before(child); + } else { + this.#container.appendChild(child); + } + } + previousChild = child; + } + + this.updateLineSelection(false); + } +} + +customElements.define("virtualized-list", VirtualizedList); diff --git a/toolkit/components/satchel/megalist/content/megalist.css b/toolkit/components/satchel/megalist/content/megalist.css new file mode 100644 index 0000000000..b442a7b60d --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.css @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Bug 1869845 - Styles in this file are still experimental! */ + +.container { + display: flex; + flex-direction: column; + justify-content: center; + max-height: 100vh; + + > search-input { + margin: 20px; + } +} + +virtualized-list { + position: relative; + overflow: auto; + margin: 20px; + + .lines-container { + padding-inline-start: unset; + } +} + +.line { + display: flex; + align-items: stretch; + position: absolute; + width: 100%; + user-select: none; + box-sizing: border-box; + height: 64px; + + background-color: var(--in-content-box-background-odd); + border-inline: 1px solid var(--in-content-border-color); + + color: var(--in-content-text-color); + + &.start { + border-block-start: 1px solid var(--in-content-border-color); + border-start-start-radius: 8px; + border-start-end-radius: 8px; + } + + &.end { + border-block-end: 1px solid var(--in-content-border-color); + border-end-start-radius: 8px; + border-end-end-radius: 8px; + height: 54px; + } + + > .menuButton { + position: relative; + visibility: hidden; + + > button { + border: none; + margin-inline-start: 2px; + padding: 2px; + background-color: transparent; + /* Fix: too lazy to load the svg */ + width: 32px; + color: unset; + } + + > .menuPopup { + position: absolute; + inset-inline-end: 0; + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + z-index: 1; + background-color: var(--in-content-table-background); + padding: 4px; + + > .separator { + border-block-start: 1px solid var(--in-content-border-color); + margin: 4px 0; + } + + > button { + text-align: start; + border-style: none; + padding: 12px; + margin-block-end: 2px; + width: 100%; + text-wrap: nowrap; + } + } + } + + > .content { + flex-grow: 1; + + > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-inline-start: 10px; + + &:last-child { + padding-block-end: 10px; + } + } + + > .icon { + margin-inline-start: 4px; + width: 16px; + height: 16px; + -moz-context-properties: fill; + fill: currentColor; + } + + > .label { + color: var(--text-color-deemphasized); + padding-block: 2px 4px; + } + + > .value { + user-select: text; + + > .icon { + -moz-context-properties: fill; + fill: currentColor; + width: auto; + height: 16px; + margin-inline: 4px; + vertical-align: text-bottom; + } + + > .icon:not([src]) { + display: none; + } + + &:is(a) { + color: currentColor; + } + } + + > .stickers { + text-align: end; + margin-block-start: 2px; + + > span { + padding: 2px; + margin-inline-end: 2px; + } + + /* Hard-coded colors will be addressed in FXCM-1013 */ + > span.risk { + background-color: slateblue; + border: 1px solid darkslateblue; + color: whitesmoke; + } + + > span.warning { + background-color: firebrick; + border: 1px solid maroon; + color: whitesmoke; + } + } + + &.section { + font-size: larger; + + > .label { + display: inline-block; + margin: 0; + color: unset; + } + + > .value { + margin-inline-end: 8px; + text-align: end; + font-size: smaller; + color: var(--text-color-deemphasized); + user-select: unset; + } + } + } + + &.selected { + color: var(--in-content-item-selected-text); + background-color: var(--in-content-item-selected); + + > .menuButton { + visibility: inherit; + } + } + + &:hover { + color: var(--in-content-item-hover-text); + background-color: var(--in-content-item-hover); + + > .menuButton { + visibility: visible; + } + } +} + +.search { + padding: 8px; + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + box-sizing: border-box; + width: 100%; +} diff --git a/toolkit/components/satchel/megalist/content/megalist.ftl b/toolkit/components/satchel/megalist/content/megalist.ftl new file mode 100644 index 0000000000..69d085a7c5 --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.ftl @@ -0,0 +1,126 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +filter-placeholder = + .placeholder = Search Your Data + .key = F + +## Commands + +command-copy = Copy +command-reveal = Reveal +command-conceal = Conceal +command-toggle = Toggle +command-open = Open +command-delete = Remove record +command-edit = Edit +command-save = Save +command-cancel = Cancel + +## Passwords + +passwords-section-label = Passwords +passwords-disabled = Passwords are disabled + +passwords-command-create = Add Password +passwords-command-import = Import from a File… +passwords-command-export = Export Passwords… +passwords-command-remove-all = Remove All Passwords… +passwords-command-settings = Settings +passwords-command-help = Help + +passwords-import-file-picker-title = Import Passwords +passwords-import-file-picker-import-button = Import + +# A description for the .csv file format that may be shown as the file type +# filter by the operating system. +passwords-import-file-picker-csv-filter-title = + { PLATFORM() -> + [macos] CSV Document + *[other] CSV File + } +# A description for the .tsv file format that may be shown as the file type +# filter by the operating system. TSV is short for 'tab separated values'. +passwords-import-file-picker-tsv-filter-title = + { PLATFORM() -> + [macos] TSV Document + *[other] TSV File + } + +# Variables +# $count (number) - Number of passwords +passwords-count = + { $count -> + [one] { $count } password + *[other] { $count } passwords + } + +# Variables +# $count (number) - Number of filtered passwords +# $total (number) - Total number of passwords +passwords-filtered-count = + { $total -> + [one] { $count } of { $total } password + *[other] { $count } of { $total } passwords + } + +passwords-origin-label = Website address +passwords-username-label = Username +passwords-password-label = Password + +## Payments + +payments-command-create = Add Payment Method + +payments-section-label = Payment methods +payments-disabled = Payments methods are disabled + +# Variables +# $count (number) - Number of payment methods +payments-count = + { $count -> + [one] { $count } payment method + *[other] { $count } payment methods + } + +# Variables +# $count (number) - Number of filtered payment methods +# $total (number) - Total number of payment methods +payments-filtered-count = + { $total -> + [one] { $count } of { $total } payment method + *[other] { $count } of { $total } payment methods + } + +card-number-label = Card Number +card-expiration-label = Expires on +card-holder-label = Name on Card + +## Addresses + +addresses-command-create = Add Address + +addresses-section-label = Addresses +addresses-disabled = Addresses are disabled + +# Variables +# $count (number) - Number of addresses +addresses-count = + { $count -> + [one] { $count } address + *[other] { $count } addresses + } + +# Variables +# $count (number) - Number of filtered addresses +# $total (number) - Total number of addresses +addresses-filtered-count = + { $total -> + [one] { $count } of { $total } address + *[other] { $count } of { $total } addresses + } + +address-name-label = Name +address-phone-label = Phone +address-email-label = Email diff --git a/toolkit/components/satchel/megalist/content/megalist.html b/toolkit/components/satchel/megalist/content/megalist.html new file mode 100644 index 0000000000..6ff3f089fc --- /dev/null +++ b/toolkit/components/satchel/megalist/content/megalist.html @@ -0,0 +1,78 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" + /> + <script + type="module" + src="chrome://global/content/megalist/MegalistView.mjs" + ></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="localization" href="preview/megalist.ftl" /> + </head> + + <body> + <megalist-view></megalist-view> + + <template id="lineElement"> + <li class="line"> + <div class="content"></div> + <div class="menuButton"> + <button>…</button> + </div> + </li> + </template> + + <template id="collapsedSectionTemplate"> + <div class="content section"> + <img + class="icon collapsed" + draggable="false" + src="chrome://global/skin/icons/arrow-down.svg" + /> + <h4 class="label"></h4> + </div> + </template> + + <template id="expandedSectionTemplate"> + <div class="content section"> + <img + class="icon expanded" + draggable="false" + src="chrome://global/skin/icons/arrow-up.svg" + /> + <h4 class="label"></h4> + <div class="value"></div> + </div> + </template> + + <template id="lineTemplate"> + <div class="content"> + <div class="label"></div> + <div class="value"> + <img class="icon" /> + <span></span> + </div> + <div class="stickers"></div> + </div> + </template> + + <template id="editingLineTemplate"> + <div class="content"> + <div class="label"></div> + <div class="value"> + <img class="icon" /> + <input type="text" /> + </div> + <div class="stickers"></div> + </div> + </template> + </body> +</html> diff --git a/toolkit/components/satchel/megalist/content/search-input.mjs b/toolkit/components/satchel/megalist/content/search-input.mjs new file mode 100644 index 0000000000..e30d13ef2a --- /dev/null +++ b/toolkit/components/satchel/megalist/content/search-input.mjs @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export default class SearchInput extends MozLitElement { + static get properties() { + return { + items: { type: Array }, + change: { type: Function }, + value: { type: String }, + }; + } + + render() { + return html` <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/megalist/content/tests/chrome/chrome.toml b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml new file mode 100644 index 0000000000..2d7fd6bccd --- /dev/null +++ b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_virtualized_list.html"] diff --git a/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html new file mode 100644 index 0000000000..65ddbcc40b --- /dev/null +++ b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html @@ -0,0 +1,125 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>VirtualizedList Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" href="chrome://global/content/megalist/megalist.css"> + <script type="module" src="chrome://global/content/megalist/VirtualizedList.mjs"></script> +</head> +<body> + <style> + </style> +<p id="display"></p> +<div id="content"> + <virtualized-list></virtualized-list> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + const virtualizedList = document.querySelector("virtualized-list"); + + function dispatchScrollEvent(target, scrollY) { + target.scrollTop = scrollY; + virtualizedList.dispatchEvent(new Event('scroll')); + } + + function updateVisibleItemBoundaries(visibleItemCount, value) { + if (value > visibleItemCount.max) { + visibleItemCount.max = value; + } + } + + // Setup + virtualizedList.lineHeight = 64; + virtualizedList.lineCount = 1000; + virtualizedList.selectedIndex = 0; + virtualizedList.createLineElement = index => { + const lineElement = document.createElement("div"); + lineElement.classList.add("line"); + lineElement.textContent = `Row ${index}`; + return lineElement; + } + + virtualizedList.style.display = "block"; + virtualizedList.style.height = "300px"; + virtualizedList.style.width = "500px"; + + /** + * Tests that the virtualized list renders expected number of items + */ + + add_task(async function test_rebuildVisibleLines() { + let container = virtualizedList.querySelector(".lines-container"); + let initialLines = container.querySelectorAll(".line"); + // Get boundaries of visible item count as they are rendered. + let visibleItemsCount = { + min: initialLines.length, + max: initialLines.length, + }; + + is( + container.style.height, + `${virtualizedList.lineHeight * virtualizedList.lineCount}px`, + "VirtualizedList is correct height." + ); + + // Scroll down 800px + dispatchScrollEvent(virtualizedList, 800); + let newRenderedLines = container.querySelectorAll(".line"); + updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length); + let firstRow = container.querySelector(".line[data-index='0']"); + ok(!firstRow, "The first row should be removed."); + + // Scroll down another 800px + dispatchScrollEvent(virtualizedList, 800); + newRenderedLines = container.querySelectorAll(".line"); + updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length); + let thirdRow = container.querySelector(".line[data-index='2']"); + ok(!thirdRow, "The third row should be removed."); + + // Check that amount of visible lines is within boundaries. This is to + // ensure the list is keeping a range of rendered items and + // not increasing the element count in the DOM. + ok( + newRenderedLines.length >= visibleItemsCount.min && + newRenderedLines.length <= visibleItemsCount.max, + "Virtual list is removing and adding lines as needed." + ); + + // Scroll back to top + dispatchScrollEvent(virtualizedList, 0); + newRenderedLines = container.querySelectorAll(".line"); + updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length); + firstRow = container.querySelector(".line[data-index='0']"); + thirdRow = container.querySelector(".line[data-index='2']"); + ok(firstRow, "The first row should be rendered again."); + ok(firstRow, "The third row should be rendered again."); + }); + + /** + * Tests that item selection is preserved when list is rebuilt + */ + add_task(async function test_updateLineSelection() { + let container = virtualizedList.querySelector(".lines-container"); + let selectedLine = container.querySelector(".selected"); + is(selectedLine.dataset.index, "0", "The correct line is selected"); + + // Scroll down 800px + dispatchScrollEvent(virtualizedList, 800); + selectedLine = container.querySelector(".selected"); + ok(!selectedLine, "Selected line is not rendered because it's out of view"); + is(virtualizedList.selectedIndex, 0, "Selected line is still preserved in list."); + + // Scroll back to top + dispatchScrollEvent(virtualizedList, 0); + selectedLine = container.querySelector(".selected"); + is(selectedLine.dataset.index, "0", "The same selected line is rendered."); + }); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/components/satchel/megalist/moz.build b/toolkit/components/satchel/megalist/moz.build new file mode 100644 index 0000000000..266281a9a8 --- /dev/null +++ b/toolkit/components/satchel/megalist/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOCHITEST_CHROME_MANIFESTS += ["content/tests/chrome/chrome.toml"] + +DIRS += [ + "aggregator", +] + +EXTRA_JS_MODULES["megalist"] += [ + "MegalistViewModel.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "MegalistChild.sys.mjs", + "MegalistParent.sys.mjs", +] diff --git a/toolkit/components/satchel/moz.build b/toolkit/components/satchel/moz.build index 4b6d08cdbf..fc5cabecd7 100644 --- a/toolkit/components/satchel/moz.build +++ b/toolkit/components/satchel/moz.build @@ -12,8 +12,8 @@ XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] XPIDL_SOURCES += [ - "nsIFormAutoComplete.idl", "nsIFormFillController.idl", + "nsIFormHistoryAutoComplete.idl", ] XPIDL_MODULE = "satchel" @@ -26,10 +26,16 @@ LOCAL_INCLUDES += [ "../build", ] +JAR_MANIFESTS += ["jar.mn"] + +DIRS += [ + "megalist", +] + EXTRA_JS_MODULES += [ "FillHelpers.sys.mjs", - "FormAutoComplete.sys.mjs", "FormHistory.sys.mjs", + "FormHistoryAutoComplete.sys.mjs", "FormHistoryStartup.sys.mjs", "FormScenarios.sys.mjs", "integrations/FirefoxRelay.sys.mjs", diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp index 1bcbde08df..61d23d157c 100644 --- a/toolkit/components/satchel/nsFormFillController.cpp +++ b/toolkit/components/satchel/nsFormFillController.cpp @@ -23,7 +23,7 @@ #include "mozilla/Services.h" #include "mozilla/StaticPrefs_ui.h" #include "nsCRT.h" -#include "nsIFormAutoComplete.h" +#include "nsIFormHistoryAutoComplete.h" #include "nsString.h" #include "nsPIDOMWindow.h" #include "nsIAutoCompleteResult.h" @@ -47,12 +47,13 @@ using mozilla::LogLevel; static mozilla::LazyLogModule sLogger("satchel"); -static nsIFormAutoComplete* GetFormAutoComplete() { - static nsCOMPtr<nsIFormAutoComplete> sInstance; +static nsIFormHistoryAutoComplete* GetFormHistoryAutoComplete() { + static nsCOMPtr<nsIFormHistoryAutoComplete> sInstance; static bool sInitialized = false; if (!sInitialized) { nsresult rv; - sInstance = do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv); + sInstance = + do_GetService("@mozilla.org/satchel/form-history-autocomplete;1", &rv); if (NS_SUCCEEDED(rv)) { ClearOnShutdown(&sInstance); @@ -64,14 +65,14 @@ static nsIFormAutoComplete* GetFormAutoComplete() { NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mLoginManagerAC, mFocusedPopup, mPopups, mLastListener, - mLastFormAutoComplete) + mLastFormHistoryAutoComplete) NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsFormFillController) NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIFormFillController) NS_INTERFACE_MAP_ENTRY(nsIFormFillController) NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteInput) NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteSearch) - NS_INTERFACE_MAP_ENTRY(nsIFormAutoCompleteObserver) + NS_INTERFACE_MAP_ENTRY(nsIFormFillCompleteObserver) NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) NS_INTERFACE_MAP_ENTRY(nsIObserver) NS_INTERFACE_MAP_ENTRY(nsIMutationObserver) @@ -705,13 +706,13 @@ nsFormFillController::StartSearch(const nsAString& aSearchString, MaybeObserveDataListMutations(); } - auto formAutoComplete = GetFormAutoComplete(); - NS_ENSURE_TRUE(formAutoComplete, NS_ERROR_FAILURE); + auto* formHistoryAutoComplete = GetFormHistoryAutoComplete(); + NS_ENSURE_TRUE(formHistoryAutoComplete, NS_ERROR_FAILURE); - formAutoComplete->AutoCompleteSearchAsync(aSearchParam, aSearchString, - mFocusedInput, aPreviousResult, - addDataList, this); - mLastFormAutoComplete = formAutoComplete; + formHistoryAutoComplete->AutoCompleteSearchAsync( + aSearchParam, aSearchString, mFocusedInput, aPreviousResult, + addDataList, this); + mLastFormHistoryAutoComplete = formHistoryAutoComplete; } return NS_OK; @@ -760,10 +761,10 @@ void nsFormFillController::RevalidateDataList() { NS_IMETHODIMP nsFormFillController::StopSearch() { // Make sure to stop and clear this, otherwise the controller will prevent - // mLastFormAutoComplete from being deleted. - if (mLastFormAutoComplete) { - mLastFormAutoComplete->StopAutoCompleteSearch(); - mLastFormAutoComplete = nullptr; + // mLastFormHistoryAutoComplete from being deleted. + if (mLastFormHistoryAutoComplete) { + mLastFormHistoryAutoComplete->StopAutoCompleteSearch(); + mLastFormHistoryAutoComplete = nullptr; } if (mLoginManagerAC) { @@ -773,7 +774,7 @@ nsFormFillController::StopSearch() { } //////////////////////////////////////////////////////////////////////// -//// nsIFormAutoCompleteObserver +//// nsIFormFillCompleteObserver NS_IMETHODIMP nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult* aResult) { diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h index eef6addb7a..239c293352 100644 --- a/toolkit/components/satchel/nsFormFillController.h +++ b/toolkit/components/satchel/nsFormFillController.h @@ -13,7 +13,7 @@ #include "nsIAutoCompleteController.h" #include "nsIAutoCompletePopup.h" #include "nsIDOMEventListener.h" -#include "nsIFormAutoComplete.h" +#include "nsIFormHistoryAutoComplete.h" #include "nsCOMPtr.h" #include "nsStubMutationObserver.h" #include "nsTHashMap.h" @@ -37,7 +37,7 @@ class HTMLInputElement; class nsFormFillController final : public nsIFormFillController, public nsIAutoCompleteInput, public nsIAutoCompleteSearch, - public nsIFormAutoCompleteObserver, + public nsIFormFillCompleteObserver, public nsIDOMEventListener, public nsIObserver, public nsMultiMutationObserver { @@ -46,7 +46,7 @@ class nsFormFillController final : public nsIFormFillController, NS_DECL_NSIFORMFILLCONTROLLER NS_DECL_NSIAUTOCOMPLETESEARCH NS_DECL_NSIAUTOCOMPLETEINPUT - NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER + NS_DECL_NSIFORMFILLCOMPLETEOBSERVER NS_DECL_NSIDOMEVENTLISTENER NS_DECL_NSIOBSERVER NS_DECL_NSIMUTATIONOBSERVER @@ -122,7 +122,7 @@ class nsFormFillController final : public nsIFormFillController, nsCOMPtr<nsIAutoCompleteObserver> mLastListener; // This is cleared by StopSearch(). - nsCOMPtr<nsIFormAutoComplete> mLastFormAutoComplete; + nsCOMPtr<nsIFormHistoryAutoComplete> mLastFormHistoryAutoComplete; nsString mLastSearchString; nsTHashMap<nsPtrHashKey<const nsINode>, bool> mPwmgrInputs; diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl index 25bd2d6738..24d9bf8193 100644 --- a/toolkit/components/satchel/nsIFormFillController.idl +++ b/toolkit/components/satchel/nsIFormFillController.idl @@ -5,6 +5,7 @@ #include "nsISupports.idl" interface nsIAutoCompletePopup; +interface nsIAutoCompleteResult; webidl Document; webidl Element; @@ -67,3 +68,16 @@ interface nsIFormFillController : nsISupports */ [can_run_script] void showPopup(); }; + +[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)] +interface nsIFormFillCompleteObserver : nsISupports +{ + /* + * Called when a search is complete and the results are ready even if the + * result set is empty. If the search is cancelled or a new search is + * started, this is not called. + * + * @param result - The search result object + */ + [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result); +}; diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormHistoryAutoComplete.idl index cc40872dd3..279b09f51e 100644 --- a/toolkit/components/satchel/nsIFormAutoComplete.idl +++ b/toolkit/components/satchel/nsIFormHistoryAutoComplete.idl @@ -6,13 +6,13 @@ #include "nsISupports.idl" interface nsIAutoCompleteResult; -interface nsIFormAutoCompleteObserver; +interface nsIFormFillCompleteObserver; interface nsIPropertyBag2; webidl HTMLInputElement; [scriptable, uuid(bfd9b82b-0ab3-4b6b-9e54-aa961ff4b732)] -interface nsIFormAutoComplete: nsISupports { +interface nsIFormHistoryAutoComplete: nsISupports { /** * Generate results for a form input autocomplete menu asynchronously. */ @@ -20,8 +20,8 @@ interface nsIFormAutoComplete: nsISupports { in AString aSearchString, in HTMLInputElement aField, in nsIAutoCompleteResult aPreviousResult, - in bool aAddDatalist, - in nsIFormAutoCompleteObserver aListener); + in boolean aAddDatalist, + in nsIFormFillCompleteObserver aListener); /** * If a search is in progress, stop it. Otherwise, do nothing. This is used @@ -29,16 +29,3 @@ interface nsIFormAutoComplete: nsISupports { */ void stopAutoCompleteSearch(); }; - -[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)] -interface nsIFormAutoCompleteObserver : nsISupports -{ - /* - * Called when a search is complete and the results are ready even if the - * result set is empty. If the search is cancelled or a new search is - * started, this is not called. - * - * @param result - The search result object - */ - [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result); -}; diff --git a/toolkit/components/satchel/test/test_capture_limit.html b/toolkit/components/satchel/test/test_capture_limit.html index 8591544016..61b0a98418 100644 --- a/toolkit/components/satchel/test/test_capture_limit.html +++ b/toolkit/components/satchel/test/test_capture_limit.html @@ -21,7 +21,7 @@ add_setup(async () => { }); add_task(async function captureLimit() { - // Capture no more than 100 fields per submit. See FormHistoryChild.jsm. + // Capture no more than 100 fields per submit. See FormHistoryChild.sys.mjs. const inputsCount = 100 + 2; const form = document.getElementById("form1"); for (let i = 1; i <= inputsCount; i++) { diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js index 13f66eb0f2..e64d34ea50 100644 --- a/toolkit/components/satchel/test/unit/test_autocomplete.js +++ b/toolkit/components/satchel/test/unit/test_autocomplete.js @@ -39,8 +39,8 @@ function run_test() { testfile.copyTo(profileDir, "formhistory.sqlite"); - fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].getService( - Ci.nsIFormAutoComplete + fac = Cc["@mozilla.org/satchel/form-history-autocomplete;1"].getService( + Ci.nsIFormHistoryAutoComplete ); timeGroupingSize = |