summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/megalist
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel/megalist')
-rw-r--r--toolkit/components/satchel/megalist/MegalistChild.sys.mjs17
-rw-r--r--toolkit/components/satchel/megalist/MegalistParent.sys.mjs27
-rw-r--r--toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs291
-rw-r--r--toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs78
-rw-r--r--toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs17
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs258
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs339
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs291
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs472
-rw-r--r--toolkit/components/satchel/megalist/aggregator/moz.build17
-rw-r--r--toolkit/components/satchel/megalist/content/MegalistView.mjs477
-rw-r--r--toolkit/components/satchel/megalist/content/VirtualizedList.mjs136
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.css208
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.ftl126
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.html78
-rw-r--r--toolkit/components/satchel/megalist/content/search-input.mjs36
-rw-r--r--toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml3
-rw-r--r--toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html125
-rw-r--r--toolkit/components/satchel/megalist/moz.build20
19 files changed, 3016 insertions, 0 deletions
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",
+]