/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @ts-check "use strict"; /* import-globals-from main.js */ /** * @import { * TranslationsSettingsElements, * SupportedLanguages, * LanguageInfo * } from "./translations" */ /** @type {string} */ const ALWAYS_TRANSLATE_LANGS_PREF = "browser.translations.alwaysTranslateLanguages"; /** @type {string} */ const NEVER_TRANSLATE_LANGS_PREF = "browser.translations.neverTranslateLanguages"; /** @type {string} */ const TOPIC_TRANSLATIONS_PREF_CHANGED = "translations:pref-changed"; /** @type {string} */ const TRANSLATIONS_PERMISSION = "translations"; /** @type {string} */ const ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS = "translations-always-translate-language-item"; /** @type {string} */ const ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-always-translate-remove-button"; /** @type {string} */ const NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS = "translations-never-translate-language-item"; /** @type {string} */ const NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-never-translate-remove-button"; /** @type {string} */ const NEVER_TRANSLATE_SITE_ITEM_CLASS = "translations-never-translate-site-item"; /** @type {string} */ const NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS = "translations-never-translate-site-remove-button"; /** @type {string} */ const DOWNLOAD_LANGUAGE_ITEM_CLASS = "translations-download-language-item"; /** @type {string} */ const DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-download-remove-button"; /** @type {string} */ const DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS = "translations-download-retry-button"; /** @type {string} */ const DOWNLOAD_LANGUAGE_FAILED_CLASS = "translations-download-language-error"; /** @type {string} */ const DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS = "translations-download-delete-confirm-button"; /** @type {string} */ const DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS = "translations-download-delete-cancel-button"; /** @type {string} */ const DOWNLOAD_LOADING_ICON = "chrome://global/skin/icons/loading.svg"; /** @type {string} */ const DOWNLOAD_DELETE_ICON = "chrome://global/skin/icons/delete.svg"; /** @type {string} */ const DOWNLOAD_ERROR_ICON = "chrome://global/skin/icons/error.svg"; /** @type {string} */ const DOWNLOAD_WARNING_ICON = "chrome://global/skin/icons/warning.svg"; /** * Dispatches a test-only event when running under automation. * * @param {string} name - Event name without the "TranslationsSettingsTest:" prefix. * @param {object} [detail] - Optional event detail. */ function dispatchTestEvent(name, detail) { if (!globalThis.Cu?.isInAutomation) { return; } const options = detail ? { detail } : undefined; document.dispatchEvent( new CustomEvent(`TranslationsSettingsTest:${name}`, options) ); } const TranslationsSettings = { /** * True once initialization has completed. * * @type {boolean} */ initialized: false, /** * Promise guarding full initialization to avoid re-entry. * * @type {Promise|null} */ initPromise: null, /** * Promise cached after the pane/group finish rendering. * * @type {Promise|null} */ paneRenderPromise: null, /** * Supported languages fetched from TranslationsParent. * * @type {SupportedLanguages|null} */ supportedLanguages: null, /** * Display names for supported languages. * * @type {Intl.DisplayNames|null} */ languageDisplayNames: null, /** * Language metadata used to build labels and selectors. * * @type {LanguageInfo[]|null} */ languageList: null, /** * Download sizes keyed by language tag. * * @type {Map|null} */ languageSizes: null, /** * Formatter used for download size labels. * * @type {Intl.NumberFormat|null} */ numberFormatter: null, /** * Current always-translate language tags. * * @type {Set} */ alwaysTranslateLanguageTags: new Set(), /** * Current never-translate language tags. * * @type {Set} */ neverTranslateLanguageTags: new Set(), /** * Current never-translate site origins. * * @type {Set} */ neverTranslateSiteOrigins: new Set(), /** * Language tags with downloaded translation models. * * @type {Set} */ downloadedLanguageTags: new Set(), /** * Language tags currently downloading. * * @type {Set} */ downloadingLanguageTags: new Set(), /** * Language tags that failed to download. * * @type {Set} */ downloadFailedLanguageTags: new Set(), /** * Language tags pending delete confirmation. * * @type {Set} */ downloadPendingDeleteLanguageTags: new Set(), /** * Language tag of the in-progress download, if any. * * @type {string|null} */ currentDownloadLangTag: null, /** * Cached DOM elements used by the module. * * @type {TranslationsSettingsElements|null} */ elements: null, /** * Handles events this object is registered for. * * @param {Event} event */ async handleEvent(event) { switch (event.type) { case "paneshown": await this.handlePaneShown( /** @type {CustomEvent} */ (event).detail?.category ); break; case "change": if (event.target === this.elements?.alwaysTranslateLanguagesSelect) { this.onAlwaysTranslateLanguageSelectionChanged(); } else if ( event.target === this.elements?.neverTranslateLanguagesSelect ) { this.onNeverTranslateLanguageSelectionChanged(); } else if (event.target === this.elements?.downloadLanguagesSelect) { this.onDownloadSelectionChanged(); } break; case "click": { const target = /** @type {HTMLElement} */ (event.target); if ( target === this.elements?.alwaysTranslateLanguagesButton || target.closest?.("#translationsAlwaysTranslateLanguagesButton") ) { await this.onAlwaysTranslateLanguageChosen( this.elements?.alwaysTranslateLanguagesSelect?.value ?? "" ); break; } if ( target === this.elements?.neverTranslateLanguagesButton || target.closest?.("#translationsNeverTranslateLanguagesButton") ) { await this.onNeverTranslateLanguageChosen( this.elements?.neverTranslateLanguagesSelect?.value ?? "" ); break; } if ( target === this.elements?.downloadLanguagesButton || target.closest?.("#translationsDownloadLanguagesButton") ) { this.onDownloadLanguageButtonClicked(); break; } const downloadRemoveButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS}`) ); if (downloadRemoveButton?.dataset.langTag) { this.onDeleteButtonClicked(downloadRemoveButton.dataset.langTag); break; } const downloadDeleteConfirmButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS}`) ); if (downloadDeleteConfirmButton?.dataset.langTag) { this.confirmDeleteLanguage( downloadDeleteConfirmButton.dataset.langTag ); break; } const downloadDeleteCancelButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS}`) ); if (downloadDeleteCancelButton?.dataset.langTag) { this.cancelDeleteLanguage(downloadDeleteCancelButton.dataset.langTag); break; } const downloadRetryButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS}`) ); if (downloadRetryButton?.dataset.langTag) { this.retryDownloadLanguage(downloadRetryButton.dataset.langTag); break; } const alwaysRemoveButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`) ); if (alwaysRemoveButton?.dataset.langTag) { this.removeAlwaysTranslateLanguage( alwaysRemoveButton.dataset.langTag ); break; } const neverRemoveButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`) ); if (neverRemoveButton?.dataset.langTag) { this.removeNeverTranslateLanguage(neverRemoveButton.dataset.langTag); break; } const neverSiteRemoveButton = /** @type {HTMLElement|null} */ ( target.closest?.(`.${NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS}`) ); if (neverSiteRemoveButton?.dataset.origin) { this.removeNeverTranslateSite(neverSiteRemoveButton.dataset.origin); } break; } case "unload": this.teardown(); break; } }, /** * Observer for translations pref changes. * * @param {any} subject * @param {string} topic * @param {string} data */ observe(subject, topic, data) { if (topic === TOPIC_TRANSLATIONS_PREF_CHANGED) { if (data === ALWAYS_TRANSLATE_LANGS_PREF) { this.refreshAlwaysTranslateLanguages().catch(console.error); } else if (data === NEVER_TRANSLATE_LANGS_PREF) { this.refreshNeverTranslateLanguages().catch(console.error); } } else if (topic === "perm-changed") { this.handlePermissionChange(subject, data); } }, /** * Runs when the translations sub-pane is shown. * * @param {string} category * @returns {Promise} */ async handlePaneShown(category) { if (category !== "paneTranslations") { return; } if (this.initPromise) { await this.initPromise; await this.refreshAlwaysTranslateLanguages(); await this.refreshNeverTranslateLanguages(); this.refreshNeverTranslateSites(); await this.refreshDownloadedLanguages(); this.dispatchInitializedTestEvent(); return; } if (this.initialized) { await this.refreshAlwaysTranslateLanguages(); await this.refreshNeverTranslateLanguages(); this.refreshNeverTranslateSites(); await this.refreshDownloadedLanguages(); this.dispatchInitializedTestEvent(); return; } this.initPromise = this.init(); await this.initPromise; this.initPromise = null; }, /** * Ensure the translations pane has finished rendering. * * @returns {Promise} */ async ensurePaneRendered() { if (this.paneRenderPromise) { await this.paneRenderPromise; return; } /** * @typedef {HTMLElement & { getUpdateComplete?: () => Promise }} ElementWithUpdateComplete */ const pane = /** @type {ElementWithUpdateComplete|null} */ ( document.querySelector('setting-pane[data-category="paneTranslations"]') ); const groups = Array.from( document.querySelectorAll( 'setting-group[groupid="translationsAutomaticTranslation"], setting-group[groupid="translationsDownloadLanguages"]' ) ); const promises = []; if (pane?.getUpdateComplete) { promises.push(pane.getUpdateComplete()); } for (const group of groups) { if (group?.getUpdateComplete) { promises.push(group.getUpdateComplete()); } } if (promises.length) { this.paneRenderPromise = (async () => { const results = await Promise.allSettled(promises); const failure = results.find(result => result.status === "rejected"); if (failure && failure.reason) { console.warn("Translations pane render wait failed", failure.reason); } })(); await this.paneRenderPromise; } }, /** * Initialize the translations settings UI. * * @returns {Promise} */ async init() { await this.ensurePaneRendered(); this.cacheElements(); if ( !this.elements?.alwaysTranslateLanguagesGroup || !this.elements?.alwaysTranslateLanguagesSelect || !this.elements?.alwaysTranslateLanguagesButton || !this.elements?.alwaysTranslateLanguagesNoneRow || !this.elements?.neverTranslateLanguagesGroup || !this.elements?.neverTranslateLanguagesSelect || !this.elements?.neverTranslateLanguagesButton || !this.elements?.neverTranslateLanguagesNoneRow || !this.elements?.neverTranslateSitesGroup || !this.elements?.downloadLanguagesGroup || !this.elements?.downloadLanguagesSelect || !this.elements?.downloadLanguagesButton || !this.elements?.downloadLanguagesNoneRow ) { this.dispatchInitializedTestEvent(); return; } try { this.numberFormatter = null; this.languageDisplayNames = TranslationsParent.createLanguageDisplayNames(); this.supportedLanguages = await TranslationsParent.getSupportedLanguages(); this.languageList = TranslationsParent.getLanguageList( this.supportedLanguages ); await this.loadLanguageSizes(); await this.refreshDownloadedLanguages(); } catch (error) { console.error("Failed to initialize translations settings UI", error); this.elements.alwaysTranslateLanguagesSelect.disabled = true; this.elements.alwaysTranslateLanguagesButton.disabled = true; this.elements.neverTranslateLanguagesSelect.disabled = true; this.elements.neverTranslateLanguagesButton.disabled = true; this.elements.downloadLanguagesSelect.disabled = true; this.setDownloadLanguageButtonDisabledState(true); this.dispatchInitializedTestEvent(); return; } this.elements.alwaysTranslateLanguagesSelect.disabled = false; this.elements.alwaysTranslateLanguagesButton.disabled = true; this.elements.neverTranslateLanguagesSelect.disabled = false; this.elements.neverTranslateLanguagesButton.disabled = true; this.elements.downloadLanguagesSelect.disabled = false; this.resetDownloadSelect(); this.setDownloadLanguageButtonDisabledState(true); await this.buildAlwaysTranslateSelectOptions(); await this.buildNeverTranslateSelectOptions(); await this.buildDownloadSelectOptions(); await this.renderDownloadLanguages(); this.elements.alwaysTranslateLanguagesSelect.addEventListener( "change", this ); this.elements.alwaysTranslateLanguagesButton.addEventListener( "click", this ); this.elements.alwaysTranslateLanguagesGroup.addEventListener("click", this); this.elements.neverTranslateLanguagesSelect.addEventListener( "change", this ); this.elements.neverTranslateLanguagesButton.addEventListener("click", this); this.elements.neverTranslateLanguagesGroup.addEventListener("click", this); this.elements.neverTranslateSitesGroup.addEventListener("click", this); this.elements.downloadLanguagesSelect.addEventListener("change", this); this.elements.downloadLanguagesGroup.addEventListener("click", this); this.elements.downloadLanguagesButton.addEventListener("click", this); Services.obs.addObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED); Services.obs.addObserver(this, "perm-changed"); window.addEventListener("unload", this); await this.refreshAlwaysTranslateLanguages(); await this.refreshNeverTranslateLanguages(); this.refreshNeverTranslateSites(); this.initialized = true; this.dispatchInitializedTestEvent(); }, /** * Dispatch the test-only Initialized event and mark the document as ready. */ dispatchInitializedTestEvent() { dispatchTestEvent("Initialized"); }, /** * Cache the DOM elements we interact with. */ cacheElements() { if (this.elements) { return; } const elements = { alwaysTranslateLanguagesGroup: /** @type {HTMLElement} */ ( document.getElementById("translationsAlwaysTranslateLanguagesGroup") ), alwaysTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ ( document.getElementById("translationsAlwaysTranslateLanguagesSelect") ), alwaysTranslateLanguagesButton: /** @type {HTMLButtonElement} */ ( document.getElementById("translationsAlwaysTranslateLanguagesButton") ), alwaysTranslateLanguagesNoneRow: /** @type {HTMLElement} */ ( document.getElementById("translationsAlwaysTranslateLanguagesNoneRow") ), neverTranslateLanguagesGroup: /** @type {HTMLElement} */ ( document.getElementById("translationsNeverTranslateLanguagesGroup") ), neverTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ ( document.getElementById("translationsNeverTranslateLanguagesSelect") ), neverTranslateLanguagesButton: /** @type {HTMLButtonElement} */ ( document.getElementById("translationsNeverTranslateLanguagesButton") ), neverTranslateLanguagesNoneRow: /** @type {HTMLElement} */ ( document.getElementById("translationsNeverTranslateLanguagesNoneRow") ), neverTranslateSitesGroup: /** @type {HTMLElement} */ ( document.getElementById("translationsNeverTranslateSitesGroup") ), neverTranslateSitesRow: /** @type {HTMLElement} */ ( document.getElementById("translationsNeverTranslateSitesRow") ), neverTranslateSitesNoneRow: /** @type {HTMLElement} */ ( document.getElementById("translationsNeverTranslateSitesNoneRow") ), downloadLanguagesGroup: /** @type {HTMLElement} */ ( document.getElementById("translationsDownloadLanguagesGroup") ), downloadLanguagesSelect: /** @type {HTMLSelectElement} */ ( document.getElementById("translationsDownloadLanguagesSelect") ), downloadLanguagesButton: /** @type {HTMLButtonElement} */ ( document.getElementById("translationsDownloadLanguagesButton") ), downloadLanguagesNoneRow: /** @type {HTMLElement} */ ( document.getElementById("translationsDownloadLanguagesNoneRow") ), }; if ( !elements.alwaysTranslateLanguagesGroup || !elements.alwaysTranslateLanguagesSelect || !elements.alwaysTranslateLanguagesNoneRow || !elements.neverTranslateLanguagesGroup || !elements.neverTranslateLanguagesSelect || !elements.neverTranslateLanguagesNoneRow ) { return; } this.elements = elements; }, /** * Load the download sizes for all supported languages and cache them. * * @returns {Promise} */ async loadLanguageSizes() { if (!this.languageList?.length) { this.languageSizes = new Map(); return; } const sizes = await Promise.all( this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => { try { return /** @type {[string, number]} */ ([ langTag, await TranslationsParent.getLanguageSize(langTag), ]); } catch (error) { console.error(`Failed to get size for ${langTag}`, error); return /** @type {[string, number]} */ ([langTag, 0]); } }) ); this.languageSizes = new Map(sizes); }, /** * Format a download size for display. * * @param {string} langTag * @returns {string|null} */ formatLanguageSize(langTag) { const sizeBytes = this.languageSizes?.get(langTag); if (!sizeBytes && sizeBytes !== 0) { return null; } const sizeInMB = sizeBytes / (1024 * 1024); if (!Number.isFinite(sizeInMB)) { return null; } return this.getNumberFormatter().format(sizeInMB); }, /** * Lazily create and return a number formatter for the app locale. * * @returns {Intl.NumberFormat} */ getNumberFormatter() { if (this.numberFormatter) { return this.numberFormatter; } this.numberFormatter = new Intl.NumberFormat( Services.locale.appLocaleAsBCP47, { minimumFractionDigits: 0, maximumFractionDigits: 1, } ); return this.numberFormatter; }, /** * Build the display label for a download language including its size. * * @param {string} langTag * @returns {Promise} */ async formatDownloadLabel(langTag) { const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; const sizeLabel = this.formatLanguageSize(langTag); if (!sizeLabel) { return languageLabel; } try { return await document.l10n.formatValue( "settings-translations-subpage-download-language-option", { language: languageLabel, size: sizeLabel } ); } catch (error) { console.error("Failed to format download language label", error); return `${languageLabel} (${sizeLabel})`; } }, /** * Populate the select options for download languages with sizes. * * @returns {Promise} */ async buildDownloadSelectOptions() { const select = this.elements?.downloadLanguagesSelect; if (!select || !this.supportedLanguages?.sourceLanguages?.length) { return; } const placeholder = select.querySelector('moz-option[value=""]'); for (const option of select.querySelectorAll("moz-option")) { if (option !== placeholder) { option.remove(); } } const sourceLanguages = [...this.supportedLanguages.sourceLanguages] .filter(({ langTag }) => langTag !== "en") .sort((lhs, rhs) => ( this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName ).localeCompare( this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName ) ); for (const { langTag, displayName } of sourceLanguages) { const option = document.createElement("moz-option"); option.setAttribute("value", langTag); const label = (await this.formatDownloadLabel(langTag)) ?? this.formatLanguageLabel(langTag) ?? displayName; option.setAttribute("label", label); const sizeLabel = this.formatLanguageSize(langTag) ?? ""; if (sizeLabel) { document.l10n.setAttributes( option, "settings-translations-subpage-download-language-option", { language: this.formatLanguageLabel(langTag) ?? displayName, size: sizeLabel, } ); } select.appendChild(option); } this.updateDownloadSelectOptionState(); this.resetDownloadSelect(); }, /** * Disable already-downloaded or downloading languages in the download select. */ updateDownloadSelectOptionState({ preserveSelection = false } = {}) { const select = this.elements?.downloadLanguagesSelect; if (!select) { return; } for (const option of select.querySelectorAll("moz-option")) { const value = option.getAttribute("value"); if (!value) { continue; } const isDisabled = this.downloadedLanguageTags.has(value) || this.downloadingLanguageTags.has(value); option.toggleAttribute("disabled", isDisabled); } if (preserveSelection) { this.updateDownloadLanguageButtonDisabled(); } else { this.resetDownloadSelect(); } dispatchTestEvent("DownloadedLanguagesSelectOptionsUpdated"); }, /** * Handle a selection in the "Always translate languages" dropdown. * * @param {string} langTag */ async onAlwaysTranslateLanguageChosen(langTag) { if (!langTag) { this.updateAlwaysTranslateAddButtonDisabledState(); return; } if (this.shouldDisableAlwaysTranslateAddButton()) { this.updateAlwaysTranslateAddButtonDisabledState(); return; } TranslationsParent.addLangTagToPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); TranslationsParent.removeLangTagFromPref( langTag, NEVER_TRANSLATE_LANGS_PREF ); await this.resetAlwaysTranslateSelect(); }, /** * Handle a selection change in the always-translate dropdown. */ onAlwaysTranslateLanguageSelectionChanged() { this.updateAlwaysTranslateAddButtonDisabledState(); }, /** * Whether the add button for always-translate languages should be disabled. * * @returns {boolean} */ shouldDisableAlwaysTranslateAddButton() { const select = this.elements?.alwaysTranslateLanguagesSelect; if (!select || select.disabled) { return true; } const langTag = select.value; if (!langTag) { return true; } const option = /** @type {HTMLElement|null} */ ( select.querySelector(`moz-option[value="${langTag}"]`) ); return option?.hasAttribute("disabled") ?? false; }, /** * Set the add button enabled state for always-translate languages. * * @param {boolean} isDisabled */ setAlwaysTranslateAddButtonDisabledState(isDisabled) { if (!this.elements?.alwaysTranslateLanguagesButton) { return; } const wasDisabled = this.elements.alwaysTranslateLanguagesButton.disabled; this.elements.alwaysTranslateLanguagesButton.disabled = isDisabled; if (wasDisabled !== isDisabled) { dispatchTestEvent( isDisabled ? "AlwaysTranslateLanguagesAddButtonDisabled" : "AlwaysTranslateLanguagesAddButtonEnabled" ); } }, /** * Update the add button enabled state for always-translate languages. */ updateAlwaysTranslateAddButtonDisabledState() { this.setAlwaysTranslateAddButtonDisabledState( this.shouldDisableAlwaysTranslateAddButton() ); }, /** * Remove the given language from the always translate list. * * @param {string} langTag */ removeAlwaysTranslateLanguage(langTag) { TranslationsParent.removeLangTagFromPref( langTag, ALWAYS_TRANSLATE_LANGS_PREF ); }, async resetSelect(select, settingId) { const setting = Preferences.getSetting?.(settingId); if (setting) { setting.value = ""; } if (!select) { return; } if (select.updateComplete) { await select.updateComplete; } select.value = ""; if (select.inputEl) { select.inputEl.value = ""; } if (select.updateComplete) { await select.updateComplete; } }, /** * Reset the dropdown back to the placeholder value and underlying setting state. */ async resetAlwaysTranslateSelect() { await this.resetSelect( this.elements?.alwaysTranslateLanguagesSelect, "translationsAlwaysTranslateLanguagesSelect" ); this.updateAlwaysTranslateAddButtonDisabledState(); }, /** * Refresh the rendered list of always-translate languages to match prefs. */ async refreshAlwaysTranslateLanguages() { if (!this.elements?.alwaysTranslateLanguagesGroup) { return; } const langTags = Array.from( TranslationsParent.getAlwaysTranslateLanguages?.() ?? [] ); if (this.alwaysTranslateLanguageTags) { for (const langTag of langTags) { if (this.alwaysTranslateLanguageTags.has(langTag)) { continue; } TranslationsParent.removeLangTagFromPref( langTag, NEVER_TRANSLATE_LANGS_PREF ); } } this.alwaysTranslateLanguageTags = new Set(langTags); this.renderAlwaysTranslateLanguages(langTags); await this.updateAlwaysTranslateSelectOptionState(); }, /** * Render the current set of always-translate languages into the list UI. * * @param {string[]} langTags */ renderAlwaysTranslateLanguages(langTags) { const { alwaysTranslateLanguagesGroup, alwaysTranslateLanguagesNoneRow } = this.elements; for (const item of alwaysTranslateLanguagesGroup.querySelectorAll( `.${ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS}` )) { item.remove(); } const previousEmptyStateVisible = alwaysTranslateLanguagesNoneRow && !alwaysTranslateLanguagesNoneRow.hidden; if (alwaysTranslateLanguagesNoneRow) { const hasLanguages = !!langTags.length; alwaysTranslateLanguagesNoneRow.hidden = hasLanguages; if (hasLanguages && alwaysTranslateLanguagesNoneRow.isConnected) { alwaysTranslateLanguagesNoneRow.remove(); } else if ( !hasLanguages && !alwaysTranslateLanguagesNoneRow.isConnected ) { alwaysTranslateLanguagesGroup.appendChild( alwaysTranslateLanguagesNoneRow ); } } const sortedLangTags = [...langTags].sort((langTagA, langTagB) => { const labelA = this.formatLanguageLabel(langTagA) ?? langTagA; const labelB = this.formatLanguageLabel(langTagB) ?? langTagB; return labelA.localeCompare(labelB); }); for (const langTag of sortedLangTags) { const label = this.formatLanguageLabel(langTag); if (!label) { continue; } const removeButton = document.createElement("moz-button"); removeButton.setAttribute("slot", "actions-start"); removeButton.setAttribute("type", "icon"); removeButton.setAttribute( "iconsrc", "chrome://global/skin/icons/delete.svg" ); removeButton.classList.add(ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS); removeButton.dataset.langTag = langTag; removeButton.setAttribute("aria-label", label); const item = document.createElement("moz-box-item"); item.classList.add(ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS); item.setAttribute("label", label); item.dataset.langTag = langTag; item.appendChild(removeButton); if ( alwaysTranslateLanguagesNoneRow && alwaysTranslateLanguagesNoneRow.parentElement === alwaysTranslateLanguagesGroup ) { alwaysTranslateLanguagesGroup.insertBefore( item, alwaysTranslateLanguagesNoneRow ); } else { alwaysTranslateLanguagesGroup.appendChild(item); } } dispatchTestEvent("AlwaysTranslateLanguagesRendered", { languages: langTags, count: langTags.length, }); const currentEmptyStateVisible = alwaysTranslateLanguagesNoneRow && !alwaysTranslateLanguagesNoneRow.hidden; if (previousEmptyStateVisible && !currentEmptyStateVisible) { dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateHidden"); } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateShown"); } }, /** * Format a language tag for display using the cached display names. * * @param {string} langTag * @returns {string|null} */ formatLanguageLabel(langTag) { try { return this.languageDisplayNames?.of(langTag) ?? null; } catch (error) { console.warn(`Failed to format language label for ${langTag}`, error); return null; } }, /** * Populate the select options for the supported source languages. */ async buildAlwaysTranslateSelectOptions() { const select = this.elements?.alwaysTranslateLanguagesSelect; if (!select || !this.supportedLanguages?.sourceLanguages?.length) { return; } const placeholder = select.querySelector('moz-option[value=""]'); for (const option of select.querySelectorAll("moz-option")) { if (option !== placeholder) { option.remove(); } } const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( (lhs, rhs) => ( this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName ).localeCompare( this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName ) ); for (const { langTag, displayName } of sourceLanguages) { const option = document.createElement("moz-option"); option.setAttribute("value", langTag); option.setAttribute( "label", this.formatLanguageLabel(langTag) ?? displayName ); select.appendChild(option); } await this.resetAlwaysTranslateSelect(); }, /** * Disable already-added languages in the select so they cannot be re-added. */ async updateAlwaysTranslateSelectOptionState() { const select = this.elements?.alwaysTranslateLanguagesSelect; if (!select) { return; } for (const option of select.querySelectorAll("moz-option")) { const value = option.getAttribute("value"); if (!value) { continue; } option.disabled = this.alwaysTranslateLanguageTags.has(value); } await this.resetAlwaysTranslateSelect(); dispatchTestEvent("AlwaysTranslateLanguagesSelectOptionsUpdated"); }, /** * Handle a selection in the "Never translate languages" dropdown. * * @param {string} langTag */ async onNeverTranslateLanguageChosen(langTag) { if (!langTag) { this.updateNeverTranslateAddButtonDisabledState(); return; } if (this.shouldDisableNeverTranslateAddButton()) { this.updateNeverTranslateAddButtonDisabledState(); return; } TranslationsParent.addLangTagToPref(langTag, NEVER_TRANSLATE_LANGS_PREF); TranslationsParent.removeLangTagFromPref( langTag, ALWAYS_TRANSLATE_LANGS_PREF ); await this.resetNeverTranslateSelect(); }, /** * Handle a selection change in the never-translate dropdown. */ onNeverTranslateLanguageSelectionChanged() { this.updateNeverTranslateAddButtonDisabledState(); }, /** * Whether the add button for never-translate languages should be disabled. * * @returns {boolean} */ shouldDisableNeverTranslateAddButton() { const select = this.elements?.neverTranslateLanguagesSelect; if (!select || select.disabled) { return true; } const langTag = select.value; if (!langTag) { return true; } const option = /** @type {HTMLElement|null} */ ( select.querySelector(`moz-option[value="${langTag}"]`) ); return option?.hasAttribute("disabled") ?? false; }, /** * Set the add button enabled state for never-translate languages. * * @param {boolean} isDisabled */ setNeverTranslateAddButtonDisabledState(isDisabled) { if (!this.elements?.neverTranslateLanguagesButton) { return; } const wasDisabled = this.elements.neverTranslateLanguagesButton.disabled; this.elements.neverTranslateLanguagesButton.disabled = isDisabled; if (wasDisabled !== isDisabled) { dispatchTestEvent( isDisabled ? "NeverTranslateLanguagesAddButtonDisabled" : "NeverTranslateLanguagesAddButtonEnabled" ); } }, /** * Update the add button enabled state for never-translate languages. */ updateNeverTranslateAddButtonDisabledState() { this.setNeverTranslateAddButtonDisabledState( this.shouldDisableNeverTranslateAddButton() ); }, /** * Remove the given language from the never translate list. * * @param {string} langTag */ removeNeverTranslateLanguage(langTag) { TranslationsParent.removeLangTagFromPref( langTag, NEVER_TRANSLATE_LANGS_PREF ); }, /** * Reset the dropdown back to the placeholder value and underlying setting state. */ async resetNeverTranslateSelect() { await this.resetSelect( this.elements?.neverTranslateLanguagesSelect, "translationsNeverTranslateLanguagesSelect" ); this.updateNeverTranslateAddButtonDisabledState(); }, /** * Refresh the rendered list of never-translate languages to match prefs. */ async refreshNeverTranslateLanguages() { if (!this.elements?.neverTranslateLanguagesGroup) { return; } const langTags = Array.from( TranslationsParent.getNeverTranslateLanguages?.() ?? [] ); this.neverTranslateLanguageTags = new Set(langTags); this.renderNeverTranslateLanguages(langTags); await this.updateNeverTranslateSelectOptionState(); }, /** * Render the current set of never-translate languages into the list UI. * * @param {string[]} langTags */ renderNeverTranslateLanguages(langTags) { const { neverTranslateLanguagesGroup, neverTranslateLanguagesNoneRow } = this.elements; for (const item of neverTranslateLanguagesGroup.querySelectorAll( `.${NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS}` )) { item.remove(); } const previousEmptyStateVisible = neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden; if (neverTranslateLanguagesNoneRow) { const hasLanguages = Boolean(langTags.length); neverTranslateLanguagesNoneRow.hidden = hasLanguages; if (hasLanguages && neverTranslateLanguagesNoneRow.isConnected) { neverTranslateLanguagesNoneRow.remove(); } else if (!hasLanguages && !neverTranslateLanguagesNoneRow.isConnected) { neverTranslateLanguagesGroup.appendChild( neverTranslateLanguagesNoneRow ); } } const sortedLangTags = [...langTags].sort((langTagA, langTagB) => { const labelA = this.formatLanguageLabel(langTagA) ?? langTagA; const labelB = this.formatLanguageLabel(langTagB) ?? langTagB; return labelA.localeCompare(labelB); }); for (const langTag of sortedLangTags) { const label = this.formatLanguageLabel(langTag); if (!label) { continue; } const removeButton = document.createElement("moz-button"); removeButton.setAttribute("slot", "actions-start"); removeButton.setAttribute("type", "icon"); removeButton.setAttribute( "iconsrc", "chrome://global/skin/icons/delete.svg" ); removeButton.classList.add(NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS); removeButton.dataset.langTag = langTag; removeButton.setAttribute("aria-label", label); const item = document.createElement("moz-box-item"); item.classList.add(NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS); item.setAttribute("label", label); item.dataset.langTag = langTag; item.appendChild(removeButton); if ( neverTranslateLanguagesNoneRow && neverTranslateLanguagesNoneRow.parentElement === neverTranslateLanguagesGroup ) { neverTranslateLanguagesGroup.insertBefore( item, neverTranslateLanguagesNoneRow ); } else { neverTranslateLanguagesGroup.appendChild(item); } } dispatchTestEvent("NeverTranslateLanguagesRendered", { languages: langTags, count: langTags.length, }); const currentEmptyStateVisible = neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden; if (previousEmptyStateVisible && !currentEmptyStateVisible) { dispatchTestEvent("NeverTranslateLanguagesEmptyStateHidden"); } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { dispatchTestEvent("NeverTranslateLanguagesEmptyStateShown"); } }, /** * Populate the select options for the supported source languages. */ async buildNeverTranslateSelectOptions() { const select = this.elements?.neverTranslateLanguagesSelect; if (!select || !this.supportedLanguages?.sourceLanguages?.length) { return; } const placeholder = select.querySelector('moz-option[value=""]'); for (const option of select.querySelectorAll("moz-option")) { if (option !== placeholder) { option.remove(); } } const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( (lhs, rhs) => ( this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName ).localeCompare( this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName ) ); for (const { langTag, displayName } of sourceLanguages) { const option = document.createElement("moz-option"); option.setAttribute("value", langTag); option.setAttribute( "label", this.formatLanguageLabel(langTag) ?? displayName ); select.appendChild(option); } await this.resetNeverTranslateSelect(); }, /** * Disable already-added languages in the select so they cannot be re-added. */ async updateNeverTranslateSelectOptionState() { const select = this.elements?.neverTranslateLanguagesSelect; if (!select) { return; } for (const option of select.querySelectorAll("moz-option")) { const value = option.getAttribute("value"); if (!value) { continue; } option.disabled = this.neverTranslateLanguageTags.has(value); } await this.resetNeverTranslateSelect(); dispatchTestEvent("NeverTranslateLanguagesSelectOptionsUpdated"); }, /** * Refresh the rendered list of never-translate sites. */ refreshNeverTranslateSites() { if (!this.elements?.neverTranslateSitesGroup) { return; } /** @type {string[]} */ let siteOrigins = []; try { siteOrigins = TranslationsParent.listNeverTranslateSites() ?? []; } catch (error) { console.error("Failed to list never translate sites", error); } this.neverTranslateSiteOrigins = new Set(siteOrigins); this.renderNeverTranslateSites(siteOrigins); }, /** * Render the never-translate sites list. * * @param {string[]} siteOrigins */ renderNeverTranslateSites(siteOrigins) { const { neverTranslateSitesGroup, neverTranslateSitesNoneRow } = this.elements ?? {}; if (!neverTranslateSitesGroup) { return; } for (const item of neverTranslateSitesGroup.querySelectorAll( `.${NEVER_TRANSLATE_SITE_ITEM_CLASS}` )) { item.remove(); } const previousEmptyStateVisible = neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden; if (neverTranslateSitesNoneRow) { const hasSites = Boolean(siteOrigins.length); neverTranslateSitesNoneRow.hidden = hasSites; if (hasSites && neverTranslateSitesNoneRow.isConnected) { neverTranslateSitesNoneRow.remove(); } else if (!hasSites && !neverTranslateSitesNoneRow.isConnected) { neverTranslateSitesGroup.appendChild(neverTranslateSitesNoneRow); } } const sortedOrigins = [...siteOrigins].sort((originA, originB) => { return this.getSiteSortKey(originA).localeCompare( this.getSiteSortKey(originB) ); }); for (const origin of sortedOrigins) { const removeButton = document.createElement("moz-button"); removeButton.setAttribute("slot", "actions-start"); removeButton.setAttribute("type", "icon"); removeButton.setAttribute( "iconsrc", "chrome://global/skin/icons/delete.svg" ); removeButton.classList.add(NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS); removeButton.dataset.origin = origin; removeButton.setAttribute("aria-label", origin); const item = document.createElement("moz-box-item"); item.classList.add(NEVER_TRANSLATE_SITE_ITEM_CLASS); item.setAttribute("label", origin); item.dataset.origin = origin; item.appendChild(removeButton); if ( neverTranslateSitesNoneRow && neverTranslateSitesNoneRow.parentElement === neverTranslateSitesGroup ) { neverTranslateSitesGroup.insertBefore(item, neverTranslateSitesNoneRow); } else { neverTranslateSitesGroup.appendChild(item); } } dispatchTestEvent("NeverTranslateSitesRendered", { sites: siteOrigins, count: siteOrigins.length, }); const currentEmptyStateVisible = neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden; if (previousEmptyStateVisible && !currentEmptyStateVisible) { dispatchTestEvent("NeverTranslateSitesEmptyStateHidden"); } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { dispatchTestEvent("NeverTranslateSitesEmptyStateShown"); } }, /** * Remove a site from the never-translate list. * * @param {string} origin */ removeNeverTranslateSite(origin) { if (!origin || !this.neverTranslateSiteOrigins.has(origin)) { return; } try { TranslationsParent.setNeverTranslateSiteByOrigin(false, origin); } catch (error) { console.error("Failed to remove never translate site", error); return; } this.refreshNeverTranslateSites(); }, /** * Create a sort key that ignores protocol differences. * * @param {string} origin * @returns {string} */ getSiteSortKey(origin) { try { return Services.io.newURI(origin).asciiHostPort; } catch { return origin; } }, /** * Handle a selection change in the download dropdown. */ onDownloadSelectionChanged() { this.updateDownloadLanguageButtonDisabled(); }, /** * Whether the download button should be disabled based on selection state. * * @returns {boolean} */ shouldDisableDownloadLanguageButton() { const select = this.elements?.downloadLanguagesSelect; if (!select || this.currentDownloadLangTag) { return true; } const langTag = select.value; if (!langTag) { return true; } const option = /** @type {HTMLElement|null} */ ( select.querySelector(`moz-option[value="${langTag}"]`) ); return option?.hasAttribute("disabled") ?? false; }, /** * Set the download button state and dispatch test events when it changes. * * @param {boolean} isDisabled */ setDownloadLanguageButtonDisabledState(isDisabled) { const button = this.elements?.downloadLanguagesButton; if (!button) { return; } const wasDisabled = button.disabled; button.disabled = isDisabled; if (wasDisabled !== isDisabled) { dispatchTestEvent( isDisabled ? "DownloadLanguageButtonDisabled" : "DownloadLanguageButtonEnabled" ); } }, /** * Update the enabled state of the download button. */ updateDownloadLanguageButtonDisabled() { this.setDownloadLanguageButtonDisabledState( this.shouldDisableDownloadLanguageButton() ); }, /** * Handle a click on the download button. * * @returns {Promise} */ async onDownloadLanguageButtonClicked() { const langTag = this.elements?.downloadLanguagesSelect?.value; if (!langTag || this.currentDownloadLangTag) { return; } this.downloadPendingDeleteLanguageTags.clear(); this.downloadFailedLanguageTags.clear(); this.currentDownloadLangTag = langTag; this.downloadingLanguageTags.add(langTag); this.setDownloadControlsDisabled(true); dispatchTestEvent("DownloadStarted", { langTag }); await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState({ preserveSelection: true }); let downloadSucceeded = false; try { await TranslationsParent.downloadLanguageFiles(langTag); this.downloadedLanguageTags.add(langTag); downloadSucceeded = true; dispatchTestEvent("DownloadCompleted", { langTag }); } catch (error) { dispatchTestEvent("DownloadFailed", { langTag }); console.error("Failed to download language files", error); this.downloadFailedLanguageTags.add(langTag); } finally { this.downloadingLanguageTags.delete(langTag); this.currentDownloadLangTag = null; this.setDownloadControlsDisabled(false); await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState({ preserveSelection: !downloadSucceeded, }); this.updateDownloadLanguageButtonDisabled(); } }, /** * Disable or enable the download controls. * * @param {boolean} isDisabled */ setDownloadControlsDisabled(isDisabled) { if (this.elements?.downloadLanguagesSelect) { this.elements.downloadLanguagesSelect.disabled = isDisabled; } this.setDownloadLanguageButtonDisabledState( isDisabled || this.shouldDisableDownloadLanguageButton() ); }, /** * Toggle ghost styling on icon buttons. * * @param {HTMLElement|null} button * @param {boolean} isGhost */ setIconButtonGhostState(button, isGhost) { if (!button) { return; } const type = isGhost ? "icon ghost" : "icon"; if (button.getAttribute("type") !== type) { button.setAttribute("type", type); } }, /** * Reset the download dropdown back to its placeholder value. */ resetDownloadSelect() { if (this.elements?.downloadLanguagesSelect) { this.elements.downloadLanguagesSelect.value = ""; } const setting = Preferences.getSetting?.( "translationsDownloadLanguagesSelect" ); if (setting) { setting.value = ""; } this.updateDownloadLanguageButtonDisabled(); }, /** * Refresh download state from disk and update the UI. * * @returns {Promise} */ async refreshDownloadedLanguages() { if (!this.languageList?.length) { return; } this.downloadPendingDeleteLanguageTags.clear(); const downloaded = await Promise.all( this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => { try { const hasFiles = await TranslationsParent.hasAllFilesForLanguage(langTag); return /** @type {[string, boolean]} */ ([langTag, hasFiles]); } catch (error) { console.error( `Failed to check download status for ${langTag}`, error ); return /** @type {[string, boolean]} */ ([langTag, false]); } }) ); this.downloadedLanguageTags = new Set( downloaded.filter(([, isDownloaded]) => isDownloaded).map(([tag]) => tag) ); for (const [langTag, isDownloaded] of downloaded) { if (isDownloaded) { this.downloadingLanguageTags.delete(langTag); this.downloadFailedLanguageTags.delete(langTag); } else { this.downloadPendingDeleteLanguageTags.delete(langTag); } } await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState(); this.updateDownloadLanguageButtonDisabled(); }, /** * Create a delete confirmation item with warning icon and action buttons. * * @param {string} langTag * @param {HTMLElement} item - The moz-box-item element to populate. * @returns {Promise} */ async createDeleteConfirmationItem(langTag, item, disableActions = false) { const warningButton = document.createElement("moz-button"); warningButton.setAttribute("slot", "actions-start"); warningButton.setAttribute("type", "icon"); warningButton.setAttribute("iconsrc", DOWNLOAD_WARNING_ICON); warningButton.style.pointerEvents = "none"; warningButton.style.color = "var(--icon-color-warning)"; warningButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); warningButton.dataset.langTag = langTag; this.setIconButtonGhostState(warningButton, true); const sizeLabel = this.formatLanguageSize(langTag) ?? "0"; const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; const confirmContent = document.createElement("div"); confirmContent.style.cssText = "display: flex; align-items: center; gap: var(--space-small);"; const confirmText = document.createElement("span"); confirmText.textContent = await document.l10n.formatValue( "settings-translations-subpage-download-delete-confirm", { language: languageLabel, size: sizeLabel } ); const buttonGroup = document.createElement("moz-button-group"); const deleteButton = document.createElement("moz-button"); deleteButton.setAttribute("type", "destructive"); deleteButton.setAttribute("size", "small"); deleteButton.disabled = disableActions; document.l10n.setAttributes( deleteButton, "settings-translations-subpage-download-delete-button" ); deleteButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS); deleteButton.dataset.langTag = langTag; const cancelButton = document.createElement("moz-button"); cancelButton.setAttribute("type", "default"); cancelButton.setAttribute("size", "small"); cancelButton.disabled = disableActions; document.l10n.setAttributes( cancelButton, "settings-translations-subpage-download-cancel-button" ); cancelButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS); cancelButton.dataset.langTag = langTag; confirmContent.appendChild(confirmText); buttonGroup.append(deleteButton, cancelButton); confirmContent.appendChild(buttonGroup); if (!deleteButton.disabled) { requestAnimationFrame(() => { requestAnimationFrame(() => { if (deleteButton.isConnected) { deleteButton.focus({ focusVisible: true }); } }); }); } item.appendChild(warningButton); item.appendChild(confirmContent); }, /** * Create a failed download item with error icon and retry button. * * @param {string} langTag * @param {HTMLElement} item - The moz-box-item element to populate. * @returns {Promise} */ async createFailedDownloadItem(langTag, item, disableActions = false) { const errorButton = document.createElement("moz-button"); errorButton.setAttribute("slot", "actions-start"); errorButton.setAttribute("type", "icon"); errorButton.setAttribute("iconsrc", DOWNLOAD_ERROR_ICON); errorButton.style.pointerEvents = "none"; errorButton.style.color = "var(--text-color-error)"; errorButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); errorButton.dataset.langTag = langTag; this.setIconButtonGhostState(errorButton, true); const sizeLabel = this.formatLanguageSize(langTag) ?? "0"; const languageLabel = this.formatLanguageLabel(langTag) ?? langTag; const errorContent = document.createElement("div"); errorContent.style.cssText = "display: flex; align-items: center; gap: var(--space-small);"; const errorText = document.createElement("span"); document.l10n.setAttributes( errorText, "settings-translations-subpage-download-error", { language: languageLabel, size: sizeLabel } ); const retryButton = document.createElement("moz-button"); retryButton.setAttribute("type", "text"); retryButton.setAttribute("size", "small"); retryButton.disabled = disableActions; document.l10n.setAttributes( retryButton, "settings-translations-subpage-download-retry-button" ); retryButton.classList.add(DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS); retryButton.dataset.langTag = langTag; errorContent.appendChild(errorText); errorContent.appendChild(retryButton); if (!retryButton.disabled) { requestAnimationFrame(() => { requestAnimationFrame(() => { if (retryButton.isConnected) { retryButton.focus({ focusVisible: true }); } }); }); } item.appendChild(errorButton); item.appendChild(errorContent); }, /** * Create a download/remove button for downloaded or downloading language items. * * @param {string} langTag * @param {boolean} isDownloading * @param {HTMLElement} item - The moz-box-item element to populate. * @param {string} progressLabel - The localized "Downloading..." text. * @returns {Promise} - Returns false if the item should be skipped. */ async createDownloadLanguageItem( langTag, isDownloading, item, progressLabel, disableActions = false ) { const label = await this.formatDownloadLabel(langTag); if (!label) { return false; } const removeButton = document.createElement("moz-button"); removeButton.setAttribute("slot", "actions-start"); removeButton.setAttribute("type", "icon"); removeButton.setAttribute( "iconsrc", isDownloading ? DOWNLOAD_LOADING_ICON : DOWNLOAD_DELETE_ICON ); removeButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS); removeButton.dataset.langTag = langTag; removeButton.setAttribute("aria-label", label); if (isDownloading) { removeButton.style.pointerEvents = "none"; removeButton.disabled = false; } else { removeButton.disabled = disableActions; } this.setIconButtonGhostState( removeButton, isDownloading || removeButton.getAttribute("iconsrc") === DOWNLOAD_LOADING_ICON ); item.setAttribute("label", label); if (isDownloading) { item.setAttribute("description", progressLabel); } item.appendChild(removeButton); return true; }, /** * Render the downloaded (and downloading) languages list. * * @returns {Promise} */ async renderDownloadLanguages() { const { downloadLanguagesGroup, downloadLanguagesNoneRow } = this.elements ?? {}; if (!downloadLanguagesGroup) { return; } const isDownloadInProgress = Boolean(this.currentDownloadLangTag); const previousEmptyStateVisible = downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden; for (const item of downloadLanguagesGroup.querySelectorAll( `.${DOWNLOAD_LANGUAGE_ITEM_CLASS}` )) { item.remove(); } const langTags = [ ...Array.from( new Set([ ...Array.from(this.downloadedLanguageTags), ...Array.from(this.downloadingLanguageTags), ...Array.from(this.downloadFailedLanguageTags), ]) ), ]; if (downloadLanguagesNoneRow) { const hasLanguages = !!langTags.length; downloadLanguagesNoneRow.hidden = hasLanguages; if (hasLanguages && downloadLanguagesNoneRow.isConnected) { downloadLanguagesNoneRow.remove(); } else if (!hasLanguages && !downloadLanguagesNoneRow.isConnected) { downloadLanguagesGroup.appendChild(downloadLanguagesNoneRow); } } const currentEmptyStateVisible = downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden; if (previousEmptyStateVisible && !currentEmptyStateVisible) { dispatchTestEvent("DownloadedLanguagesEmptyStateHidden"); } else if (!previousEmptyStateVisible && currentEmptyStateVisible) { dispatchTestEvent("DownloadedLanguagesEmptyStateShown"); } const sortedLangTags = [...langTags].sort((lhs, rhs) => { const labelA = this.formatLanguageLabel(lhs) ?? lhs; const labelB = this.formatLanguageLabel(rhs) ?? rhs; return labelA.localeCompare(labelB); }); const progressLabel = await document.l10n.formatValue( "settings-translations-subpage-download-progress" ); for (const langTag of sortedLangTags) { const isDownloading = this.downloadingLanguageTags.has(langTag); const isFailed = this.downloadFailedLanguageTags.has(langTag); const isPendingDelete = this.downloadPendingDeleteLanguageTags.has(langTag); const item = document.createElement("moz-box-item"); item.classList.add(DOWNLOAD_LANGUAGE_ITEM_CLASS); item.dataset.langTag = langTag; if (isPendingDelete) { await this.createDeleteConfirmationItem( langTag, item, isDownloadInProgress ); } else if (isFailed) { item.classList.add(DOWNLOAD_LANGUAGE_FAILED_CLASS); await this.createFailedDownloadItem( langTag, item, isDownloadInProgress ); } else { const shouldAdd = await this.createDownloadLanguageItem( langTag, isDownloading, item, progressLabel, isDownloadInProgress ); if (!shouldAdd) { continue; } } if ( downloadLanguagesNoneRow && downloadLanguagesNoneRow.parentElement === downloadLanguagesGroup ) { downloadLanguagesGroup.insertBefore(item, downloadLanguagesNoneRow); } else { downloadLanguagesGroup.appendChild(item); } } dispatchTestEvent("DownloadedLanguagesRendered", { languages: sortedLangTags, count: sortedLangTags.length, downloading: sortedLangTags.filter(langTag => this.downloadingLanguageTags.has(langTag) ), }); }, /** * Show delete confirmation UI when delete button is clicked. * * @param {string} langTag * @returns {Promise} */ async onDeleteButtonClicked(langTag) { if (!langTag || !this.downloadedLanguageTags.has(langTag)) { return; } this.downloadFailedLanguageTags.clear(); this.downloadPendingDeleteLanguageTags.clear(); this.downloadPendingDeleteLanguageTags.add(langTag); await this.renderDownloadLanguages(); }, /** * Confirm and complete deletion of a language. * * @param {string} langTag * @returns {Promise} */ async confirmDeleteLanguage(langTag) { if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) { return; } this.downloadPendingDeleteLanguageTags.delete(langTag); try { await TranslationsParent.deleteLanguageFiles(langTag); this.downloadedLanguageTags.delete(langTag); dispatchTestEvent("DownloadDeleted", { langTag }); } catch (error) { console.error("Failed to remove downloaded language files", error); await this.renderDownloadLanguages(); return; } await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState(); this.updateDownloadLanguageButtonDisabled(); }, /** * Cancel delete confirmation and restore normal state. * * @param {string} langTag * @returns {Promise} */ async cancelDeleteLanguage(langTag) { if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) { return; } this.downloadPendingDeleteLanguageTags.delete(langTag); await this.renderDownloadLanguages(); }, /** * Retry downloading a failed language. * * @param {string} langTag * @returns {Promise} */ async retryDownloadLanguage(langTag) { if (!langTag || !this.downloadFailedLanguageTags.has(langTag)) { return; } this.downloadFailedLanguageTags.delete(langTag); this.currentDownloadLangTag = langTag; this.downloadingLanguageTags.add(langTag); this.setDownloadControlsDisabled(true); dispatchTestEvent("DownloadStarted", { langTag }); await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState({ preserveSelection: true }); let downloadSucceeded = false; try { await TranslationsParent.downloadLanguageFiles(langTag); this.downloadedLanguageTags.add(langTag); downloadSucceeded = true; dispatchTestEvent("DownloadCompleted", { langTag }); } catch (error) { console.error("Failed to download language files", error); this.downloadFailedLanguageTags.add(langTag); dispatchTestEvent("DownloadFailed", { langTag }); } finally { this.downloadingLanguageTags.delete(langTag); this.currentDownloadLangTag = null; this.setDownloadControlsDisabled(false); await this.renderDownloadLanguages(); this.updateDownloadSelectOptionState({ preserveSelection: !downloadSucceeded, }); this.updateDownloadLanguageButtonDisabled(); } }, /** * Handle updates to translations permissions. * * @param {nsISupports} subject * @param {string} data */ handlePermissionChange(subject, data) { if (data === "cleared") { this.neverTranslateSiteOrigins = new Set(); this.renderNeverTranslateSites([]); return; } const perm = subject?.QueryInterface?.(Ci.nsIPermission); if (perm?.type !== TRANSLATIONS_PERMISSION) { return; } this.refreshNeverTranslateSites(); }, /** * Remove observers and listeners added during init. */ teardown() { try { Services.obs.removeObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED); Services.obs.removeObserver(this, "perm-changed"); } catch (e) { // Ignore if we were never added. } document.removeEventListener("paneshown", this); window.removeEventListener("unload", this); this.elements?.alwaysTranslateLanguagesSelect?.removeEventListener( "change", this ); this.elements?.alwaysTranslateLanguagesGroup?.removeEventListener( "click", this ); this.elements?.alwaysTranslateLanguagesButton?.removeEventListener( "click", this ); this.elements?.neverTranslateLanguagesSelect?.removeEventListener( "change", this ); this.elements?.neverTranslateLanguagesButton?.removeEventListener( "click", this ); this.elements?.neverTranslateLanguagesGroup?.removeEventListener( "click", this ); this.elements?.neverTranslateSitesGroup?.removeEventListener("click", this); this.elements?.downloadLanguagesSelect?.removeEventListener("change", this); this.elements?.downloadLanguagesGroup?.removeEventListener("click", this); this.elements?.downloadLanguagesButton?.removeEventListener("click", this); }, }; document.addEventListener("paneshown", TranslationsSettings);