tor-browser

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

commit d9e4629dd40d310a2a50e3be85e22d59fb6b5bad
parent 5a99bf38a776f86b36fe40efafa31aec6679f59b
Author: Erik Nordin <enordin@mozilla.com>
Date:   Thu, 18 Dec 2025 15:42:06 +0000

Bug 2002127 - Part 10: Implement Always Translate Languages Functionality r=hjones

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

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

Diffstat:
Mbrowser/components/preferences/jar.mn | 1+
Mbrowser/components/preferences/main.inc.xhtml | 1+
Abrowser/components/preferences/translations.d.ts | 23+++++++++++++++++++++++
Abrowser/components/preferences/translations.js | 529+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 554 insertions(+), 0 deletions(-)

diff --git a/browser/components/preferences/jar.mn b/browser/components/preferences/jar.mn @@ -8,6 +8,7 @@ browser.jar: * content/browser/preferences/preferences.xhtml content/browser/preferences/main.js + content/browser/preferences/translations.js content/browser/preferences/home.js content/browser/preferences/search.js content/browser/preferences/privacy.js diff --git a/browser/components/preferences/main.inc.xhtml b/browser/components/preferences/main.inc.xhtml @@ -5,6 +5,7 @@ <!-- General panel --> <script src="chrome://browser/content/preferences/main.js"/> +<script src="chrome://browser/content/preferences/translations.js"/> #ifdef MOZ_UPDATER <script src="chrome://browser/content/aboutDialog-appUpdater.js"/> diff --git a/browser/components/preferences/translations.d.ts b/browser/components/preferences/translations.d.ts @@ -0,0 +1,23 @@ +/* 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/. */ + +/** + * Type definitions for translations.js + */ + +export interface LanguageInfo { + langTag: string; + displayName: string; +} + +export interface SupportedLanguages { + sourceLanguages: LanguageInfo[]; + targetLanguages: LanguageInfo[]; +} + +export interface TranslationsSettingsElements { + alwaysTranslateLanguagesGroup: HTMLElement; + alwaysTranslateLanguagesSelect: HTMLSelectElement; + alwaysTranslateLanguagesNoneRow: HTMLElement; +} diff --git a/browser/components/preferences/translations.js b/browser/components/preferences/translations.js @@ -0,0 +1,529 @@ +/* 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 ALWAYS_LANGUAGE_ITEM_CLASS = "translations-always-language-item"; +/** @type {string} */ +const ALWAYS_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-always-remove-button"; + +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, + + /** + * Current always-translate language tags. + * + * @type {Set<string>} + */ + alwaysLanguageTags: new Set(), + + /** + * 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) { + await this.onAlwaysLanguageChosen( + /** @type {HTMLSelectElement} */ (event.target).value + ); + } + break; + case "click": { + const target = /** @type {HTMLElement} */ (event.target); + const removeButton = /** @type {HTMLElement|null} */ ( + target.closest?.(`.${ALWAYS_LANGUAGE_REMOVE_BUTTON_CLASS}`) + ); + if (removeButton?.dataset.langTag) { + this.removeAlwaysLanguage(removeButton.dataset.langTag); + } + 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 && + data === ALWAYS_TRANSLATE_LANGS_PREF + ) { + this.refreshAlwaysLanguages().catch(console.error); + } + }, + + /** + * 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.refreshAlwaysLanguages(); + return; + } + + if (this.initialized) { + await this.refreshAlwaysLanguages(); + 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 group = /** @type {ElementWithUpdateComplete|null} */ ( + document.querySelector('setting-group[groupid="moreTranslationSettings"]') + ); + + const promises = []; + if (pane?.getUpdateComplete) { + promises.push(pane.getUpdateComplete()); + } + 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 "Always translate languages" section. + * + * @returns {Promise<void>} + */ + async init() { + await this.ensurePaneRendered(); + this.cacheElements(); + if ( + !this.elements?.alwaysTranslateLanguagesGroup || + !this.elements?.alwaysTranslateLanguagesSelect || + !this.elements?.alwaysTranslateLanguagesNoneRow + ) { + return; + } + + try { + this.languageDisplayNames = + TranslationsParent.createLanguageDisplayNames(); + this.supportedLanguages = + await TranslationsParent.getSupportedLanguages(); + } catch (error) { + console.error("Failed to initialize translations settings UI", error); + this.elements.alwaysTranslateLanguagesSelect.disabled = true; + return; + } + + this.elements.alwaysTranslateLanguagesSelect.disabled = false; + await this.buildAlwaysSelectOptions(); + + this.elements.alwaysTranslateLanguagesSelect.addEventListener( + "change", + this + ); + this.elements.alwaysTranslateLanguagesGroup.addEventListener("click", this); + Services.obs.addObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED); + window.addEventListener("unload", this); + + await this.refreshAlwaysLanguages(); + this.initialized = true; + }, + + /** + * 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") + ), + alwaysTranslateLanguagesNoneRow: /** @type {HTMLElement} */ ( + document.getElementById("translationsAlwaysTranslateLanguagesNoneRow") + ), + }; + + if ( + !elements.alwaysTranslateLanguagesGroup || + !elements.alwaysTranslateLanguagesSelect || + !elements.alwaysTranslateLanguagesNoneRow + ) { + return; + } + + this.elements = elements; + }, + + /** + * Handle a selection in the "Always translate languages" dropdown. + * + * @param {string} langTag + */ + async onAlwaysLanguageChosen(langTag) { + if (!langTag) { + return; + } + + TranslationsParent.addLangTagToPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); + TranslationsParent.removeLangTagFromPref( + langTag, + NEVER_TRANSLATE_LANGS_PREF + ); + await this.resetAlwaysSelect(); + }, + + /** + * Remove the given language from the always translate list. + * + * @param {string} langTag + */ + removeAlwaysLanguage(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 resetAlwaysSelect() { + await this.resetSelect( + this.elements?.alwaysTranslateLanguagesSelect, + "translationsAlwaysTranslateLanguagesSelect" + ); + }, + + /** + * Refresh the rendered list of always-translate languages to match prefs. + */ + async refreshAlwaysLanguages() { + if (!this.elements?.alwaysTranslateLanguagesGroup) { + return; + } + + const langTags = Array.from( + TranslationsParent.getAlwaysTranslateLanguages?.() ?? [] + ); + + if (this.alwaysLanguageTags) { + for (const langTag of langTags) { + if (this.alwaysLanguageTags.has(langTag)) { + continue; + } + TranslationsParent.removeLangTagFromPref( + langTag, + NEVER_TRANSLATE_LANGS_PREF + ); + } + } + + this.alwaysLanguageTags = new Set(langTags); + + this.renderAlwaysLanguages(langTags); + await this.updateAlwaysSelectOptionState(); + }, + + /** + * Render the current set of always-translate languages into the list UI. + * + * @param {string[]} langTags + */ + renderAlwaysLanguages(langTags) { + const { alwaysTranslateLanguagesGroup, alwaysTranslateLanguagesNoneRow } = + this.elements; + + for (const item of alwaysTranslateLanguagesGroup.querySelectorAll( + `.${ALWAYS_LANGUAGE_ITEM_CLASS}` + )) { + item.remove(); + } + + 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 ghost"); + removeButton.setAttribute( + "iconsrc", + "chrome://global/skin/icons/delete.svg" + ); + removeButton.classList.add(ALWAYS_LANGUAGE_REMOVE_BUTTON_CLASS); + removeButton.dataset.langTag = langTag; + removeButton.setAttribute("aria-label", label); + + const item = document.createElement("moz-box-item"); + item.classList.add(ALWAYS_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); + } + } + }, + + /** + * 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 buildAlwaysSelectOptions() { + 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( + (a, b) => a.displayName.localeCompare(b.displayName) + ); + for (const { langTag, displayName } of sourceLanguages) { + const option = document.createElement("moz-option"); + option.setAttribute("value", langTag); + option.setAttribute("label", displayName); + select.appendChild(option); + } + + await this.resetAlwaysSelect(); + }, + + /** + * Disable already-added languages in the select so they cannot be re-added. + */ + async updateAlwaysSelectOptionState() { + 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.alwaysLanguageTags.has(value); + } + + await this.resetAlwaysSelect(); + }, + + /** + * Remove observers and listeners added during init. + */ + teardown() { + try { + Services.obs.removeObserver(this, TOPIC_TRANSLATIONS_PREF_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 + ); + }, +}; + +document.addEventListener("paneshown", TranslationsSettings);