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:
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