Source code

Revision control

Copy as Markdown

Other Tools

/* 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<void>|null}
*/
initPromise: null,
/**
* Promise cached after the pane/group finish rendering.
*
* @type {Promise<void>|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<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>}
*/
alwaysTranslateLanguageTags: new Set(),
/**
* Current never-translate language tags.
*
* @type {Set<string>}
*/
neverTranslateLanguageTags: new Set(),
/**
* Current never-translate site origins.
*
* @type {Set<string>}
*/
neverTranslateSiteOrigins: 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}
*/
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<void>}
*/
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<void>}
*/
async ensurePaneRendered() {
if (this.paneRenderPromise) {
await this.paneRenderPromise;
return;
}
/**
* @typedef {HTMLElement & { getUpdateComplete?: () => Promise<void> }} 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<void>}
*/
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<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.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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>} - 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<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);
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);