tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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);