manageDialog.mjs (12349B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const { AppConstants } = ChromeUtils.importESModule( 6 "resource://gre/modules/AppConstants.sys.mjs" 7 ); 8 const { FormAutofill } = ChromeUtils.importESModule( 9 "resource://autofill/FormAutofill.sys.mjs" 10 ); 11 const { AutofillTelemetry } = ChromeUtils.importESModule( 12 "resource://gre/modules/shared/AutofillTelemetry.sys.mjs" 13 ); 14 15 const lazy = {}; 16 ChromeUtils.defineESModuleGetters(lazy, { 17 CreditCard: "resource://gre/modules/CreditCard.sys.mjs", 18 FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", 19 formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", 20 FormAutofillPreferences: 21 "resource://autofill/FormAutofillPreferences.sys.mjs", 22 }); 23 24 ChromeUtils.defineLazyGetter(lazy, "log", () => 25 FormAutofill.defineLogGetter(lazy, "manageAddresses") 26 ); 27 28 ChromeUtils.defineLazyGetter( 29 lazy, 30 "l10n", 31 () => new Localization([" browser/preferences/formAutofill.ftl"], true) 32 ); 33 34 class ManageRecords { 35 constructor(subStorageName, elements) { 36 this._storageInitPromise = lazy.formAutofillStorage.initialize(); 37 this._subStorageName = subStorageName; 38 this._elements = elements; 39 this._newRequest = false; 40 this._isLoadingRecords = false; 41 this.prefWin = window.opener; 42 window.addEventListener("load", this, { once: true }); 43 } 44 45 async init() { 46 await this.loadRecords(); 47 this.attachEventListeners(); 48 // For testing only: Notify when the dialog is ready for interaction 49 window.dispatchEvent(new CustomEvent("FormReadyForTests")); 50 } 51 52 uninit() { 53 lazy.log.debug("uninit"); 54 this.detachEventListeners(); 55 this._elements = null; 56 } 57 58 /** 59 * Get the selected options on the addresses element. 60 * 61 * @returns {Array<DOMElement>} 62 */ 63 get _selectedOptions() { 64 return Array.from(this._elements.records.selectedOptions); 65 } 66 67 /** 68 * Get storage and ensure it has been initialized. 69 * 70 * @returns {object} 71 */ 72 async getStorage() { 73 await this._storageInitPromise; 74 return lazy.formAutofillStorage[this._subStorageName]; 75 } 76 77 /** 78 * Load records and render them. This function is a wrapper for _loadRecords 79 * to ensure any reentrant will be handled well. 80 */ 81 async loadRecords() { 82 // This function can be early returned when there is any reentrant happends. 83 // "_newRequest" needs to be set to ensure all changes will be applied. 84 if (this._isLoadingRecords) { 85 this._newRequest = true; 86 return; 87 } 88 this._isLoadingRecords = true; 89 90 await this._loadRecords(); 91 92 // _loadRecords should be invoked again if there is any multiple entrant 93 // during running _loadRecords(). This step ensures that the latest request 94 // still is applied. 95 while (this._newRequest) { 96 this._newRequest = false; 97 await this._loadRecords(); 98 } 99 this._isLoadingRecords = false; 100 101 // For testing only: Notify when records are loaded 102 this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded")); 103 } 104 105 async _loadRecords() { 106 let storage = await this.getStorage(); 107 let records = await storage.getAll(); 108 // Sort by last used time starting with most recent 109 records.sort((a, b) => { 110 let aLastUsed = a.timeLastUsed || a.timeLastModified; 111 let bLastUsed = b.timeLastUsed || b.timeLastModified; 112 return bLastUsed - aLastUsed; 113 }); 114 await this.renderRecordElements(records); 115 this.updateButtonsStates(this._selectedOptions.length); 116 } 117 118 /** 119 * Render the records onto the page while maintaining selected options if 120 * they still exist. 121 * 122 * @param {Array<object>} records 123 */ 124 async renderRecordElements(records) { 125 let selectedGuids = this._selectedOptions.map(option => option.value); 126 this.clearRecordElements(); 127 for (let record of records) { 128 let { id, args, raw } = await this.getLabelInfo(record); 129 let option = new Option( 130 raw ?? "", 131 record.guid, 132 false, 133 selectedGuids.includes(record.guid) 134 ); 135 if (id) { 136 document.l10n.setAttributes(option, id, args); 137 } 138 139 option.record = record; 140 this._elements.records.appendChild(option); 141 } 142 } 143 144 /** 145 * Remove all existing record elements. 146 */ 147 clearRecordElements() { 148 const parentElement = this._elements.records; 149 while (parentElement.lastChild) { 150 parentElement.removeChild(parentElement.lastChild); 151 } 152 } 153 154 /** 155 * Remove records by selected options. 156 * 157 * @param {Array<DOMElement>} options 158 */ 159 async removeRecords(options) { 160 let storage = await this.getStorage(); 161 // Pause listening to storage change event to avoid triggering `loadRecords` 162 // when removing records 163 Services.obs.removeObserver(this, "formautofill-storage-changed"); 164 165 for (let option of options) { 166 storage.remove(option.value); 167 option.remove(); 168 } 169 this.updateButtonsStates(this._selectedOptions); 170 171 // Resume listening to storage change event 172 Services.obs.addObserver(this, "formautofill-storage-changed"); 173 // For testing only: notify record(s) has been removed 174 this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved")); 175 176 for (let i = 0; i < options.length; i++) { 177 AutofillTelemetry.recordManageEvent(this.telemetryType, "delete"); 178 } 179 } 180 181 /** 182 * Enable/disable the Edit and Remove buttons based on number of selected 183 * options. 184 * 185 * @param {number} selectedCount 186 */ 187 updateButtonsStates(selectedCount) { 188 lazy.log.debug("updateButtonsStates:", selectedCount); 189 if (selectedCount == 0) { 190 this._elements.edit.setAttribute("disabled", "disabled"); 191 this._elements.remove.setAttribute("disabled", "disabled"); 192 } else if (selectedCount == 1) { 193 this._elements.edit.removeAttribute("disabled"); 194 this._elements.remove.removeAttribute("disabled"); 195 } else if (selectedCount > 1) { 196 this._elements.edit.setAttribute("disabled", "disabled"); 197 this._elements.remove.removeAttribute("disabled"); 198 } 199 this._elements.add.disabled = !Services.prefs.getBoolPref( 200 `extensions.formautofill.${this._subStorageName}.enabled` 201 ); 202 } 203 204 /** 205 * Handle events 206 * 207 * @param {DOMEvent} event 208 */ 209 handleEvent(event) { 210 switch (event.type) { 211 case "load": { 212 this.init(); 213 break; 214 } 215 case "click": { 216 this.handleClick(event); 217 break; 218 } 219 case "change": { 220 this.updateButtonsStates(this._selectedOptions.length); 221 break; 222 } 223 case "unload": { 224 this.uninit(); 225 break; 226 } 227 case "keypress": { 228 this.handleKeyPress(event); 229 break; 230 } 231 case "contextmenu": { 232 event.preventDefault(); 233 break; 234 } 235 } 236 } 237 238 /** 239 * Handle click events 240 * 241 * @param {DOMEvent} event 242 */ 243 handleClick(event) { 244 if (event.target == this._elements.remove) { 245 this.removeRecords(this._selectedOptions); 246 } else if (event.target == this._elements.add) { 247 this.openEditDialog(); 248 } else if ( 249 event.target == this._elements.edit || 250 (event.target.parentNode == this._elements.records && event.detail > 1) 251 ) { 252 this.openEditDialog(this._selectedOptions[0].record); 253 } 254 } 255 256 /** 257 * Handle key press events 258 * 259 * @param {DOMEvent} event 260 */ 261 handleKeyPress(event) { 262 if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { 263 window.close(); 264 } 265 if (event.keyCode == KeyEvent.DOM_VK_DELETE) { 266 this.removeRecords(this._selectedOptions); 267 } 268 } 269 270 observe(_subject, topic, _data) { 271 switch (topic) { 272 case "formautofill-storage-changed": { 273 this.loadRecords(); 274 } 275 } 276 } 277 278 /** 279 * Attach event listener 280 */ 281 attachEventListeners() { 282 window.addEventListener("unload", this, { once: true }); 283 window.addEventListener("keypress", this); 284 window.addEventListener("contextmenu", this); 285 this._elements.records.addEventListener("change", this); 286 this._elements.records.addEventListener("click", this); 287 this._elements.controlsContainer.addEventListener("click", this); 288 Services.obs.addObserver(this, "formautofill-storage-changed"); 289 } 290 291 /** 292 * Remove event listener 293 */ 294 detachEventListeners() { 295 window.removeEventListener("keypress", this); 296 window.removeEventListener("contextmenu", this); 297 this._elements.records.removeEventListener("change", this); 298 this._elements.records.removeEventListener("click", this); 299 this._elements.controlsContainer.removeEventListener("click", this); 300 Services.obs.removeObserver(this, "formautofill-storage-changed"); 301 } 302 } 303 304 export class ManageAddresses extends ManageRecords { 305 telemetryType = AutofillTelemetry.ADDRESS; 306 307 constructor(elements) { 308 super("addresses", elements); 309 elements.add.setAttribute( 310 "search-l10n-ids", 311 lazy.FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",") 312 ); 313 AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); 314 } 315 316 static getAddressL10nStrings() { 317 const l10nIds = [ 318 ...lazy.FormAutofillUtils.MANAGE_ADDRESSES_L10N_IDS, 319 ...lazy.FormAutofillUtils.EDIT_ADDRESS_L10N_IDS, 320 ]; 321 322 return l10nIds.reduce( 323 (acc, id) => ({ 324 ...acc, 325 [id]: lazy.l10n.formatValueSync(id), 326 }), 327 {} 328 ); 329 } 330 331 /** 332 * Open the edit address dialog to create/edit an address. 333 * 334 * @param {object} address [optional] 335 */ 336 openEditDialog(address) { 337 return lazy.FormAutofillPreferences.openEditAddressDialog( 338 address, 339 this.prefWin 340 ); 341 } 342 343 getLabelInfo(address) { 344 return { raw: lazy.FormAutofillUtils.getAddressLabel(address) }; 345 } 346 } 347 348 export class ManageCreditCards extends ManageRecords { 349 telemetryType = AutofillTelemetry.CREDIT_CARD; 350 351 constructor(elements) { 352 super("creditCards", elements); 353 elements.add.setAttribute( 354 "search-l10n-ids", 355 lazy.FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",") 356 ); 357 358 this._isDecrypted = false; 359 AutofillTelemetry.recordManageEvent(this.telemetryType, "show"); 360 } 361 362 /** 363 * Open the edit address dialog to create/edit a credit card. 364 * 365 * @param {object} creditCard [optional] 366 */ 367 async openEditDialog(creditCard) { 368 return lazy.FormAutofillPreferences.openEditCreditCardDialog( 369 creditCard, 370 this.prefWin 371 ); 372 } 373 374 /** 375 * Get credit card display label. It should display masked numbers and the 376 * cardholder's name, separated by a comma. 377 * 378 * @param {object} creditCard 379 * @returns {Promise<string>} 380 */ 381 async getLabelInfo(creditCard) { 382 // The card type is displayed visually using an image. For a11y, we need 383 // to expose it as text. We do this using aria-label. However, 384 // aria-label overrides the text content, so we must include that also. 385 // Since the text content is generated by Fluent, aria-label must be 386 // generated by Fluent also. 387 const type = creditCard["cc-type"]; 388 const typeL10nId = lazy.CreditCard.getNetworkL10nId(type); 389 const typeName = typeL10nId 390 ? await document.l10n.formatValue(typeL10nId) 391 : (type ?? ""); // Unknown card type 392 return lazy.CreditCard.getLabelInfo({ 393 name: creditCard["cc-name"], 394 number: creditCard["cc-number"], 395 month: creditCard["cc-exp-month"], 396 year: creditCard["cc-exp-year"], 397 type: typeName, 398 }); 399 } 400 401 async renderRecordElements(records) { 402 // Revert back to encrypted form when re-rendering happens 403 this._isDecrypted = false; 404 // Display third-party card icons when possible 405 this._elements.records.classList.toggle( 406 "branded", 407 AppConstants.MOZILLA_OFFICIAL 408 ); 409 await super.renderRecordElements(records); 410 411 let options = this._elements.records.options; 412 for (let option of options) { 413 let record = option.record; 414 if (record && record["cc-type"]) { 415 option.setAttribute("cc-type", record["cc-type"]); 416 } else { 417 option.removeAttribute("cc-type"); 418 } 419 } 420 } 421 422 updateButtonsStates(selectedCount) { 423 super.updateButtonsStates(selectedCount); 424 } 425 426 handleClick(event) { 427 super.handleClick(event); 428 } 429 }