translations.js (65593B)
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 // @ts-check 6 7 "use strict"; 8 9 /* import-globals-from main.js */ 10 11 /** 12 * @import { 13 * TranslationsSettingsElements, 14 * SupportedLanguages, 15 * LanguageInfo 16 * } from "./translations" 17 */ 18 19 /** @type {string} */ 20 const ALWAYS_TRANSLATE_LANGS_PREF = 21 "browser.translations.alwaysTranslateLanguages"; 22 /** @type {string} */ 23 const NEVER_TRANSLATE_LANGS_PREF = 24 "browser.translations.neverTranslateLanguages"; 25 /** @type {string} */ 26 const TOPIC_TRANSLATIONS_PREF_CHANGED = "translations:pref-changed"; 27 /** @type {string} */ 28 const TRANSLATIONS_PERMISSION = "translations"; 29 30 /** @type {string} */ 31 const ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS = 32 "translations-always-translate-language-item"; 33 /** @type {string} */ 34 const ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS = 35 "translations-always-translate-remove-button"; 36 37 /** @type {string} */ 38 const NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS = 39 "translations-never-translate-language-item"; 40 /** @type {string} */ 41 const NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS = 42 "translations-never-translate-remove-button"; 43 /** @type {string} */ 44 const NEVER_TRANSLATE_SITE_ITEM_CLASS = 45 "translations-never-translate-site-item"; 46 /** @type {string} */ 47 const NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS = 48 "translations-never-translate-site-remove-button"; 49 50 /** @type {string} */ 51 const DOWNLOAD_LANGUAGE_ITEM_CLASS = "translations-download-language-item"; 52 /** @type {string} */ 53 const DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS = 54 "translations-download-remove-button"; 55 /** @type {string} */ 56 const DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS = 57 "translations-download-retry-button"; 58 /** @type {string} */ 59 const DOWNLOAD_LANGUAGE_FAILED_CLASS = "translations-download-language-error"; 60 /** @type {string} */ 61 const DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS = 62 "translations-download-delete-confirm-button"; 63 /** @type {string} */ 64 const DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS = 65 "translations-download-delete-cancel-button"; 66 /** @type {string} */ 67 const DOWNLOAD_LOADING_ICON = "chrome://global/skin/icons/loading.svg"; 68 /** @type {string} */ 69 const DOWNLOAD_DELETE_ICON = "chrome://global/skin/icons/delete.svg"; 70 /** @type {string} */ 71 const DOWNLOAD_ERROR_ICON = "chrome://global/skin/icons/error.svg"; 72 /** @type {string} */ 73 const DOWNLOAD_WARNING_ICON = "chrome://global/skin/icons/warning.svg"; 74 75 /** 76 * Dispatches a test-only event when running under automation. 77 * 78 * @param {string} name - Event name without the "TranslationsSettingsTest:" prefix. 79 * @param {object} [detail] - Optional event detail. 80 */ 81 function dispatchTestEvent(name, detail) { 82 if (!globalThis.Cu?.isInAutomation) { 83 return; 84 } 85 const options = detail ? { detail } : undefined; 86 document.dispatchEvent( 87 new CustomEvent(`TranslationsSettingsTest:${name}`, options) 88 ); 89 } 90 91 const TranslationsSettings = { 92 /** 93 * True once initialization has completed. 94 * 95 * @type {boolean} 96 */ 97 initialized: false, 98 99 /** 100 * Promise guarding full initialization to avoid re-entry. 101 * 102 * @type {Promise<void>|null} 103 */ 104 initPromise: null, 105 106 /** 107 * Promise cached after the pane/group finish rendering. 108 * 109 * @type {Promise<void>|null} 110 */ 111 paneRenderPromise: null, 112 113 /** 114 * Supported languages fetched from TranslationsParent. 115 * 116 * @type {SupportedLanguages|null} 117 */ 118 supportedLanguages: null, 119 120 /** 121 * Display names for supported languages. 122 * 123 * @type {Intl.DisplayNames|null} 124 */ 125 languageDisplayNames: null, 126 127 /** 128 * Language metadata used to build labels and selectors. 129 * 130 * @type {LanguageInfo[]|null} 131 */ 132 languageList: null, 133 134 /** 135 * Download sizes keyed by language tag. 136 * 137 * @type {Map<string, number>|null} 138 */ 139 languageSizes: null, 140 141 /** 142 * Formatter used for download size labels. 143 * 144 * @type {Intl.NumberFormat|null} 145 */ 146 numberFormatter: null, 147 148 /** 149 * Current always-translate language tags. 150 * 151 * @type {Set<string>} 152 */ 153 alwaysTranslateLanguageTags: new Set(), 154 155 /** 156 * Current never-translate language tags. 157 * 158 * @type {Set<string>} 159 */ 160 neverTranslateLanguageTags: new Set(), 161 162 /** 163 * Current never-translate site origins. 164 * 165 * @type {Set<string>} 166 */ 167 neverTranslateSiteOrigins: new Set(), 168 169 /** 170 * Language tags with downloaded translation models. 171 * 172 * @type {Set<string>} 173 */ 174 downloadedLanguageTags: new Set(), 175 176 /** 177 * Language tags currently downloading. 178 * 179 * @type {Set<string>} 180 */ 181 downloadingLanguageTags: new Set(), 182 183 /** 184 * Language tags that failed to download. 185 * 186 * @type {Set<string>} 187 */ 188 downloadFailedLanguageTags: new Set(), 189 190 /** 191 * Language tags pending delete confirmation. 192 * 193 * @type {Set<string>} 194 */ 195 downloadPendingDeleteLanguageTags: new Set(), 196 197 /** 198 * Language tag of the in-progress download, if any. 199 * 200 * @type {string|null} 201 */ 202 currentDownloadLangTag: null, 203 204 /** 205 * Cached DOM elements used by the module. 206 * 207 * @type {TranslationsSettingsElements|null} 208 */ 209 elements: null, 210 211 /** 212 * Handles events this object is registered for. 213 * 214 * @param {Event} event 215 */ 216 async handleEvent(event) { 217 switch (event.type) { 218 case "paneshown": 219 await this.handlePaneShown( 220 /** @type {CustomEvent} */ (event).detail?.category 221 ); 222 break; 223 case "change": 224 if (event.target === this.elements?.alwaysTranslateLanguagesSelect) { 225 this.onAlwaysTranslateLanguageSelectionChanged(); 226 } else if ( 227 event.target === this.elements?.neverTranslateLanguagesSelect 228 ) { 229 this.onNeverTranslateLanguageSelectionChanged(); 230 } else if (event.target === this.elements?.downloadLanguagesSelect) { 231 this.onDownloadSelectionChanged(); 232 } 233 break; 234 case "click": { 235 const target = /** @type {HTMLElement} */ (event.target); 236 if ( 237 target === this.elements?.alwaysTranslateLanguagesButton || 238 target.closest?.("#translationsAlwaysTranslateLanguagesButton") 239 ) { 240 await this.onAlwaysTranslateLanguageChosen( 241 this.elements?.alwaysTranslateLanguagesSelect?.value ?? "" 242 ); 243 break; 244 } 245 if ( 246 target === this.elements?.neverTranslateLanguagesButton || 247 target.closest?.("#translationsNeverTranslateLanguagesButton") 248 ) { 249 await this.onNeverTranslateLanguageChosen( 250 this.elements?.neverTranslateLanguagesSelect?.value ?? "" 251 ); 252 break; 253 } 254 255 if ( 256 target === this.elements?.downloadLanguagesButton || 257 target.closest?.("#translationsDownloadLanguagesButton") 258 ) { 259 this.onDownloadLanguageButtonClicked(); 260 break; 261 } 262 263 const downloadRemoveButton = /** @type {HTMLElement|null} */ ( 264 target.closest?.(`.${DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS}`) 265 ); 266 if (downloadRemoveButton?.dataset.langTag) { 267 this.onDeleteButtonClicked(downloadRemoveButton.dataset.langTag); 268 break; 269 } 270 271 const downloadDeleteConfirmButton = /** @type {HTMLElement|null} */ ( 272 target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS}`) 273 ); 274 if (downloadDeleteConfirmButton?.dataset.langTag) { 275 this.confirmDeleteLanguage( 276 downloadDeleteConfirmButton.dataset.langTag 277 ); 278 break; 279 } 280 281 const downloadDeleteCancelButton = /** @type {HTMLElement|null} */ ( 282 target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS}`) 283 ); 284 if (downloadDeleteCancelButton?.dataset.langTag) { 285 this.cancelDeleteLanguage(downloadDeleteCancelButton.dataset.langTag); 286 break; 287 } 288 289 const downloadRetryButton = /** @type {HTMLElement|null} */ ( 290 target.closest?.(`.${DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS}`) 291 ); 292 if (downloadRetryButton?.dataset.langTag) { 293 this.retryDownloadLanguage(downloadRetryButton.dataset.langTag); 294 break; 295 } 296 297 const alwaysRemoveButton = /** @type {HTMLElement|null} */ ( 298 target.closest?.(`.${ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`) 299 ); 300 if (alwaysRemoveButton?.dataset.langTag) { 301 this.removeAlwaysTranslateLanguage( 302 alwaysRemoveButton.dataset.langTag 303 ); 304 break; 305 } 306 307 const neverRemoveButton = /** @type {HTMLElement|null} */ ( 308 target.closest?.(`.${NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`) 309 ); 310 if (neverRemoveButton?.dataset.langTag) { 311 this.removeNeverTranslateLanguage(neverRemoveButton.dataset.langTag); 312 break; 313 } 314 315 const neverSiteRemoveButton = /** @type {HTMLElement|null} */ ( 316 target.closest?.(`.${NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS}`) 317 ); 318 if (neverSiteRemoveButton?.dataset.origin) { 319 this.removeNeverTranslateSite(neverSiteRemoveButton.dataset.origin); 320 } 321 break; 322 } 323 case "unload": 324 this.teardown(); 325 break; 326 } 327 }, 328 329 /** 330 * Observer for translations pref changes. 331 * 332 * @param {any} subject 333 * @param {string} topic 334 * @param {string} data 335 */ 336 observe(subject, topic, data) { 337 if (topic === TOPIC_TRANSLATIONS_PREF_CHANGED) { 338 if (data === ALWAYS_TRANSLATE_LANGS_PREF) { 339 this.refreshAlwaysTranslateLanguages().catch(console.error); 340 } else if (data === NEVER_TRANSLATE_LANGS_PREF) { 341 this.refreshNeverTranslateLanguages().catch(console.error); 342 } 343 } else if (topic === "perm-changed") { 344 this.handlePermissionChange(subject, data); 345 } 346 }, 347 348 /** 349 * Runs when the translations sub-pane is shown. 350 * 351 * @param {string} category 352 * @returns {Promise<void>} 353 */ 354 async handlePaneShown(category) { 355 if (category !== "paneTranslations") { 356 return; 357 } 358 359 if (this.initPromise) { 360 await this.initPromise; 361 await this.refreshAlwaysTranslateLanguages(); 362 await this.refreshNeverTranslateLanguages(); 363 this.refreshNeverTranslateSites(); 364 await this.refreshDownloadedLanguages(); 365 this.dispatchInitializedTestEvent(); 366 return; 367 } 368 369 if (this.initialized) { 370 await this.refreshAlwaysTranslateLanguages(); 371 await this.refreshNeverTranslateLanguages(); 372 this.refreshNeverTranslateSites(); 373 await this.refreshDownloadedLanguages(); 374 this.dispatchInitializedTestEvent(); 375 return; 376 } 377 378 this.initPromise = this.init(); 379 await this.initPromise; 380 this.initPromise = null; 381 }, 382 383 /** 384 * Ensure the translations pane has finished rendering. 385 * 386 * @returns {Promise<void>} 387 */ 388 async ensurePaneRendered() { 389 if (this.paneRenderPromise) { 390 await this.paneRenderPromise; 391 return; 392 } 393 394 /** 395 * @typedef {HTMLElement & { getUpdateComplete?: () => Promise<void> }} ElementWithUpdateComplete 396 */ 397 const pane = /** @type {ElementWithUpdateComplete|null} */ ( 398 document.querySelector('setting-pane[data-category="paneTranslations"]') 399 ); 400 const groups = Array.from( 401 document.querySelectorAll( 402 'setting-group[groupid="translationsAutomaticTranslation"], setting-group[groupid="translationsDownloadLanguages"]' 403 ) 404 ); 405 406 const promises = []; 407 if (pane?.getUpdateComplete) { 408 promises.push(pane.getUpdateComplete()); 409 } 410 for (const group of groups) { 411 if (group?.getUpdateComplete) { 412 promises.push(group.getUpdateComplete()); 413 } 414 } 415 416 if (promises.length) { 417 this.paneRenderPromise = (async () => { 418 const results = await Promise.allSettled(promises); 419 const failure = results.find(result => result.status === "rejected"); 420 if (failure && failure.reason) { 421 console.warn("Translations pane render wait failed", failure.reason); 422 } 423 })(); 424 await this.paneRenderPromise; 425 } 426 }, 427 428 /** 429 * Initialize the translations settings UI. 430 * 431 * @returns {Promise<void>} 432 */ 433 async init() { 434 await this.ensurePaneRendered(); 435 this.cacheElements(); 436 if ( 437 !this.elements?.alwaysTranslateLanguagesGroup || 438 !this.elements?.alwaysTranslateLanguagesSelect || 439 !this.elements?.alwaysTranslateLanguagesButton || 440 !this.elements?.alwaysTranslateLanguagesNoneRow || 441 !this.elements?.neverTranslateLanguagesGroup || 442 !this.elements?.neverTranslateLanguagesSelect || 443 !this.elements?.neverTranslateLanguagesButton || 444 !this.elements?.neverTranslateLanguagesNoneRow || 445 !this.elements?.neverTranslateSitesGroup || 446 !this.elements?.downloadLanguagesGroup || 447 !this.elements?.downloadLanguagesSelect || 448 !this.elements?.downloadLanguagesButton || 449 !this.elements?.downloadLanguagesNoneRow 450 ) { 451 this.dispatchInitializedTestEvent(); 452 return; 453 } 454 455 try { 456 this.numberFormatter = null; 457 this.languageDisplayNames = 458 TranslationsParent.createLanguageDisplayNames(); 459 this.supportedLanguages = 460 await TranslationsParent.getSupportedLanguages(); 461 this.languageList = TranslationsParent.getLanguageList( 462 this.supportedLanguages 463 ); 464 await this.loadLanguageSizes(); 465 await this.refreshDownloadedLanguages(); 466 } catch (error) { 467 console.error("Failed to initialize translations settings UI", error); 468 this.elements.alwaysTranslateLanguagesSelect.disabled = true; 469 this.elements.alwaysTranslateLanguagesButton.disabled = true; 470 this.elements.neverTranslateLanguagesSelect.disabled = true; 471 this.elements.neverTranslateLanguagesButton.disabled = true; 472 this.elements.downloadLanguagesSelect.disabled = true; 473 this.setDownloadLanguageButtonDisabledState(true); 474 this.dispatchInitializedTestEvent(); 475 return; 476 } 477 478 this.elements.alwaysTranslateLanguagesSelect.disabled = false; 479 this.elements.alwaysTranslateLanguagesButton.disabled = true; 480 this.elements.neverTranslateLanguagesSelect.disabled = false; 481 this.elements.neverTranslateLanguagesButton.disabled = true; 482 this.elements.downloadLanguagesSelect.disabled = false; 483 this.resetDownloadSelect(); 484 this.setDownloadLanguageButtonDisabledState(true); 485 await this.buildAlwaysTranslateSelectOptions(); 486 await this.buildNeverTranslateSelectOptions(); 487 await this.buildDownloadSelectOptions(); 488 await this.renderDownloadLanguages(); 489 490 this.elements.alwaysTranslateLanguagesSelect.addEventListener( 491 "change", 492 this 493 ); 494 this.elements.alwaysTranslateLanguagesButton.addEventListener( 495 "click", 496 this 497 ); 498 this.elements.alwaysTranslateLanguagesGroup.addEventListener("click", this); 499 this.elements.neverTranslateLanguagesSelect.addEventListener( 500 "change", 501 this 502 ); 503 this.elements.neverTranslateLanguagesButton.addEventListener("click", this); 504 this.elements.neverTranslateLanguagesGroup.addEventListener("click", this); 505 this.elements.neverTranslateSitesGroup.addEventListener("click", this); 506 this.elements.downloadLanguagesSelect.addEventListener("change", this); 507 this.elements.downloadLanguagesGroup.addEventListener("click", this); 508 this.elements.downloadLanguagesButton.addEventListener("click", this); 509 Services.obs.addObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED); 510 Services.obs.addObserver(this, "perm-changed"); 511 window.addEventListener("unload", this); 512 513 await this.refreshAlwaysTranslateLanguages(); 514 await this.refreshNeverTranslateLanguages(); 515 this.refreshNeverTranslateSites(); 516 this.initialized = true; 517 518 this.dispatchInitializedTestEvent(); 519 }, 520 521 /** 522 * Dispatch the test-only Initialized event and mark the document as ready. 523 */ 524 dispatchInitializedTestEvent() { 525 dispatchTestEvent("Initialized"); 526 }, 527 528 /** 529 * Cache the DOM elements we interact with. 530 */ 531 cacheElements() { 532 if (this.elements) { 533 return; 534 } 535 536 const elements = { 537 alwaysTranslateLanguagesGroup: /** @type {HTMLElement} */ ( 538 document.getElementById("translationsAlwaysTranslateLanguagesGroup") 539 ), 540 alwaysTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ ( 541 document.getElementById("translationsAlwaysTranslateLanguagesSelect") 542 ), 543 alwaysTranslateLanguagesButton: /** @type {HTMLButtonElement} */ ( 544 document.getElementById("translationsAlwaysTranslateLanguagesButton") 545 ), 546 alwaysTranslateLanguagesNoneRow: /** @type {HTMLElement} */ ( 547 document.getElementById("translationsAlwaysTranslateLanguagesNoneRow") 548 ), 549 neverTranslateLanguagesGroup: /** @type {HTMLElement} */ ( 550 document.getElementById("translationsNeverTranslateLanguagesGroup") 551 ), 552 neverTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ ( 553 document.getElementById("translationsNeverTranslateLanguagesSelect") 554 ), 555 neverTranslateLanguagesButton: /** @type {HTMLButtonElement} */ ( 556 document.getElementById("translationsNeverTranslateLanguagesButton") 557 ), 558 neverTranslateLanguagesNoneRow: /** @type {HTMLElement} */ ( 559 document.getElementById("translationsNeverTranslateLanguagesNoneRow") 560 ), 561 neverTranslateSitesGroup: /** @type {HTMLElement} */ ( 562 document.getElementById("translationsNeverTranslateSitesGroup") 563 ), 564 neverTranslateSitesRow: /** @type {HTMLElement} */ ( 565 document.getElementById("translationsNeverTranslateSitesRow") 566 ), 567 neverTranslateSitesNoneRow: /** @type {HTMLElement} */ ( 568 document.getElementById("translationsNeverTranslateSitesNoneRow") 569 ), 570 downloadLanguagesGroup: /** @type {HTMLElement} */ ( 571 document.getElementById("translationsDownloadLanguagesGroup") 572 ), 573 downloadLanguagesSelect: /** @type {HTMLSelectElement} */ ( 574 document.getElementById("translationsDownloadLanguagesSelect") 575 ), 576 downloadLanguagesButton: /** @type {HTMLButtonElement} */ ( 577 document.getElementById("translationsDownloadLanguagesButton") 578 ), 579 downloadLanguagesNoneRow: /** @type {HTMLElement} */ ( 580 document.getElementById("translationsDownloadLanguagesNoneRow") 581 ), 582 }; 583 584 if ( 585 !elements.alwaysTranslateLanguagesGroup || 586 !elements.alwaysTranslateLanguagesSelect || 587 !elements.alwaysTranslateLanguagesNoneRow || 588 !elements.neverTranslateLanguagesGroup || 589 !elements.neverTranslateLanguagesSelect || 590 !elements.neverTranslateLanguagesNoneRow 591 ) { 592 return; 593 } 594 595 this.elements = elements; 596 }, 597 598 /** 599 * Load the download sizes for all supported languages and cache them. 600 * 601 * @returns {Promise<void>} 602 */ 603 async loadLanguageSizes() { 604 if (!this.languageList?.length) { 605 this.languageSizes = new Map(); 606 return; 607 } 608 609 const sizes = await Promise.all( 610 this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => { 611 try { 612 return /** @type {[string, number]} */ ([ 613 langTag, 614 await TranslationsParent.getLanguageSize(langTag), 615 ]); 616 } catch (error) { 617 console.error(`Failed to get size for ${langTag}`, error); 618 return /** @type {[string, number]} */ ([langTag, 0]); 619 } 620 }) 621 ); 622 623 this.languageSizes = new Map(sizes); 624 }, 625 626 /** 627 * Format a download size for display. 628 * 629 * @param {string} langTag 630 * @returns {string|null} 631 */ 632 formatLanguageSize(langTag) { 633 const sizeBytes = this.languageSizes?.get(langTag); 634 if (!sizeBytes && sizeBytes !== 0) { 635 return null; 636 } 637 638 const sizeInMB = sizeBytes / (1024 * 1024); 639 if (!Number.isFinite(sizeInMB)) { 640 return null; 641 } 642 643 return this.getNumberFormatter().format(sizeInMB); 644 }, 645 646 /** 647 * Lazily create and return a number formatter for the app locale. 648 * 649 * @returns {Intl.NumberFormat} 650 */ 651 getNumberFormatter() { 652 if (this.numberFormatter) { 653 return this.numberFormatter; 654 } 655 this.numberFormatter = new Intl.NumberFormat( 656 Services.locale.appLocaleAsBCP47, 657 { 658 minimumFractionDigits: 0, 659 maximumFractionDigits: 1, 660 } 661 ); 662 return this.numberFormatter; 663 }, 664 665 /** 666 * Build the display label for a download language including its size. 667 * 668 * @param {string} langTag 669 * @returns {Promise<string|null>} 670 */ 671 async formatDownloadLabel(langTag) { 672 const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; 673 const sizeLabel = this.formatLanguageSize(langTag); 674 if (!sizeLabel) { 675 return languageLabel; 676 } 677 try { 678 return await document.l10n.formatValue( 679 "settings-translations-subpage-download-language-option", 680 { language: languageLabel, size: sizeLabel } 681 ); 682 } catch (error) { 683 console.error("Failed to format download language label", error); 684 return `${languageLabel} (${sizeLabel})`; 685 } 686 }, 687 688 /** 689 * Populate the select options for download languages with sizes. 690 * 691 * @returns {Promise<void>} 692 */ 693 async buildDownloadSelectOptions() { 694 const select = this.elements?.downloadLanguagesSelect; 695 if (!select || !this.supportedLanguages?.sourceLanguages?.length) { 696 return; 697 } 698 699 const placeholder = select.querySelector('moz-option[value=""]'); 700 for (const option of select.querySelectorAll("moz-option")) { 701 if (option !== placeholder) { 702 option.remove(); 703 } 704 } 705 706 const sourceLanguages = [...this.supportedLanguages.sourceLanguages] 707 .filter(({ langTag }) => langTag !== "en") 708 .sort((lhs, rhs) => 709 ( 710 this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName 711 ).localeCompare( 712 this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName 713 ) 714 ); 715 for (const { langTag, displayName } of sourceLanguages) { 716 const option = document.createElement("moz-option"); 717 option.setAttribute("value", langTag); 718 const label = 719 (await this.formatDownloadLabel(langTag)) ?? 720 this.formatLanguageLabel(langTag) ?? 721 displayName; 722 option.setAttribute("label", label); 723 const sizeLabel = this.formatLanguageSize(langTag) ?? ""; 724 if (sizeLabel) { 725 document.l10n.setAttributes( 726 option, 727 "settings-translations-subpage-download-language-option", 728 { 729 language: this.formatLanguageLabel(langTag) ?? displayName, 730 size: sizeLabel, 731 } 732 ); 733 } 734 select.appendChild(option); 735 } 736 737 this.updateDownloadSelectOptionState(); 738 this.resetDownloadSelect(); 739 }, 740 741 /** 742 * Disable already-downloaded or downloading languages in the download select. 743 */ 744 updateDownloadSelectOptionState({ preserveSelection = false } = {}) { 745 const select = this.elements?.downloadLanguagesSelect; 746 if (!select) { 747 return; 748 } 749 750 for (const option of select.querySelectorAll("moz-option")) { 751 const value = option.getAttribute("value"); 752 if (!value) { 753 continue; 754 } 755 const isDisabled = 756 this.downloadedLanguageTags.has(value) || 757 this.downloadingLanguageTags.has(value); 758 option.toggleAttribute("disabled", isDisabled); 759 } 760 761 if (preserveSelection) { 762 this.updateDownloadLanguageButtonDisabled(); 763 } else { 764 this.resetDownloadSelect(); 765 } 766 dispatchTestEvent("DownloadedLanguagesSelectOptionsUpdated"); 767 }, 768 769 /** 770 * Handle a selection in the "Always translate languages" dropdown. 771 * 772 * @param {string} langTag 773 */ 774 async onAlwaysTranslateLanguageChosen(langTag) { 775 if (!langTag) { 776 this.updateAlwaysTranslateAddButtonDisabledState(); 777 return; 778 } 779 780 if (this.shouldDisableAlwaysTranslateAddButton()) { 781 this.updateAlwaysTranslateAddButtonDisabledState(); 782 return; 783 } 784 785 TranslationsParent.addLangTagToPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); 786 TranslationsParent.removeLangTagFromPref( 787 langTag, 788 NEVER_TRANSLATE_LANGS_PREF 789 ); 790 await this.resetAlwaysTranslateSelect(); 791 }, 792 793 /** 794 * Handle a selection change in the always-translate dropdown. 795 */ 796 onAlwaysTranslateLanguageSelectionChanged() { 797 this.updateAlwaysTranslateAddButtonDisabledState(); 798 }, 799 800 /** 801 * Whether the add button for always-translate languages should be disabled. 802 * 803 * @returns {boolean} 804 */ 805 shouldDisableAlwaysTranslateAddButton() { 806 const select = this.elements?.alwaysTranslateLanguagesSelect; 807 if (!select || select.disabled) { 808 return true; 809 } 810 811 const langTag = select.value; 812 if (!langTag) { 813 return true; 814 } 815 816 const option = /** @type {HTMLElement|null} */ ( 817 select.querySelector(`moz-option[value="${langTag}"]`) 818 ); 819 return option?.hasAttribute("disabled") ?? false; 820 }, 821 822 /** 823 * Set the add button enabled state for always-translate languages. 824 * 825 * @param {boolean} isDisabled 826 */ 827 setAlwaysTranslateAddButtonDisabledState(isDisabled) { 828 if (!this.elements?.alwaysTranslateLanguagesButton) { 829 return; 830 } 831 832 const wasDisabled = this.elements.alwaysTranslateLanguagesButton.disabled; 833 this.elements.alwaysTranslateLanguagesButton.disabled = isDisabled; 834 if (wasDisabled !== isDisabled) { 835 dispatchTestEvent( 836 isDisabled 837 ? "AlwaysTranslateLanguagesAddButtonDisabled" 838 : "AlwaysTranslateLanguagesAddButtonEnabled" 839 ); 840 } 841 }, 842 843 /** 844 * Update the add button enabled state for always-translate languages. 845 */ 846 updateAlwaysTranslateAddButtonDisabledState() { 847 this.setAlwaysTranslateAddButtonDisabledState( 848 this.shouldDisableAlwaysTranslateAddButton() 849 ); 850 }, 851 852 /** 853 * Remove the given language from the always translate list. 854 * 855 * @param {string} langTag 856 */ 857 removeAlwaysTranslateLanguage(langTag) { 858 TranslationsParent.removeLangTagFromPref( 859 langTag, 860 ALWAYS_TRANSLATE_LANGS_PREF 861 ); 862 }, 863 864 async resetSelect(select, settingId) { 865 const setting = Preferences.getSetting?.(settingId); 866 if (setting) { 867 setting.value = ""; 868 } 869 870 if (!select) { 871 return; 872 } 873 874 if (select.updateComplete) { 875 await select.updateComplete; 876 } 877 878 select.value = ""; 879 if (select.inputEl) { 880 select.inputEl.value = ""; 881 } 882 883 if (select.updateComplete) { 884 await select.updateComplete; 885 } 886 }, 887 888 /** 889 * Reset the dropdown back to the placeholder value and underlying setting state. 890 */ 891 async resetAlwaysTranslateSelect() { 892 await this.resetSelect( 893 this.elements?.alwaysTranslateLanguagesSelect, 894 "translationsAlwaysTranslateLanguagesSelect" 895 ); 896 this.updateAlwaysTranslateAddButtonDisabledState(); 897 }, 898 899 /** 900 * Refresh the rendered list of always-translate languages to match prefs. 901 */ 902 async refreshAlwaysTranslateLanguages() { 903 if (!this.elements?.alwaysTranslateLanguagesGroup) { 904 return; 905 } 906 907 const langTags = Array.from( 908 TranslationsParent.getAlwaysTranslateLanguages?.() ?? [] 909 ); 910 911 if (this.alwaysTranslateLanguageTags) { 912 for (const langTag of langTags) { 913 if (this.alwaysTranslateLanguageTags.has(langTag)) { 914 continue; 915 } 916 TranslationsParent.removeLangTagFromPref( 917 langTag, 918 NEVER_TRANSLATE_LANGS_PREF 919 ); 920 } 921 } 922 923 this.alwaysTranslateLanguageTags = new Set(langTags); 924 925 this.renderAlwaysTranslateLanguages(langTags); 926 await this.updateAlwaysTranslateSelectOptionState(); 927 }, 928 929 /** 930 * Render the current set of always-translate languages into the list UI. 931 * 932 * @param {string[]} langTags 933 */ 934 renderAlwaysTranslateLanguages(langTags) { 935 const { alwaysTranslateLanguagesGroup, alwaysTranslateLanguagesNoneRow } = 936 this.elements; 937 938 for (const item of alwaysTranslateLanguagesGroup.querySelectorAll( 939 `.${ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS}` 940 )) { 941 item.remove(); 942 } 943 944 const previousEmptyStateVisible = 945 alwaysTranslateLanguagesNoneRow && 946 !alwaysTranslateLanguagesNoneRow.hidden; 947 948 if (alwaysTranslateLanguagesNoneRow) { 949 const hasLanguages = !!langTags.length; 950 alwaysTranslateLanguagesNoneRow.hidden = hasLanguages; 951 952 if (hasLanguages && alwaysTranslateLanguagesNoneRow.isConnected) { 953 alwaysTranslateLanguagesNoneRow.remove(); 954 } else if ( 955 !hasLanguages && 956 !alwaysTranslateLanguagesNoneRow.isConnected 957 ) { 958 alwaysTranslateLanguagesGroup.appendChild( 959 alwaysTranslateLanguagesNoneRow 960 ); 961 } 962 } 963 964 const sortedLangTags = [...langTags].sort((langTagA, langTagB) => { 965 const labelA = this.formatLanguageLabel(langTagA) ?? langTagA; 966 const labelB = this.formatLanguageLabel(langTagB) ?? langTagB; 967 return labelA.localeCompare(labelB); 968 }); 969 970 for (const langTag of sortedLangTags) { 971 const label = this.formatLanguageLabel(langTag); 972 if (!label) { 973 continue; 974 } 975 976 const removeButton = document.createElement("moz-button"); 977 removeButton.setAttribute("slot", "actions-start"); 978 removeButton.setAttribute("type", "icon"); 979 removeButton.setAttribute( 980 "iconsrc", 981 "chrome://global/skin/icons/delete.svg" 982 ); 983 removeButton.classList.add(ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS); 984 removeButton.dataset.langTag = langTag; 985 removeButton.setAttribute("aria-label", label); 986 987 const item = document.createElement("moz-box-item"); 988 item.classList.add(ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS); 989 item.setAttribute("label", label); 990 item.dataset.langTag = langTag; 991 item.appendChild(removeButton); 992 if ( 993 alwaysTranslateLanguagesNoneRow && 994 alwaysTranslateLanguagesNoneRow.parentElement === 995 alwaysTranslateLanguagesGroup 996 ) { 997 alwaysTranslateLanguagesGroup.insertBefore( 998 item, 999 alwaysTranslateLanguagesNoneRow 1000 ); 1001 } else { 1002 alwaysTranslateLanguagesGroup.appendChild(item); 1003 } 1004 } 1005 1006 dispatchTestEvent("AlwaysTranslateLanguagesRendered", { 1007 languages: langTags, 1008 count: langTags.length, 1009 }); 1010 1011 const currentEmptyStateVisible = 1012 alwaysTranslateLanguagesNoneRow && 1013 !alwaysTranslateLanguagesNoneRow.hidden; 1014 if (previousEmptyStateVisible && !currentEmptyStateVisible) { 1015 dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateHidden"); 1016 } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { 1017 dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateShown"); 1018 } 1019 }, 1020 1021 /** 1022 * Format a language tag for display using the cached display names. 1023 * 1024 * @param {string} langTag 1025 * @returns {string|null} 1026 */ 1027 formatLanguageLabel(langTag) { 1028 try { 1029 return this.languageDisplayNames?.of(langTag) ?? null; 1030 } catch (error) { 1031 console.warn(`Failed to format language label for ${langTag}`, error); 1032 return null; 1033 } 1034 }, 1035 1036 /** 1037 * Populate the select options for the supported source languages. 1038 */ 1039 async buildAlwaysTranslateSelectOptions() { 1040 const select = this.elements?.alwaysTranslateLanguagesSelect; 1041 if (!select || !this.supportedLanguages?.sourceLanguages?.length) { 1042 return; 1043 } 1044 1045 const placeholder = select.querySelector('moz-option[value=""]'); 1046 for (const option of select.querySelectorAll("moz-option")) { 1047 if (option !== placeholder) { 1048 option.remove(); 1049 } 1050 } 1051 1052 const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( 1053 (lhs, rhs) => 1054 ( 1055 this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName 1056 ).localeCompare( 1057 this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName 1058 ) 1059 ); 1060 for (const { langTag, displayName } of sourceLanguages) { 1061 const option = document.createElement("moz-option"); 1062 option.setAttribute("value", langTag); 1063 option.setAttribute( 1064 "label", 1065 this.formatLanguageLabel(langTag) ?? displayName 1066 ); 1067 select.appendChild(option); 1068 } 1069 1070 await this.resetAlwaysTranslateSelect(); 1071 }, 1072 1073 /** 1074 * Disable already-added languages in the select so they cannot be re-added. 1075 */ 1076 async updateAlwaysTranslateSelectOptionState() { 1077 const select = this.elements?.alwaysTranslateLanguagesSelect; 1078 if (!select) { 1079 return; 1080 } 1081 1082 for (const option of select.querySelectorAll("moz-option")) { 1083 const value = option.getAttribute("value"); 1084 if (!value) { 1085 continue; 1086 } 1087 option.disabled = this.alwaysTranslateLanguageTags.has(value); 1088 } 1089 1090 await this.resetAlwaysTranslateSelect(); 1091 1092 dispatchTestEvent("AlwaysTranslateLanguagesSelectOptionsUpdated"); 1093 }, 1094 1095 /** 1096 * Handle a selection in the "Never translate languages" dropdown. 1097 * 1098 * @param {string} langTag 1099 */ 1100 async onNeverTranslateLanguageChosen(langTag) { 1101 if (!langTag) { 1102 this.updateNeverTranslateAddButtonDisabledState(); 1103 return; 1104 } 1105 1106 if (this.shouldDisableNeverTranslateAddButton()) { 1107 this.updateNeverTranslateAddButtonDisabledState(); 1108 return; 1109 } 1110 1111 TranslationsParent.addLangTagToPref(langTag, NEVER_TRANSLATE_LANGS_PREF); 1112 TranslationsParent.removeLangTagFromPref( 1113 langTag, 1114 ALWAYS_TRANSLATE_LANGS_PREF 1115 ); 1116 await this.resetNeverTranslateSelect(); 1117 }, 1118 1119 /** 1120 * Handle a selection change in the never-translate dropdown. 1121 */ 1122 onNeverTranslateLanguageSelectionChanged() { 1123 this.updateNeverTranslateAddButtonDisabledState(); 1124 }, 1125 1126 /** 1127 * Whether the add button for never-translate languages should be disabled. 1128 * 1129 * @returns {boolean} 1130 */ 1131 shouldDisableNeverTranslateAddButton() { 1132 const select = this.elements?.neverTranslateLanguagesSelect; 1133 if (!select || select.disabled) { 1134 return true; 1135 } 1136 1137 const langTag = select.value; 1138 if (!langTag) { 1139 return true; 1140 } 1141 1142 const option = /** @type {HTMLElement|null} */ ( 1143 select.querySelector(`moz-option[value="${langTag}"]`) 1144 ); 1145 return option?.hasAttribute("disabled") ?? false; 1146 }, 1147 1148 /** 1149 * Set the add button enabled state for never-translate languages. 1150 * 1151 * @param {boolean} isDisabled 1152 */ 1153 setNeverTranslateAddButtonDisabledState(isDisabled) { 1154 if (!this.elements?.neverTranslateLanguagesButton) { 1155 return; 1156 } 1157 1158 const wasDisabled = this.elements.neverTranslateLanguagesButton.disabled; 1159 this.elements.neverTranslateLanguagesButton.disabled = isDisabled; 1160 if (wasDisabled !== isDisabled) { 1161 dispatchTestEvent( 1162 isDisabled 1163 ? "NeverTranslateLanguagesAddButtonDisabled" 1164 : "NeverTranslateLanguagesAddButtonEnabled" 1165 ); 1166 } 1167 }, 1168 1169 /** 1170 * Update the add button enabled state for never-translate languages. 1171 */ 1172 updateNeverTranslateAddButtonDisabledState() { 1173 this.setNeverTranslateAddButtonDisabledState( 1174 this.shouldDisableNeverTranslateAddButton() 1175 ); 1176 }, 1177 1178 /** 1179 * Remove the given language from the never translate list. 1180 * 1181 * @param {string} langTag 1182 */ 1183 removeNeverTranslateLanguage(langTag) { 1184 TranslationsParent.removeLangTagFromPref( 1185 langTag, 1186 NEVER_TRANSLATE_LANGS_PREF 1187 ); 1188 }, 1189 1190 /** 1191 * Reset the dropdown back to the placeholder value and underlying setting state. 1192 */ 1193 async resetNeverTranslateSelect() { 1194 await this.resetSelect( 1195 this.elements?.neverTranslateLanguagesSelect, 1196 "translationsNeverTranslateLanguagesSelect" 1197 ); 1198 this.updateNeverTranslateAddButtonDisabledState(); 1199 }, 1200 1201 /** 1202 * Refresh the rendered list of never-translate languages to match prefs. 1203 */ 1204 async refreshNeverTranslateLanguages() { 1205 if (!this.elements?.neverTranslateLanguagesGroup) { 1206 return; 1207 } 1208 1209 const langTags = Array.from( 1210 TranslationsParent.getNeverTranslateLanguages?.() ?? [] 1211 ); 1212 this.neverTranslateLanguageTags = new Set(langTags); 1213 1214 this.renderNeverTranslateLanguages(langTags); 1215 await this.updateNeverTranslateSelectOptionState(); 1216 }, 1217 1218 /** 1219 * Render the current set of never-translate languages into the list UI. 1220 * 1221 * @param {string[]} langTags 1222 */ 1223 renderNeverTranslateLanguages(langTags) { 1224 const { neverTranslateLanguagesGroup, neverTranslateLanguagesNoneRow } = 1225 this.elements; 1226 1227 for (const item of neverTranslateLanguagesGroup.querySelectorAll( 1228 `.${NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS}` 1229 )) { 1230 item.remove(); 1231 } 1232 1233 const previousEmptyStateVisible = 1234 neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden; 1235 1236 if (neverTranslateLanguagesNoneRow) { 1237 const hasLanguages = Boolean(langTags.length); 1238 neverTranslateLanguagesNoneRow.hidden = hasLanguages; 1239 1240 if (hasLanguages && neverTranslateLanguagesNoneRow.isConnected) { 1241 neverTranslateLanguagesNoneRow.remove(); 1242 } else if (!hasLanguages && !neverTranslateLanguagesNoneRow.isConnected) { 1243 neverTranslateLanguagesGroup.appendChild( 1244 neverTranslateLanguagesNoneRow 1245 ); 1246 } 1247 } 1248 1249 const sortedLangTags = [...langTags].sort((langTagA, langTagB) => { 1250 const labelA = this.formatLanguageLabel(langTagA) ?? langTagA; 1251 const labelB = this.formatLanguageLabel(langTagB) ?? langTagB; 1252 return labelA.localeCompare(labelB); 1253 }); 1254 1255 for (const langTag of sortedLangTags) { 1256 const label = this.formatLanguageLabel(langTag); 1257 if (!label) { 1258 continue; 1259 } 1260 1261 const removeButton = document.createElement("moz-button"); 1262 removeButton.setAttribute("slot", "actions-start"); 1263 removeButton.setAttribute("type", "icon"); 1264 removeButton.setAttribute( 1265 "iconsrc", 1266 "chrome://global/skin/icons/delete.svg" 1267 ); 1268 removeButton.classList.add(NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS); 1269 removeButton.dataset.langTag = langTag; 1270 removeButton.setAttribute("aria-label", label); 1271 1272 const item = document.createElement("moz-box-item"); 1273 item.classList.add(NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS); 1274 item.setAttribute("label", label); 1275 item.dataset.langTag = langTag; 1276 item.appendChild(removeButton); 1277 if ( 1278 neverTranslateLanguagesNoneRow && 1279 neverTranslateLanguagesNoneRow.parentElement === 1280 neverTranslateLanguagesGroup 1281 ) { 1282 neverTranslateLanguagesGroup.insertBefore( 1283 item, 1284 neverTranslateLanguagesNoneRow 1285 ); 1286 } else { 1287 neverTranslateLanguagesGroup.appendChild(item); 1288 } 1289 } 1290 1291 dispatchTestEvent("NeverTranslateLanguagesRendered", { 1292 languages: langTags, 1293 count: langTags.length, 1294 }); 1295 1296 const currentEmptyStateVisible = 1297 neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden; 1298 if (previousEmptyStateVisible && !currentEmptyStateVisible) { 1299 dispatchTestEvent("NeverTranslateLanguagesEmptyStateHidden"); 1300 } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { 1301 dispatchTestEvent("NeverTranslateLanguagesEmptyStateShown"); 1302 } 1303 }, 1304 1305 /** 1306 * Populate the select options for the supported source languages. 1307 */ 1308 async buildNeverTranslateSelectOptions() { 1309 const select = this.elements?.neverTranslateLanguagesSelect; 1310 if (!select || !this.supportedLanguages?.sourceLanguages?.length) { 1311 return; 1312 } 1313 1314 const placeholder = select.querySelector('moz-option[value=""]'); 1315 for (const option of select.querySelectorAll("moz-option")) { 1316 if (option !== placeholder) { 1317 option.remove(); 1318 } 1319 } 1320 1321 const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( 1322 (lhs, rhs) => 1323 ( 1324 this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName 1325 ).localeCompare( 1326 this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName 1327 ) 1328 ); 1329 for (const { langTag, displayName } of sourceLanguages) { 1330 const option = document.createElement("moz-option"); 1331 option.setAttribute("value", langTag); 1332 option.setAttribute( 1333 "label", 1334 this.formatLanguageLabel(langTag) ?? displayName 1335 ); 1336 select.appendChild(option); 1337 } 1338 1339 await this.resetNeverTranslateSelect(); 1340 }, 1341 1342 /** 1343 * Disable already-added languages in the select so they cannot be re-added. 1344 */ 1345 async updateNeverTranslateSelectOptionState() { 1346 const select = this.elements?.neverTranslateLanguagesSelect; 1347 if (!select) { 1348 return; 1349 } 1350 1351 for (const option of select.querySelectorAll("moz-option")) { 1352 const value = option.getAttribute("value"); 1353 if (!value) { 1354 continue; 1355 } 1356 option.disabled = this.neverTranslateLanguageTags.has(value); 1357 } 1358 1359 await this.resetNeverTranslateSelect(); 1360 1361 dispatchTestEvent("NeverTranslateLanguagesSelectOptionsUpdated"); 1362 }, 1363 1364 /** 1365 * Refresh the rendered list of never-translate sites. 1366 */ 1367 refreshNeverTranslateSites() { 1368 if (!this.elements?.neverTranslateSitesGroup) { 1369 return; 1370 } 1371 1372 /** @type {string[]} */ 1373 let siteOrigins = []; 1374 try { 1375 siteOrigins = TranslationsParent.listNeverTranslateSites() ?? []; 1376 } catch (error) { 1377 console.error("Failed to list never translate sites", error); 1378 } 1379 1380 this.neverTranslateSiteOrigins = new Set(siteOrigins); 1381 this.renderNeverTranslateSites(siteOrigins); 1382 }, 1383 1384 /** 1385 * Render the never-translate sites list. 1386 * 1387 * @param {string[]} siteOrigins 1388 */ 1389 renderNeverTranslateSites(siteOrigins) { 1390 const { neverTranslateSitesGroup, neverTranslateSitesNoneRow } = 1391 this.elements ?? {}; 1392 if (!neverTranslateSitesGroup) { 1393 return; 1394 } 1395 1396 for (const item of neverTranslateSitesGroup.querySelectorAll( 1397 `.${NEVER_TRANSLATE_SITE_ITEM_CLASS}` 1398 )) { 1399 item.remove(); 1400 } 1401 1402 const previousEmptyStateVisible = 1403 neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden; 1404 1405 if (neverTranslateSitesNoneRow) { 1406 const hasSites = Boolean(siteOrigins.length); 1407 neverTranslateSitesNoneRow.hidden = hasSites; 1408 1409 if (hasSites && neverTranslateSitesNoneRow.isConnected) { 1410 neverTranslateSitesNoneRow.remove(); 1411 } else if (!hasSites && !neverTranslateSitesNoneRow.isConnected) { 1412 neverTranslateSitesGroup.appendChild(neverTranslateSitesNoneRow); 1413 } 1414 } 1415 1416 const sortedOrigins = [...siteOrigins].sort((originA, originB) => { 1417 return this.getSiteSortKey(originA).localeCompare( 1418 this.getSiteSortKey(originB) 1419 ); 1420 }); 1421 1422 for (const origin of sortedOrigins) { 1423 const removeButton = document.createElement("moz-button"); 1424 removeButton.setAttribute("slot", "actions-start"); 1425 removeButton.setAttribute("type", "icon"); 1426 removeButton.setAttribute( 1427 "iconsrc", 1428 "chrome://global/skin/icons/delete.svg" 1429 ); 1430 removeButton.classList.add(NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS); 1431 removeButton.dataset.origin = origin; 1432 removeButton.setAttribute("aria-label", origin); 1433 1434 const item = document.createElement("moz-box-item"); 1435 item.classList.add(NEVER_TRANSLATE_SITE_ITEM_CLASS); 1436 item.setAttribute("label", origin); 1437 item.dataset.origin = origin; 1438 item.appendChild(removeButton); 1439 if ( 1440 neverTranslateSitesNoneRow && 1441 neverTranslateSitesNoneRow.parentElement === neverTranslateSitesGroup 1442 ) { 1443 neverTranslateSitesGroup.insertBefore(item, neverTranslateSitesNoneRow); 1444 } else { 1445 neverTranslateSitesGroup.appendChild(item); 1446 } 1447 } 1448 1449 dispatchTestEvent("NeverTranslateSitesRendered", { 1450 sites: siteOrigins, 1451 count: siteOrigins.length, 1452 }); 1453 1454 const currentEmptyStateVisible = 1455 neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden; 1456 if (previousEmptyStateVisible && !currentEmptyStateVisible) { 1457 dispatchTestEvent("NeverTranslateSitesEmptyStateHidden"); 1458 } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { 1459 dispatchTestEvent("NeverTranslateSitesEmptyStateShown"); 1460 } 1461 }, 1462 1463 /** 1464 * Remove a site from the never-translate list. 1465 * 1466 * @param {string} origin 1467 */ 1468 removeNeverTranslateSite(origin) { 1469 if (!origin || !this.neverTranslateSiteOrigins.has(origin)) { 1470 return; 1471 } 1472 1473 try { 1474 TranslationsParent.setNeverTranslateSiteByOrigin(false, origin); 1475 } catch (error) { 1476 console.error("Failed to remove never translate site", error); 1477 return; 1478 } 1479 1480 this.refreshNeverTranslateSites(); 1481 }, 1482 1483 /** 1484 * Create a sort key that ignores protocol differences. 1485 * 1486 * @param {string} origin 1487 * @returns {string} 1488 */ 1489 getSiteSortKey(origin) { 1490 try { 1491 return Services.io.newURI(origin).asciiHostPort; 1492 } catch { 1493 return origin; 1494 } 1495 }, 1496 1497 /** 1498 * Handle a selection change in the download dropdown. 1499 */ 1500 onDownloadSelectionChanged() { 1501 this.updateDownloadLanguageButtonDisabled(); 1502 }, 1503 1504 /** 1505 * Whether the download button should be disabled based on selection state. 1506 * 1507 * @returns {boolean} 1508 */ 1509 shouldDisableDownloadLanguageButton() { 1510 const select = this.elements?.downloadLanguagesSelect; 1511 if (!select || this.currentDownloadLangTag) { 1512 return true; 1513 } 1514 1515 const langTag = select.value; 1516 if (!langTag) { 1517 return true; 1518 } 1519 1520 const option = /** @type {HTMLElement|null} */ ( 1521 select.querySelector(`moz-option[value="${langTag}"]`) 1522 ); 1523 return option?.hasAttribute("disabled") ?? false; 1524 }, 1525 1526 /** 1527 * Set the download button state and dispatch test events when it changes. 1528 * 1529 * @param {boolean} isDisabled 1530 */ 1531 setDownloadLanguageButtonDisabledState(isDisabled) { 1532 const button = this.elements?.downloadLanguagesButton; 1533 if (!button) { 1534 return; 1535 } 1536 1537 const wasDisabled = button.disabled; 1538 button.disabled = isDisabled; 1539 1540 if (wasDisabled !== isDisabled) { 1541 dispatchTestEvent( 1542 isDisabled 1543 ? "DownloadLanguageButtonDisabled" 1544 : "DownloadLanguageButtonEnabled" 1545 ); 1546 } 1547 }, 1548 1549 /** 1550 * Update the enabled state of the download button. 1551 */ 1552 updateDownloadLanguageButtonDisabled() { 1553 this.setDownloadLanguageButtonDisabledState( 1554 this.shouldDisableDownloadLanguageButton() 1555 ); 1556 }, 1557 1558 /** 1559 * Handle a click on the download button. 1560 * 1561 * @returns {Promise<void>} 1562 */ 1563 async onDownloadLanguageButtonClicked() { 1564 const langTag = this.elements?.downloadLanguagesSelect?.value; 1565 if (!langTag || this.currentDownloadLangTag) { 1566 return; 1567 } 1568 1569 this.downloadPendingDeleteLanguageTags.clear(); 1570 this.downloadFailedLanguageTags.clear(); 1571 this.currentDownloadLangTag = langTag; 1572 this.downloadingLanguageTags.add(langTag); 1573 this.setDownloadControlsDisabled(true); 1574 dispatchTestEvent("DownloadStarted", { langTag }); 1575 await this.renderDownloadLanguages(); 1576 this.updateDownloadSelectOptionState({ preserveSelection: true }); 1577 1578 let downloadSucceeded = false; 1579 try { 1580 await TranslationsParent.downloadLanguageFiles(langTag); 1581 this.downloadedLanguageTags.add(langTag); 1582 downloadSucceeded = true; 1583 dispatchTestEvent("DownloadCompleted", { langTag }); 1584 } catch (error) { 1585 dispatchTestEvent("DownloadFailed", { langTag }); 1586 console.error("Failed to download language files", error); 1587 this.downloadFailedLanguageTags.add(langTag); 1588 } finally { 1589 this.downloadingLanguageTags.delete(langTag); 1590 this.currentDownloadLangTag = null; 1591 this.setDownloadControlsDisabled(false); 1592 await this.renderDownloadLanguages(); 1593 this.updateDownloadSelectOptionState({ 1594 preserveSelection: !downloadSucceeded, 1595 }); 1596 this.updateDownloadLanguageButtonDisabled(); 1597 } 1598 }, 1599 1600 /** 1601 * Disable or enable the download controls. 1602 * 1603 * @param {boolean} isDisabled 1604 */ 1605 setDownloadControlsDisabled(isDisabled) { 1606 if (this.elements?.downloadLanguagesSelect) { 1607 this.elements.downloadLanguagesSelect.disabled = isDisabled; 1608 } 1609 this.setDownloadLanguageButtonDisabledState( 1610 isDisabled || this.shouldDisableDownloadLanguageButton() 1611 ); 1612 }, 1613 1614 /** 1615 * Toggle ghost styling on icon buttons. 1616 * 1617 * @param {HTMLElement|null} button 1618 * @param {boolean} isGhost 1619 */ 1620 setIconButtonGhostState(button, isGhost) { 1621 if (!button) { 1622 return; 1623 } 1624 const type = isGhost ? "icon ghost" : "icon"; 1625 if (button.getAttribute("type") !== type) { 1626 button.setAttribute("type", type); 1627 } 1628 }, 1629 1630 /** 1631 * Reset the download dropdown back to its placeholder value. 1632 */ 1633 resetDownloadSelect() { 1634 if (this.elements?.downloadLanguagesSelect) { 1635 this.elements.downloadLanguagesSelect.value = ""; 1636 } 1637 const setting = Preferences.getSetting?.( 1638 "translationsDownloadLanguagesSelect" 1639 ); 1640 if (setting) { 1641 setting.value = ""; 1642 } 1643 this.updateDownloadLanguageButtonDisabled(); 1644 }, 1645 1646 /** 1647 * Refresh download state from disk and update the UI. 1648 * 1649 * @returns {Promise<void>} 1650 */ 1651 async refreshDownloadedLanguages() { 1652 if (!this.languageList?.length) { 1653 return; 1654 } 1655 1656 this.downloadPendingDeleteLanguageTags.clear(); 1657 const downloaded = await Promise.all( 1658 this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => { 1659 try { 1660 const hasFiles = 1661 await TranslationsParent.hasAllFilesForLanguage(langTag); 1662 return /** @type {[string, boolean]} */ ([langTag, hasFiles]); 1663 } catch (error) { 1664 console.error( 1665 `Failed to check download status for ${langTag}`, 1666 error 1667 ); 1668 return /** @type {[string, boolean]} */ ([langTag, false]); 1669 } 1670 }) 1671 ); 1672 1673 this.downloadedLanguageTags = new Set( 1674 downloaded.filter(([, isDownloaded]) => isDownloaded).map(([tag]) => tag) 1675 ); 1676 1677 for (const [langTag, isDownloaded] of downloaded) { 1678 if (isDownloaded) { 1679 this.downloadingLanguageTags.delete(langTag); 1680 this.downloadFailedLanguageTags.delete(langTag); 1681 } else { 1682 this.downloadPendingDeleteLanguageTags.delete(langTag); 1683 } 1684 } 1685 1686 await this.renderDownloadLanguages(); 1687 this.updateDownloadSelectOptionState(); 1688 this.updateDownloadLanguageButtonDisabled(); 1689 }, 1690 1691 /** 1692 * Create a delete confirmation item with warning icon and action buttons. 1693 * 1694 * @param {string} langTag 1695 * @param {HTMLElement} item - The moz-box-item element to populate. 1696 * @returns {Promise<void>} 1697 */ 1698 async createDeleteConfirmationItem(langTag, item, disableActions = false) { 1699 const warningButton = document.createElement("moz-button"); 1700 warningButton.setAttribute("slot", "actions-start"); 1701 warningButton.setAttribute("type", "icon"); 1702 warningButton.setAttribute("iconsrc", DOWNLOAD_WARNING_ICON); 1703 warningButton.style.pointerEvents = "none"; 1704 warningButton.style.color = "var(--icon-color-warning)"; 1705 warningButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); 1706 warningButton.dataset.langTag = langTag; 1707 this.setIconButtonGhostState(warningButton, true); 1708 1709 const sizeLabel = this.formatLanguageSize(langTag) ?? "0"; 1710 const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; 1711 1712 const confirmContent = document.createElement("div"); 1713 confirmContent.style.cssText = 1714 "display: flex; align-items: center; gap: var(--space-small);"; 1715 1716 const confirmText = document.createElement("span"); 1717 confirmText.textContent = await document.l10n.formatValue( 1718 "settings-translations-subpage-download-delete-confirm", 1719 { language: languageLabel, size: sizeLabel } 1720 ); 1721 1722 const buttonGroup = document.createElement("moz-button-group"); 1723 1724 const deleteButton = document.createElement("moz-button"); 1725 deleteButton.setAttribute("type", "destructive"); 1726 deleteButton.setAttribute("size", "small"); 1727 deleteButton.disabled = disableActions; 1728 document.l10n.setAttributes( 1729 deleteButton, 1730 "settings-translations-subpage-download-delete-button" 1731 ); 1732 deleteButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS); 1733 deleteButton.dataset.langTag = langTag; 1734 1735 const cancelButton = document.createElement("moz-button"); 1736 cancelButton.setAttribute("type", "default"); 1737 cancelButton.setAttribute("size", "small"); 1738 cancelButton.disabled = disableActions; 1739 document.l10n.setAttributes( 1740 cancelButton, 1741 "settings-translations-subpage-download-cancel-button" 1742 ); 1743 cancelButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS); 1744 cancelButton.dataset.langTag = langTag; 1745 1746 confirmContent.appendChild(confirmText); 1747 buttonGroup.append(deleteButton, cancelButton); 1748 confirmContent.appendChild(buttonGroup); 1749 1750 if (!deleteButton.disabled) { 1751 requestAnimationFrame(() => { 1752 requestAnimationFrame(() => { 1753 if (deleteButton.isConnected) { 1754 deleteButton.focus({ focusVisible: true }); 1755 } 1756 }); 1757 }); 1758 } 1759 1760 item.appendChild(warningButton); 1761 item.appendChild(confirmContent); 1762 }, 1763 1764 /** 1765 * Create a failed download item with error icon and retry button. 1766 * 1767 * @param {string} langTag 1768 * @param {HTMLElement} item - The moz-box-item element to populate. 1769 * @returns {Promise<void>} 1770 */ 1771 async createFailedDownloadItem(langTag, item, disableActions = false) { 1772 const errorButton = document.createElement("moz-button"); 1773 errorButton.setAttribute("slot", "actions-start"); 1774 errorButton.setAttribute("type", "icon"); 1775 errorButton.setAttribute("iconsrc", DOWNLOAD_ERROR_ICON); 1776 errorButton.style.pointerEvents = "none"; 1777 errorButton.style.color = "var(--text-color-error)"; 1778 errorButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); 1779 errorButton.dataset.langTag = langTag; 1780 this.setIconButtonGhostState(errorButton, true); 1781 1782 const sizeLabel = this.formatLanguageSize(langTag) ?? "0"; 1783 const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; 1784 1785 const errorContent = document.createElement("div"); 1786 errorContent.style.cssText = 1787 "display: flex; align-items: center; gap: var(--space-small);"; 1788 1789 const errorText = document.createElement("span"); 1790 document.l10n.setAttributes( 1791 errorText, 1792 "settings-translations-subpage-download-error", 1793 { language: languageLabel, size: sizeLabel } 1794 ); 1795 1796 const retryButton = document.createElement("moz-button"); 1797 retryButton.setAttribute("type", "text"); 1798 retryButton.setAttribute("size", "small"); 1799 retryButton.disabled = disableActions; 1800 document.l10n.setAttributes( 1801 retryButton, 1802 "settings-translations-subpage-download-retry-button" 1803 ); 1804 retryButton.classList.add(DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS); 1805 retryButton.dataset.langTag = langTag; 1806 1807 errorContent.appendChild(errorText); 1808 errorContent.appendChild(retryButton); 1809 1810 if (!retryButton.disabled) { 1811 requestAnimationFrame(() => { 1812 requestAnimationFrame(() => { 1813 if (retryButton.isConnected) { 1814 retryButton.focus({ focusVisible: true }); 1815 } 1816 }); 1817 }); 1818 } 1819 1820 item.appendChild(errorButton); 1821 item.appendChild(errorContent); 1822 }, 1823 1824 /** 1825 * Create a download/remove button for downloaded or downloading language items. 1826 * 1827 * @param {string} langTag 1828 * @param {boolean} isDownloading 1829 * @param {HTMLElement} item - The moz-box-item element to populate. 1830 * @param {string} progressLabel - The localized "Downloading..." text. 1831 * @returns {Promise<boolean>} - Returns false if the item should be skipped. 1832 */ 1833 async createDownloadLanguageItem( 1834 langTag, 1835 isDownloading, 1836 item, 1837 progressLabel, 1838 disableActions = false 1839 ) { 1840 const label = await this.formatDownloadLabel(langTag); 1841 if (!label) { 1842 return false; 1843 } 1844 1845 const removeButton = document.createElement("moz-button"); 1846 removeButton.setAttribute("slot", "actions-start"); 1847 removeButton.setAttribute("type", "icon"); 1848 removeButton.setAttribute( 1849 "iconsrc", 1850 isDownloading ? DOWNLOAD_LOADING_ICON : DOWNLOAD_DELETE_ICON 1851 ); 1852 removeButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); 1853 removeButton.dataset.langTag = langTag; 1854 removeButton.setAttribute("aria-label", label); 1855 if (isDownloading) { 1856 removeButton.style.pointerEvents = "none"; 1857 removeButton.disabled = false; 1858 } else { 1859 removeButton.disabled = disableActions; 1860 } 1861 this.setIconButtonGhostState( 1862 removeButton, 1863 isDownloading || 1864 removeButton.getAttribute("iconsrc") === DOWNLOAD_LOADING_ICON 1865 ); 1866 1867 item.setAttribute("label", label); 1868 if (isDownloading) { 1869 item.setAttribute("description", progressLabel); 1870 } 1871 1872 item.appendChild(removeButton); 1873 return true; 1874 }, 1875 1876 /** 1877 * Render the downloaded (and downloading) languages list. 1878 * 1879 * @returns {Promise<void>} 1880 */ 1881 async renderDownloadLanguages() { 1882 const { downloadLanguagesGroup, downloadLanguagesNoneRow } = 1883 this.elements ?? {}; 1884 if (!downloadLanguagesGroup) { 1885 return; 1886 } 1887 1888 const isDownloadInProgress = Boolean(this.currentDownloadLangTag); 1889 const previousEmptyStateVisible = 1890 downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden; 1891 1892 for (const item of downloadLanguagesGroup.querySelectorAll( 1893 `.${DOWNLOAD_LANGUAGE_ITEM_CLASS}` 1894 )) { 1895 item.remove(); 1896 } 1897 1898 const langTags = [ 1899 ...Array.from( 1900 new Set([ 1901 ...Array.from(this.downloadedLanguageTags), 1902 ...Array.from(this.downloadingLanguageTags), 1903 ...Array.from(this.downloadFailedLanguageTags), 1904 ]) 1905 ), 1906 ]; 1907 1908 if (downloadLanguagesNoneRow) { 1909 const hasLanguages = !!langTags.length; 1910 downloadLanguagesNoneRow.hidden = hasLanguages; 1911 1912 if (hasLanguages && downloadLanguagesNoneRow.isConnected) { 1913 downloadLanguagesNoneRow.remove(); 1914 } else if (!hasLanguages && !downloadLanguagesNoneRow.isConnected) { 1915 downloadLanguagesGroup.appendChild(downloadLanguagesNoneRow); 1916 } 1917 } 1918 1919 const currentEmptyStateVisible = 1920 downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden; 1921 if (previousEmptyStateVisible && !currentEmptyStateVisible) { 1922 dispatchTestEvent("DownloadedLanguagesEmptyStateHidden"); 1923 } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { 1924 dispatchTestEvent("DownloadedLanguagesEmptyStateShown"); 1925 } 1926 1927 const sortedLangTags = [...langTags].sort((lhs, rhs) => { 1928 const labelA = this.formatLanguageLabel(lhs) ?? lhs; 1929 const labelB = this.formatLanguageLabel(rhs) ?? rhs; 1930 return labelA.localeCompare(labelB); 1931 }); 1932 1933 const progressLabel = await document.l10n.formatValue( 1934 "settings-translations-subpage-download-progress" 1935 ); 1936 1937 for (const langTag of sortedLangTags) { 1938 const isDownloading = this.downloadingLanguageTags.has(langTag); 1939 const isFailed = this.downloadFailedLanguageTags.has(langTag); 1940 const isPendingDelete = 1941 this.downloadPendingDeleteLanguageTags.has(langTag); 1942 1943 const item = document.createElement("moz-box-item"); 1944 item.classList.add(DOWNLOAD_LANGUAGE_ITEM_CLASS); 1945 item.dataset.langTag = langTag; 1946 1947 if (isPendingDelete) { 1948 await this.createDeleteConfirmationItem( 1949 langTag, 1950 item, 1951 isDownloadInProgress 1952 ); 1953 } else if (isFailed) { 1954 item.classList.add(DOWNLOAD_LANGUAGE_FAILED_CLASS); 1955 await this.createFailedDownloadItem( 1956 langTag, 1957 item, 1958 isDownloadInProgress 1959 ); 1960 } else { 1961 const shouldAdd = await this.createDownloadLanguageItem( 1962 langTag, 1963 isDownloading, 1964 item, 1965 progressLabel, 1966 isDownloadInProgress 1967 ); 1968 if (!shouldAdd) { 1969 continue; 1970 } 1971 } 1972 1973 if ( 1974 downloadLanguagesNoneRow && 1975 downloadLanguagesNoneRow.parentElement === downloadLanguagesGroup 1976 ) { 1977 downloadLanguagesGroup.insertBefore(item, downloadLanguagesNoneRow); 1978 } else { 1979 downloadLanguagesGroup.appendChild(item); 1980 } 1981 } 1982 1983 dispatchTestEvent("DownloadedLanguagesRendered", { 1984 languages: sortedLangTags, 1985 count: sortedLangTags.length, 1986 downloading: sortedLangTags.filter(langTag => 1987 this.downloadingLanguageTags.has(langTag) 1988 ), 1989 }); 1990 }, 1991 1992 /** 1993 * Show delete confirmation UI when delete button is clicked. 1994 * 1995 * @param {string} langTag 1996 * @returns {Promise<void>} 1997 */ 1998 async onDeleteButtonClicked(langTag) { 1999 if (!langTag || !this.downloadedLanguageTags.has(langTag)) { 2000 return; 2001 } 2002 2003 this.downloadFailedLanguageTags.clear(); 2004 this.downloadPendingDeleteLanguageTags.clear(); 2005 this.downloadPendingDeleteLanguageTags.add(langTag); 2006 await this.renderDownloadLanguages(); 2007 }, 2008 2009 /** 2010 * Confirm and complete deletion of a language. 2011 * 2012 * @param {string} langTag 2013 * @returns {Promise<void>} 2014 */ 2015 async confirmDeleteLanguage(langTag) { 2016 if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) { 2017 return; 2018 } 2019 2020 this.downloadPendingDeleteLanguageTags.delete(langTag); 2021 2022 try { 2023 await TranslationsParent.deleteLanguageFiles(langTag); 2024 this.downloadedLanguageTags.delete(langTag); 2025 dispatchTestEvent("DownloadDeleted", { langTag }); 2026 } catch (error) { 2027 console.error("Failed to remove downloaded language files", error); 2028 await this.renderDownloadLanguages(); 2029 return; 2030 } 2031 2032 await this.renderDownloadLanguages(); 2033 this.updateDownloadSelectOptionState(); 2034 this.updateDownloadLanguageButtonDisabled(); 2035 }, 2036 2037 /** 2038 * Cancel delete confirmation and restore normal state. 2039 * 2040 * @param {string} langTag 2041 * @returns {Promise<void>} 2042 */ 2043 async cancelDeleteLanguage(langTag) { 2044 if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) { 2045 return; 2046 } 2047 2048 this.downloadPendingDeleteLanguageTags.delete(langTag); 2049 await this.renderDownloadLanguages(); 2050 }, 2051 2052 /** 2053 * Retry downloading a failed language. 2054 * 2055 * @param {string} langTag 2056 * @returns {Promise<void>} 2057 */ 2058 async retryDownloadLanguage(langTag) { 2059 if (!langTag || !this.downloadFailedLanguageTags.has(langTag)) { 2060 return; 2061 } 2062 2063 this.downloadFailedLanguageTags.delete(langTag); 2064 this.currentDownloadLangTag = langTag; 2065 this.downloadingLanguageTags.add(langTag); 2066 this.setDownloadControlsDisabled(true); 2067 dispatchTestEvent("DownloadStarted", { langTag }); 2068 await this.renderDownloadLanguages(); 2069 this.updateDownloadSelectOptionState({ preserveSelection: true }); 2070 2071 let downloadSucceeded = false; 2072 try { 2073 await TranslationsParent.downloadLanguageFiles(langTag); 2074 this.downloadedLanguageTags.add(langTag); 2075 downloadSucceeded = true; 2076 dispatchTestEvent("DownloadCompleted", { langTag }); 2077 } catch (error) { 2078 console.error("Failed to download language files", error); 2079 this.downloadFailedLanguageTags.add(langTag); 2080 dispatchTestEvent("DownloadFailed", { langTag }); 2081 } finally { 2082 this.downloadingLanguageTags.delete(langTag); 2083 this.currentDownloadLangTag = null; 2084 this.setDownloadControlsDisabled(false); 2085 await this.renderDownloadLanguages(); 2086 this.updateDownloadSelectOptionState({ 2087 preserveSelection: !downloadSucceeded, 2088 }); 2089 this.updateDownloadLanguageButtonDisabled(); 2090 } 2091 }, 2092 2093 /** 2094 * Handle updates to translations permissions. 2095 * 2096 * @param {nsISupports} subject 2097 * @param {string} data 2098 */ 2099 handlePermissionChange(subject, data) { 2100 if (data === "cleared") { 2101 this.neverTranslateSiteOrigins = new Set(); 2102 this.renderNeverTranslateSites([]); 2103 return; 2104 } 2105 2106 const perm = subject?.QueryInterface?.(Ci.nsIPermission); 2107 if (perm?.type !== TRANSLATIONS_PERMISSION) { 2108 return; 2109 } 2110 2111 this.refreshNeverTranslateSites(); 2112 }, 2113 2114 /** 2115 * Remove observers and listeners added during init. 2116 */ 2117 teardown() { 2118 try { 2119 Services.obs.removeObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED); 2120 Services.obs.removeObserver(this, "perm-changed"); 2121 } catch (e) { 2122 // Ignore if we were never added. 2123 } 2124 document.removeEventListener("paneshown", this); 2125 window.removeEventListener("unload", this); 2126 this.elements?.alwaysTranslateLanguagesSelect?.removeEventListener( 2127 "change", 2128 this 2129 ); 2130 this.elements?.alwaysTranslateLanguagesGroup?.removeEventListener( 2131 "click", 2132 this 2133 ); 2134 this.elements?.alwaysTranslateLanguagesButton?.removeEventListener( 2135 "click", 2136 this 2137 ); 2138 this.elements?.neverTranslateLanguagesSelect?.removeEventListener( 2139 "change", 2140 this 2141 ); 2142 this.elements?.neverTranslateLanguagesButton?.removeEventListener( 2143 "click", 2144 this 2145 ); 2146 this.elements?.neverTranslateLanguagesGroup?.removeEventListener( 2147 "click", 2148 this 2149 ); 2150 this.elements?.neverTranslateSitesGroup?.removeEventListener("click", this); 2151 this.elements?.downloadLanguagesSelect?.removeEventListener("change", this); 2152 this.elements?.downloadLanguagesGroup?.removeEventListener("click", this); 2153 this.elements?.downloadLanguagesButton?.removeEventListener("click", this); 2154 }, 2155 }; 2156 2157 document.addEventListener("paneshown", TranslationsSettings);