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

+
+ +
+
+
+
+ + diff --git a/toolkit/components/satchel/megalist/moz.build b/toolkit/components/satchel/megalist/moz.build new file mode 100644 index 0000000000..266281a9a8 --- /dev/null +++ b/toolkit/components/satchel/megalist/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOCHITEST_CHROME_MANIFESTS += ["content/tests/chrome/chrome.toml"] + +DIRS += [ + "aggregator", +] + +EXTRA_JS_MODULES["megalist"] += [ + "MegalistViewModel.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "MegalistChild.sys.mjs", + "MegalistParent.sys.mjs", +] -- cgit v1.2.3