tor-browser

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

commit 64b849fa70e25a3fe319e93ec3fae88e46358a02
parent 742ed6d1c2d627341e05e630f6ce7bbfe1be8cbd
Author: Erik Nordin <enordin@mozilla.com>
Date:   Thu, 18 Dec 2025 15:42:06 +0000

Bug 2002127 - Part 13: Implement Download Languages Functionality r=fluent-reviewers,bolsson,hjones

This commit adds implements the "Download Languages" functionality
in the Translations subpage within the about:settings UI, compatible with
the settings redesign initiative.

Differential Revision: https://phabricator.services.mozilla.com/D274230

Diffstat:
Mbrowser/components/preferences/translations.d.ts | 4++++
Mbrowser/components/preferences/translations.js | 847++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbrowser/locales/en-US/browser/preferences/preferences.ftl | 27+++++++++++++++++++++++++++
3 files changed, 872 insertions(+), 6 deletions(-)

diff --git a/browser/components/preferences/translations.d.ts b/browser/components/preferences/translations.d.ts @@ -26,4 +26,8 @@ export interface TranslationsSettingsElements { neverTranslateSitesGroup: HTMLElement; neverTranslateSitesRow: HTMLElement; neverTranslateSitesNoneRow: HTMLElement; + downloadLanguagesGroup: HTMLElement; + downloadLanguagesSelect: HTMLSelectElement; + downloadLanguagesButton: HTMLButtonElement; + downloadLanguagesNoneRow: HTMLElement; } diff --git a/browser/components/preferences/translations.js b/browser/components/preferences/translations.js @@ -41,6 +41,29 @@ const NEVER_SITE_ITEM_CLASS = "translations-never-site-item"; /** @type {string} */ const NEVER_SITE_REMOVE_BUTTON_CLASS = "translations-never-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_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"; + const TranslationsSettings = { /** * True once initialization has completed. @@ -78,6 +101,27 @@ const TranslationsSettings = { languageDisplayNames: null, /** + * Language metadata used to build labels and selectors. + * + * @type {LanguageInfo[]|null} + */ + languageList: null, + + /** + * Download sizes keyed by language tag. + * + * @type {Map<string, number>|null} + */ + languageSizes: null, + + /** + * Formatter used for download size labels. + * + * @type {Intl.NumberFormat|null} + */ + numberFormatter: null, + + /** * Current always-translate language tags. * * @type {Set<string>} @@ -99,6 +143,41 @@ const TranslationsSettings = { neverSiteOrigins: new Set(), /** + * Language tags with downloaded translation models. + * + * @type {Set<string>} + */ + downloadedLanguageTags: new Set(), + + /** + * Language tags currently downloading. + * + * @type {Set<string>} + */ + downloadingLanguageTags: new Set(), + + /** + * Language tags that failed to download. + * + * @type {Set<string>} + */ + downloadFailedLanguageTags: new Set(), + + /** + * Language tags pending delete confirmation. + * + * @type {Set<string>} + */ + 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} @@ -128,10 +207,54 @@ const TranslationsSettings = { await this.onNeverLanguageChosen( /** @type {HTMLSelectElement} */ (event.target).value ); + } else if (event.target === this.elements?.downloadLanguagesSelect) { + this.onDownloadSelectionChanged(); } break; case "click": { const target = /** @type {HTMLElement} */ (event.target); + if ( + target === this.elements?.downloadLanguagesButton || + target.closest?.("#translationsDownloadLanguagesButton") + ) { + this.onDownloadButtonClicked(); + 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_LANGUAGE_REMOVE_BUTTON_CLASS}`) ); @@ -197,6 +320,7 @@ const TranslationsSettings = { await this.refreshAlwaysLanguages(); await this.refreshNeverLanguages(); this.refreshNeverSites(); + await this.refreshDownloadedLanguages(); return; } @@ -204,6 +328,7 @@ const TranslationsSettings = { await this.refreshAlwaysLanguages(); await this.refreshNeverLanguages(); this.refreshNeverSites(); + await this.refreshDownloadedLanguages(); return; } @@ -254,7 +379,7 @@ const TranslationsSettings = { }, /** - * Initialize the "Always translate languages" and "Never translate languages" sections. + * Initialize the translations settings UI. * * @returns {Promise<void>} */ @@ -268,27 +393,44 @@ const TranslationsSettings = { !this.elements?.neverTranslateLanguagesGroup || !this.elements?.neverTranslateLanguagesSelect || !this.elements?.neverTranslateLanguagesNoneRow || - !this.elements?.neverTranslateSitesGroup + !this.elements?.neverTranslateSitesGroup || + !this.elements?.downloadLanguagesGroup || + !this.elements?.downloadLanguagesSelect || + !this.elements?.downloadLanguagesButton || + !this.elements?.downloadLanguagesNoneRow ) { 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.neverTranslateLanguagesSelect.disabled = true; + this.elements.downloadLanguagesSelect.disabled = true; + this.setDownloadButtonDisabledState(true); return; } this.elements.alwaysTranslateLanguagesSelect.disabled = false; this.elements.neverTranslateLanguagesSelect.disabled = false; + this.elements.downloadLanguagesSelect.disabled = false; + this.resetDownloadSelect(); + this.setDownloadButtonDisabledState(true); await this.buildAlwaysSelectOptions(); await this.buildNeverSelectOptions(); + await this.buildDownloadSelectOptions(); + await this.renderDownloadLanguages(); this.elements.alwaysTranslateLanguagesSelect.addEventListener( "change", @@ -301,6 +443,9 @@ const TranslationsSettings = { ); 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); @@ -347,6 +492,18 @@ const TranslationsSettings = { 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 ( @@ -364,6 +521,176 @@ const TranslationsSettings = { }, /** + * Load the download sizes for all supported languages and cache them. + * + * @returns {Promise<void>} + */ + 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<string|null>} + */ + 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<void>} + */ + 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.updateDownloadButtonDisabled(); + } else { + this.resetDownloadSelect(); + } + }, + + /** * Handle a selection in the "Always translate languages" dropdown. * * @param {string} langTag @@ -563,12 +890,20 @@ const TranslationsSettings = { } const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( - (a, b) => a.displayName.localeCompare(b.displayName) + (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", displayName); + option.setAttribute( + "label", + this.formatLanguageLabel(langTag) ?? displayName + ); select.appendChild(option); } @@ -740,12 +1075,20 @@ const TranslationsSettings = { } const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort( - (a, b) => a.displayName.localeCompare(b.displayName) + (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", displayName); + option.setAttribute( + "label", + this.formatLanguageLabel(langTag) ?? displayName + ); select.appendChild(option); } @@ -890,6 +1233,495 @@ const TranslationsSettings = { }, /** + * Handle a selection change in the download dropdown. + */ + onDownloadSelectionChanged() { + this.updateDownloadButtonDisabled(); + }, + + /** + * Whether the download button should be disabled based on selection state. + * + * @returns {boolean} + */ + isDownloadButtonDisabled() { + 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. + * + * @param {boolean} isDisabled + */ + setDownloadButtonDisabledState(isDisabled) { + const button = this.elements?.downloadLanguagesButton; + if (!button) { + return; + } + + button.disabled = isDisabled; + }, + + /** + * Update the enabled state of the download button. + */ + updateDownloadButtonDisabled() { + this.setDownloadButtonDisabledState(this.isDownloadButtonDisabled()); + }, + + /** + * Handle a click on the download button. + * + * @returns {Promise<void>} + */ + async onDownloadButtonClicked() { + const langTag = this.elements?.downloadLanguagesSelect?.value; + if (!langTag || this.currentDownloadLangTag) { + return; + } + + this.downloadFailedLanguageTags.clear(); + this.currentDownloadLangTag = langTag; + this.downloadingLanguageTags.add(langTag); + this.setDownloadControlsDisabled(true); + await this.renderDownloadLanguages(); + this.updateDownloadSelectOptionState({ preserveSelection: true }); + + let downloadSucceeded = false; + try { + await TranslationsParent.downloadLanguageFiles(langTag); + this.downloadedLanguageTags.add(langTag); + downloadSucceeded = true; + } catch (error) { + 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.updateDownloadButtonDisabled(); + } + }, + + /** + * Disable or enable the download controls. + * + * @param {boolean} isDisabled + */ + setDownloadControlsDisabled(isDisabled) { + if (this.elements?.downloadLanguagesSelect) { + this.elements.downloadLanguagesSelect.disabled = isDisabled; + } + this.setDownloadButtonDisabledState( + isDisabled || this.isDownloadButtonDisabled() + ); + }, + + /** + * 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.updateDownloadButtonDisabled(); + }, + + /** + * Refresh download state from disk and update the UI. + * + * @returns {Promise<void>} + */ + async refreshDownloadedLanguages() { + if (!this.languageList?.length) { + return; + } + + 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.updateDownloadButtonDisabled(); + }, + + /** + * 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<void>} + */ + async createDeleteConfirmationItem(langTag, item) { + const warningButton = document.createElement("moz-button"); + warningButton.setAttribute("slot", "actions-start"); + warningButton.setAttribute("type", "icon ghost"); + 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; + + 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 deleteButton = document.createElement("moz-button"); + deleteButton.setAttribute("type", "default"); + 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"); + 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); + confirmContent.appendChild(deleteButton); + confirmContent.appendChild(cancelButton); + + 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<void>} + */ + async createFailedDownloadItem(langTag, item) { + const errorButton = document.createElement("moz-button"); + errorButton.setAttribute("slot", "actions-start"); + errorButton.setAttribute("type", "icon ghost"); + 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; + + 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"); + 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); + + 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<boolean>} - Returns false if the item should be skipped. + */ + async createDownloadLanguageItem( + langTag, + isDownloading, + item, + progressLabel + ) { + 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 ghost"); + 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"; + } + + item.setAttribute("label", label); + if (isDownloading) { + item.setAttribute("description", progressLabel); + } + + item.appendChild(removeButton); + return true; + }, + + /** + * Render the downloaded (and downloading) languages list. + * + * @returns {Promise<void>} + */ + async renderDownloadLanguages() { + const { downloadLanguagesGroup, downloadLanguagesNoneRow } = + this.elements ?? {}; + if (!downloadLanguagesGroup) { + return; + } + + 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 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); + } else if (isFailed) { + await this.createFailedDownloadItem(langTag, item); + } else { + const shouldAdd = await this.createDownloadLanguageItem( + langTag, + isDownloading, + item, + progressLabel + ); + if (!shouldAdd) { + continue; + } + } + + if ( + downloadLanguagesNoneRow && + downloadLanguagesNoneRow.parentElement === downloadLanguagesGroup + ) { + downloadLanguagesGroup.insertBefore(item, downloadLanguagesNoneRow); + } else { + downloadLanguagesGroup.appendChild(item); + } + } + }, + + /** + * Show delete confirmation UI when delete button is clicked. + * + * @param {string} langTag + * @returns {Promise<void>} + */ + async onDeleteButtonClicked(langTag) { + if (!langTag || !this.downloadedLanguageTags.has(langTag)) { + return; + } + + this.downloadPendingDeleteLanguageTags.add(langTag); + await this.renderDownloadLanguages(); + }, + + /** + * Confirm and complete deletion of a language. + * + * @param {string} langTag + * @returns {Promise<void>} + */ + async confirmDeleteLanguage(langTag) { + if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) { + return; + } + + this.downloadPendingDeleteLanguageTags.delete(langTag); + + try { + await TranslationsParent.deleteLanguageFiles(langTag); + this.downloadedLanguageTags.delete(langTag); + } catch (error) { + console.error("Failed to remove downloaded language files", error); + await this.renderDownloadLanguages(); + return; + } + + await this.renderDownloadLanguages(); + this.updateDownloadSelectOptionState(); + this.updateDownloadButtonDisabled(); + }, + + /** + * Cancel delete confirmation and restore normal state. + * + * @param {string} langTag + * @returns {Promise<void>} + */ + 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<void>} + */ + async retryDownloadLanguage(langTag) { + if (!langTag || !this.downloadFailedLanguageTags.has(langTag)) { + return; + } + + this.downloadFailedLanguageTags.delete(langTag); + this.currentDownloadLangTag = langTag; + this.downloadingLanguageTags.add(langTag); + this.setDownloadControlsDisabled(true); + await this.renderDownloadLanguages(); + this.updateDownloadSelectOptionState({ preserveSelection: true }); + + let downloadSucceeded = false; + try { + await TranslationsParent.downloadLanguageFiles(langTag); + this.downloadedLanguageTags.add(langTag); + downloadSucceeded = true; + } catch (error) { + 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.updateDownloadButtonDisabled(); + } + }, + + /** * Handle updates to translations permissions. * * @param {nsISupports} subject @@ -939,6 +1771,9 @@ const TranslationsSettings = { 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); }, }; diff --git a/browser/locales/en-US/browser/preferences/preferences.ftl b/browser/locales/en-US/browser/preferences/preferences.ftl @@ -469,12 +469,39 @@ settings-translations-subpage-download-languages-button = .aria-label = Download language .title = Download language +# Variables: +# $language (string) - Localized name of the language to download. +# $size (string) - Download size in megabytes, formatted for the locale. +settings-translations-subpage-download-language-option = { $language } ({ $size }MB) + .label = { $language } ({ $size }MB) + settings-translations-subpage-no-languages-downloaded = .label = No languages downloaded settings-translations-subpage-no-languages-added = .label = No languages added +settings-translations-subpage-download-progress = Download in progress… + +# Variables: +# $language (string) - The localized display name of the language. +# $size (string) - The download size of the language in megabytes. +settings-translations-subpage-download-error = Couldn’t download { $language } ({ $size }MB) + +settings-translations-subpage-download-retry-button = + .label = Try again + +# Variables: +# $language (string) - The localized display name of the language. +# $size (string) - The download size of the language in megabytes. +settings-translations-subpage-download-delete-confirm = Delete { $language } ({ $size }MB)? + +settings-translations-subpage-download-delete-button = + .label = Delete + +settings-translations-subpage-download-cancel-button = + .label = Cancel + settings-translations-subpage-no-sites-added = .label = No sites added