summaryrefslogtreecommitdiffstats
path: root/browser/components/translations/content
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/translations/content')
-rw-r--r--browser/components/translations/content/translationsPanel.inc.xhtml149
-rw-r--r--browser/components/translations/content/translationsPanel.js1132
2 files changed, 1281 insertions, 0 deletions
diff --git a/browser/components/translations/content/translationsPanel.inc.xhtml b/browser/components/translations/content/translationsPanel.inc.xhtml
new file mode 100644
index 0000000000..df08469822
--- /dev/null
+++ b/browser/components/translations/content/translationsPanel.inc.xhtml
@@ -0,0 +1,149 @@
+<!-- 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/. -->
+
+<html:template id="template-translations-panel">
+<panel id="translations-panel"
+ class="panel-no-padding translations-panel"
+ type="arrow"
+ role="alertdialog"
+ noautofocus="true"
+ aria-labelledby="translations-panel-main-header-label"
+ orient="vertical">
+ <panelmultiview id="translations-panel-multiview"
+ mainViewId="translations-panel-view-default">
+ <panelview id="translations-panel-view-default"
+ class="PanelUI-subView translations-panel-view"
+ role="document"
+ showheader="true">
+ <hbox class="panel-header translations-panel-header">
+ <html:h1>
+ <html:span id="translations-panel-header"></html:span>
+ </html:h1>
+ <toolbarbutton id="translations-panel-settings" class="panel-info-button"
+ data-l10n-id="translations-panel-settings-button"
+ closemenu="none"
+ oncommand="TranslationsPanel.openSettingsPopup(this)">
+ <image class="translations-panel-gear-icon" />
+ <menupopup>
+ <menuitem class="always-translate-language-menuitem"
+ data-l10n-id="translations-panel-settings-always-translate-unknown-language"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onAlwaysTranslateLanguage()"/>
+ <menuitem class="never-translate-language-menuitem"
+ data-l10n-id="translations-panel-settings-never-translate-unknown-language"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onNeverTranslateLanguage()"/>
+ <menuitem class="never-translate-site-menuitem"
+ data-l10n-id="translations-panel-settings-never-translate-site"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onNeverTranslateSite()"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="translations-panel-settings-manage-languages"
+ oncommand="TranslationsPanel.openManageLanguages()"/>
+ </menupopup>
+ </toolbarbutton>
+ </hbox>
+
+ <vbox class="translations-panel-content">
+ <vbox id="translations-panel-lang-selection">
+ <label data-l10n-id="translations-panel-from-label"></label>
+ <menulist id="translations-panel-from"
+ flex="1"
+ value="detect"
+ size="large"
+ oncommand="TranslationsPanel.onChangeLanguages(event)">
+ <menupopup id="translations-panel-from-menupopup"
+ class="translations-panel-language-menupopup-from">
+ <menuitem data-l10n-id="translations-panel-choose-language" value=""></menuitem>
+ <!-- The list of <menuitem> will be dynamically inserted. -->
+ </menupopup>
+ </menulist>
+
+ <label data-l10n-id="translations-panel-to-label"></label>
+ <menulist id="translations-panel-to"
+ flex="1"
+ value="detect"
+ size="large"
+ oncommand="TranslationsPanel.onChangeLanguages(event)">
+ <menupopup id="translations-panel-to-menupopup"
+ class="translations-panel-language-menupopup-to">
+ <menuitem data-l10n-id="translations-panel-choose-language" value=""></menuitem>
+ <!-- The list of <menuitem> will be dynamically inserted. -->
+ </menupopup>
+ </menulist>
+ </vbox>
+
+ <vbox id="translations-panel-error">
+ <hbox class="translations-panel-error-header">
+ <image class="translations-panel-error-icon translations-panel-error-header-icon" />
+ <description id="translations-panel-error-message"></description>
+ </hbox>
+ <hbox id="translations-panel-error-message-hint"></hbox>
+ <hbox pack="end">
+ <button id="translations-panel-translate-hint-action" />
+ </hbox>
+ </vbox>
+ </vbox>
+
+ <hbox class="panel-footer translations-panel-footer">
+ <button id="translations-panel-restore-button"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onRestore(event);"
+ data-l10n-id="translations-panel-restore-button"
+ tabindex="0">
+ </button>
+ <button id="translations-panel-not-now"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onCancel(event);"
+ data-l10n-id="translations-panel-translate-cancel"
+ tabindex="0">
+ </button>
+ <button id="translations-panel-translate"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onTranslate(event);"
+ data-l10n-id="translations-panel-translate-button"
+ default="true"
+ tabindex="0">
+ </button>
+ </hbox>
+ </panelview>
+
+ <panelview id="translations-panel-view-unsupported-language"
+ class="PanelUI-subView translations-panel-view"
+ role="document"
+ showheader="true">
+ <hbox class="panel-header translations-panel-header">
+ <image class="translations-panel-error-icon" />
+ <html:h1>
+ <html:span data-l10n-id="translations-panel-error-unsupported"></html:span>
+ </html:h1>
+ </hbox>
+
+ <vbox class="translations-panel-content">
+ <description id="translations-panel-error-unsupported-hint"></description>
+ </vbox>
+
+ <hbox class="panel-footer translations-panel-footer">
+ <button class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onChangeSourceLanguage(event);"
+ data-l10n-id="translations-panel-error-change-button"
+ tabindex="0">
+ </button>
+ <button class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onCancel(event);"
+ data-l10n-id="translations-panel-error-dismiss-button"
+ default="true"
+ tabindex="0">
+ </button>
+ </hbox>
+ </panelview>
+ </panelmultiview>
+</panel>
+</html:template>
diff --git a/browser/components/translations/content/translationsPanel.js b/browser/components/translations/content/translationsPanel.js
new file mode 100644
index 0000000000..3d7d5be5fc
--- /dev/null
+++ b/browser/components/translations/content/translationsPanel.js
@@ -0,0 +1,1132 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+/* eslint-disable jsdoc/valid-types */
+/**
+ * @typedef {import("../../../../toolkit/components/translations/translations").LangTags} LangTags
+ */
+/* eslint-enable jsdoc/valid-types */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TranslationsTelemetry:
+ "chrome://browser/content/translations/TranslationsTelemetry.sys.mjs",
+});
+
+/**
+ * The set of actions that can occur from interaction with the
+ * translations panel.
+ */
+const PageAction = Object.freeze({
+ NO_CHANGE: "NO_CHANGE",
+ HIDE_BUTTON: "HIDE_BUTTON",
+ RESTORE_PAGE: "RESTORE_PAGE",
+ TRANSLATE_PAGE: "TRANSLATE_PAGE",
+});
+
+/**
+ * A mechanism for determining the next relevant page action
+ * based on the current translated state of the page and the state
+ * of the persistent options in the translations panel settings.
+ */
+class CheckboxStateMachine {
+ /**
+ * Whether or not translations is active on the page.
+ *
+ * @type {boolean}
+ */
+ #translationsActive = false;
+
+ /**
+ * Whether the always-translate-language menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #alwaysTranslateLanguage = false;
+
+ /**
+ * Whether the never-translate-language menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #neverTranslateLanguage = false;
+
+ /**
+ * Whether the never-translate-site menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #neverTranslateSite = false;
+
+ /**
+ * @param {boolean} translationsActive
+ * @param {boolean} alwaysTranslateLanguage
+ * @param {boolean} neverTranslateLanguage
+ * @param {boolean} neverTranslateSite
+ */
+ constructor(
+ translationsActive,
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ ) {
+ this.#translationsActive = translationsActive;
+ this.#alwaysTranslateLanguage = alwaysTranslateLanguage;
+ this.#neverTranslateLanguage = neverTranslateLanguage;
+ this.#neverTranslateSite = neverTranslateSite;
+ }
+
+ /**
+ * Accepts four integers that are either 0 or 1 and returns
+ * a single, unique number for each possible combination of
+ * values.
+ *
+ * @param {number} translationsActive
+ * @param {number} alwaysTranslateLanguage
+ * @param {number} neverTranslateLanguage
+ * @param {number} neverTranslateSite
+ *
+ * @returns {number} - An integer representation of the state
+ */
+ static #computeState(
+ translationsActive,
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ ) {
+ return (
+ (translationsActive << 3) |
+ (alwaysTranslateLanguage << 2) |
+ (neverTranslateLanguage << 1) |
+ neverTranslateSite
+ );
+ }
+
+ /**
+ * Returns the current state of the data members as a single number.
+ *
+ * @returns {number} - An integer representation of the state
+ */
+ #state() {
+ return CheckboxStateMachine.#computeState(
+ Number(this.#translationsActive),
+ Number(this.#alwaysTranslateLanguage),
+ Number(this.#neverTranslateLanguage),
+ Number(this.#neverTranslateSite)
+ );
+ }
+
+ /**
+ * Returns the next page action to take when the always-translate-language
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onAlwaysTranslateLanguage() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 0, 1, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.TRANSLATE_PAGE;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+
+ /**
+ * Returns the next page action to take when the never-translate-language
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onNeverTranslateLanguage() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.HIDE_BUTTON;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+
+ /**
+ * Returns the next page action to take when the never-translate-site
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onNeverTranslateSite() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 1, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.HIDE_BUTTON;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 1): {
+ return PageAction.TRANSLATE_PAGE;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+}
+
+/**
+ * This singleton class controls the Translations popup panel.
+ *
+ * This component is a `/browser` component, and the actor is a `/toolkit` actor, so care
+ * must be taken to keep the presentation (this component) from the state management
+ * (the Translations actor). This class reacts to state changes coming from the
+ * Translations actor.
+ */
+var TranslationsPanel = new (class {
+ /** @type {Console?} */
+ #console;
+
+ /**
+ * The cached detected languages for both the document and the user.
+ *
+ * @type {null | LangTags}
+ */
+ detectedLanguages = null;
+
+ /**
+ * Lazily get a console instance.
+ *
+ * @returns {Console}
+ */
+ get console() {
+ if (!this.#console) {
+ this.#console = console.createInstance({
+ maxLogLevelPref: "browser.translations.logLevel",
+ prefix: "Translations",
+ });
+ }
+ return this.#console;
+ }
+
+ /**
+ * Where the lazy elements are stored.
+ *
+ * @type {Record<string, Element>?}
+ */
+ #lazyElements;
+
+ /**
+ * Lazily creates the dom elements, and lazily selects them.
+ *
+ * @returns {Record<string, Element>}
+ */
+ get elements() {
+ if (!this.#lazyElements) {
+ // Lazily turn the template into a DOM element.
+ /** @type {HTMLTemplateElement} */
+ const wrapper = document.getElementById("template-translations-panel");
+ const panel = wrapper.content.firstElementChild;
+ wrapper.replaceWith(wrapper.content);
+
+ const settingsButton = document.getElementById(
+ "translations-panel-settings"
+ );
+ // Clone the settings toolbarbutton across all the views.
+ for (const header of panel.querySelectorAll(".panel-header")) {
+ if (header.contains(settingsButton)) {
+ continue;
+ }
+ const settingsButtonClone = settingsButton.cloneNode(true);
+ settingsButtonClone.removeAttribute("id");
+ header.appendChild(settingsButtonClone);
+ }
+
+ // Lazily select the elements.
+ this.#lazyElements = {
+ panel,
+ settingsButton,
+ // The rest of the elements are set by the getter below.
+ };
+
+ /**
+ * Define a getter on #lazyElements that gets the element by an id
+ * or class name.
+ */
+ const getter = (name, discriminator) => {
+ let element;
+ Object.defineProperty(this.#lazyElements, name, {
+ get: () => {
+ if (!element) {
+ if (discriminator[0] === ".") {
+ // Lookup by class
+ element = document.querySelector(discriminator);
+ } else {
+ // Lookup by id
+ element = document.getElementById(discriminator);
+ }
+ }
+ if (!element) {
+ throw new Error(
+ `Could not find "${name}" at "#${discriminator}".`
+ );
+ }
+ return element;
+ },
+ });
+ };
+
+ // Getters by id
+ getter("appMenuButton", "PanelUI-menu-button");
+ getter("button", "translations-button");
+ getter("buttonLocale", "translations-button-locale");
+ getter("buttonCircleArrows", "translations-button-circle-arrows");
+ getter("defaultTranslate", "translations-panel-translate");
+ getter("error", "translations-panel-error");
+ getter("errorMessage", "translations-panel-error-message");
+ getter("errorMessageHint", "translations-panel-error-message-hint");
+ getter("errorHintAction", "translations-panel-translate-hint-action");
+ getter("fromMenuList", "translations-panel-from");
+ getter("header", "translations-panel-header");
+ getter("langSelection", "translations-panel-lang-selection");
+ getter("multiview", "translations-panel-multiview");
+ getter("notNowButton", "translations-panel-not-now");
+ getter("restoreButton", "translations-panel-restore-button");
+ getter("toMenuList", "translations-panel-to");
+ getter("unsupportedHint", "translations-panel-error-unsupported-hint");
+
+ // Getters by class
+ getter(
+ "alwaysTranslateLanguageMenuItem",
+ ".always-translate-language-menuitem"
+ );
+ getter(
+ "neverTranslateLanguageMenuItem",
+ ".never-translate-language-menuitem"
+ );
+ getter("neverTranslateSiteMenuItem", ".never-translate-site-menuitem");
+ }
+
+ return this.#lazyElements;
+ }
+
+ /**
+ * Cache the last command used for error hints so that it can be later removed.
+ */
+ #lastHintCommand = null;
+
+ /**
+ * @param {object} options
+ * @param {string} options.message - l10n id
+ * @param {string} options.hint - l10n id
+ * @param {string} options.actionText - l10n id
+ * @param {Function} options.actionCommand - The action to perform.
+ */
+ #showError({
+ message,
+ hint,
+ actionText: hintCommandText,
+ actionCommand: hintCommand,
+ }) {
+ const { error, errorMessage, errorMessageHint, errorHintAction } =
+ this.elements;
+ error.hidden = false;
+ document.l10n.setAttributes(errorMessage, message);
+
+ if (hint) {
+ errorMessageHint.hidden = false;
+ document.l10n.setAttributes(errorMessageHint, hint);
+ } else {
+ errorMessageHint.hidden = true;
+ }
+
+ if (hintCommand && hintCommandText) {
+ errorHintAction.removeEventListener("command", this.#lastHintCommand);
+ this.#lastHintCommand = hintCommand;
+ errorHintAction.addEventListener("command", hintCommand);
+ errorHintAction.hidden = false;
+ document.l10n.setAttributes(errorHintAction, hintCommandText);
+ } else {
+ errorHintAction.hidden = true;
+ }
+ }
+
+ /**
+ * @returns {TranslationsParent}
+ */
+ #getTranslationsActor() {
+ const actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "Translations"
+ );
+
+ if (!actor) {
+ throw new Error("Unable to get the TranslationsParent");
+ }
+ return actor;
+ }
+
+ /**
+ * Fetches the language tags for the document and the user and caches the results
+ * Use `#getCachedDetectedLanguages` when the lang tags do not need to be re-fetched.
+ * This requires a bit of work to do, so prefer the cached version when possible.
+ *
+ * @returns {Promise<LangTags>}
+ */
+ async #fetchDetectedLanguages() {
+ this.detectedLanguages =
+ await this.#getTranslationsActor().getLangTagsForTranslation();
+ return this.detectedLanguages;
+ }
+
+ /**
+ * If the detected language tags have been retrieved previously, return the cached
+ * version. Otherwise do a fresh lookup of the document's language tag.
+ *
+ * @returns {Promise<LangTags>}
+ */
+ async #getCachedDetectedLanguages() {
+ if (!this.detectedLanguages) {
+ return this.#fetchDetectedLanguages();
+ }
+ return this.detectedLanguages;
+ }
+
+ /**
+ * @type {"initialized" | "error" | "uninitialized"}
+ */
+ #langListsPhase = "uninitialized";
+
+ /**
+ * Builds the <menulist> of languages for both the "from" and "to". This can be
+ * called every time the popup is shown, as it will retry when there is an error
+ * (such as a network error) or be a noop if it's already initialized.
+ *
+ * TODO(Bug 1813796) This needs to be updated when the supported languages change
+ * via RemoteSettings.
+ */
+ async #ensureLangListsBuilt() {
+ switch (this.#langListsPhase) {
+ case "initialized":
+ // This has already been initialized.
+ return;
+ case "error":
+ // Attempt to re-initialize.
+ this.#langListsPhase = "uninitialized";
+ break;
+ case "uninitialized":
+ // Ready to initialize.
+ break;
+ default:
+ this.console.error("Unknown langList phase", this.#langListsPhase);
+ }
+
+ try {
+ /** @type {SupportedLanguages} */
+ const { languagePairs, fromLanguages, toLanguages } =
+ await this.#getTranslationsActor().getSupportedLanguages();
+
+ // Verify that we are in a proper state.
+ if (languagePairs.length === 0) {
+ throw new Error("No translation languages were retrieved.");
+ }
+
+ const { panel } = this.elements;
+ const fromPopups = panel.querySelectorAll(
+ ".translations-panel-language-menupopup-from"
+ );
+ const toPopups = panel.querySelectorAll(
+ ".translations-panel-language-menupopup-to"
+ );
+
+ for (const popup of fromPopups) {
+ for (const { langTag, isBeta, displayName } of fromLanguages) {
+ const fromMenuItem = document.createXULElement("menuitem");
+ fromMenuItem.setAttribute("value", langTag);
+ if (isBeta) {
+ document.l10n.setAttributes(
+ fromMenuItem,
+ "translations-panel-displayname-beta",
+ { language: displayName }
+ );
+ } else {
+ fromMenuItem.setAttribute("label", displayName);
+ }
+ popup.appendChild(fromMenuItem);
+ }
+ }
+
+ for (const popup of toPopups) {
+ for (const { langTag, isBeta, displayName } of toLanguages) {
+ const toMenuItem = document.createXULElement("menuitem");
+ toMenuItem.setAttribute("value", langTag);
+ if (isBeta) {
+ document.l10n.setAttributes(
+ toMenuItem,
+ "translations-panel-displayname-beta",
+ { language: displayName }
+ );
+ } else {
+ toMenuItem.setAttribute("label", displayName);
+ }
+ popup.appendChild(toMenuItem);
+ }
+ }
+
+ this.#langListsPhase = "initialized";
+ } catch (error) {
+ this.console.error(error);
+ this.#langListsPhase = "error";
+ }
+ }
+
+ /**
+ * Show the default view of choosing a source and target language.
+ *
+ * @param {boolean} force - Force the page to show translation options.
+ */
+ async #showDefaultView(force = false) {
+ const {
+ fromMenuList,
+ multiview,
+ panel,
+ error,
+ toMenuList,
+ defaultTranslate,
+ langSelection,
+ } = this.elements;
+
+ if (this.#langListsPhase === "error") {
+ // There was an error, display it in the view rather than the language
+ // dropdowns.
+ const { restoreButton, notNowButton, header, errorHintAction } =
+ this.elements;
+
+ this.#showError({
+ message: "translations-panel-error-load-languages",
+ hint: "translations-panel-error-load-languages-hint",
+ actionText: "translations-panel-error-load-languages-hint-button",
+ actionCommand: () => this.#reloadLangList(),
+ });
+
+ document.l10n.setAttributes(header, "translations-panel-header");
+ defaultTranslate.disabled = true;
+ restoreButton.hidden = true;
+ notNowButton.hidden = false;
+ langSelection.hidden = true;
+ errorHintAction.disabled = false;
+ return;
+ }
+
+ // Remove any old selected values synchronously before asking for new ones.
+ fromMenuList.value = "";
+ error.hidden = true;
+ langSelection.hidden = false;
+
+ /** @type {null | LangTags} */
+ const langTags = await this.#fetchDetectedLanguages();
+ if (langTags?.isDocLangTagSupported || force) {
+ // Show the default view with the language selection
+ const { header, restoreButton, notNowButton } = this.elements;
+ document.l10n.setAttributes(header, "translations-panel-header");
+
+ if (langTags?.isDocLangTagSupported) {
+ fromMenuList.value = langTags?.docLangTag ?? "";
+ } else {
+ fromMenuList.value = "";
+ }
+ toMenuList.value = langTags?.userLangTag ?? "";
+
+ this.onChangeLanguages();
+
+ restoreButton.hidden = true;
+ notNowButton.hidden = false;
+ multiview.setAttribute("mainViewId", "translations-panel-view-default");
+ } else {
+ // Show the "unsupported language" view.
+ const { unsupportedHint } = this.elements;
+ multiview.setAttribute(
+ "mainViewId",
+ "translations-panel-view-unsupported-language"
+ );
+ let language;
+ if (langTags?.docLangTag) {
+ const displayNames = new Intl.DisplayNames(undefined, {
+ type: "language",
+ fallback: "none",
+ });
+ language = displayNames.of(langTags.docLangTag);
+ }
+ if (language) {
+ document.l10n.setAttributes(
+ unsupportedHint,
+ "translations-panel-error-unsupported-hint-known",
+ { language }
+ );
+ } else {
+ document.l10n.setAttributes(
+ unsupportedHint,
+ "translations-panel-error-unsupported-hint-unknown"
+ );
+ }
+ }
+
+ // Focus the "from" language, as it is the only field not set.
+ panel.addEventListener(
+ "ViewShown",
+ () => {
+ if (!fromMenuList.value) {
+ fromMenuList.focus();
+ }
+ if (!toMenuList.value) {
+ toMenuList.focus();
+ }
+ },
+ { once: true }
+ );
+ }
+
+ /**
+ * Updates the checked states of the settings menu checkboxes that
+ * pertain to languages.
+ */
+ async #updateSettingsMenuLanguageCheckboxStates() {
+ const { docLangTag, isDocLangTagSupported } =
+ await this.#getCachedDetectedLanguages();
+
+ const { panel } = this.elements;
+ const alwaysTranslateMenuItems = panel.querySelectorAll(
+ ".always-translate-language-menuitem"
+ );
+ const neverTranslateMenuItems = panel.querySelectorAll(
+ ".never-translate-language-menuitem"
+ );
+
+ if (
+ !docLangTag ||
+ !isDocLangTagSupported ||
+ docLangTag === new Intl.Locale(Services.locale.appLocaleAsBCP47).language
+ ) {
+ for (const menuitem of alwaysTranslateMenuItems) {
+ menuitem.disabled = true;
+ }
+ for (const menuitem of neverTranslateMenuItems) {
+ menuitem.disabled = true;
+ }
+ return;
+ }
+
+ const alwaysTranslateLanguage =
+ TranslationsParent.shouldAlwaysTranslateLanguage(docLangTag);
+ const neverTranslateLanguage =
+ TranslationsParent.shouldNeverTranslateLanguage(docLangTag);
+
+ for (const menuitem of alwaysTranslateMenuItems) {
+ menuitem.setAttribute(
+ "checked",
+ alwaysTranslateLanguage ? "true" : "false"
+ );
+ menuitem.disabled = false;
+ }
+ for (const menuitem of neverTranslateMenuItems) {
+ menuitem.setAttribute(
+ "checked",
+ neverTranslateLanguage ? "true" : "false"
+ );
+ menuitem.disabled = false;
+ }
+ }
+
+ /**
+ * Updates the checked states of the settings menu checkboxes that
+ * pertain to site permissions.
+ */
+ async #updateSettingsMenuSiteCheckboxStates() {
+ const { panel } = this.elements;
+ const neverTranslateSiteMenuItems = panel.querySelectorAll(
+ ".never-translate-site-menuitem"
+ );
+ const neverTranslateSite =
+ await this.#getTranslationsActor().shouldNeverTranslateSite();
+
+ for (const menuitem of neverTranslateSiteMenuItems) {
+ menuitem.setAttribute("checked", neverTranslateSite ? "true" : "false");
+ }
+ }
+
+ /**
+ * Populates the language-related settings menuitems by adding the
+ * localized display name of the document's detected language tag.
+ */
+ async #populateSettingsMenuItems() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+
+ const { panel } = this.elements;
+
+ const alwaysTranslateMenuItems = panel.querySelectorAll(
+ ".always-translate-language-menuitem"
+ );
+ const neverTranslateMenuItems = panel.querySelectorAll(
+ ".never-translate-language-menuitem"
+ );
+
+ /** @type {string | undefined} */
+ let docLangDisplayName;
+ if (docLangTag) {
+ const displayNames = new Services.intl.DisplayNames(undefined, {
+ type: "language",
+ fallback: "none",
+ });
+ // The display name will still be empty if the docLangTag is not known.
+ docLangDisplayName = displayNames.of(docLangTag);
+ }
+
+ for (const menuitem of alwaysTranslateMenuItems) {
+ if (docLangDisplayName) {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-always-translate-language",
+ { language: docLangDisplayName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-always-translate-unknown-language"
+ );
+ }
+ }
+
+ for (const menuitem of neverTranslateMenuItems) {
+ if (docLangDisplayName) {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-never-translate-language",
+ { language: docLangDisplayName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-never-translate-unknown-language"
+ );
+ }
+ }
+
+ await Promise.all([
+ this.#updateSettingsMenuLanguageCheckboxStates(),
+ this.#updateSettingsMenuSiteCheckboxStates(),
+ ]);
+ }
+
+ /**
+ * Configures the panel for the user to reset the page after it has been translated.
+ *
+ * @param {TranslationPair} translationPair
+ */
+ async #showRevisitView({ fromLanguage, toLanguage }) {
+ const { header, fromMenuList, toMenuList, restoreButton, notNowButton } =
+ this.elements;
+
+ fromMenuList.value = fromLanguage;
+ toMenuList.value = toLanguage;
+ this.onChangeLanguages();
+
+ restoreButton.hidden = false;
+ notNowButton.hidden = true;
+
+ const displayNames = new Services.intl.DisplayNames(undefined, {
+ type: "language",
+ });
+
+ document.l10n.setAttributes(header, "translations-panel-revisit-header", {
+ fromLanguage: displayNames.of(fromLanguage),
+ toLanguage: displayNames.of(toLanguage),
+ });
+ }
+
+ /**
+ * Handle the disable logic for when the menulist is changed for the "Translate to"
+ * on the "revisit" subview.
+ */
+ onChangeRevisitTo() {
+ const { revisitTranslate, revisitMenuList } = this.elements;
+ revisitTranslate.disabled = !revisitMenuList.value;
+ }
+
+ /**
+ * When changing the "dual" view's language, handle cases where the translate button
+ * should be disabled.
+ */
+ onChangeLanguages() {
+ const { defaultTranslate, toMenuList, fromMenuList } = this.elements;
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+ defaultTranslate.disabled =
+ // The translation languages are the same, don't allow this translation.
+ toMenuList.value === fromMenuList.value ||
+ // No "to" language was provided.
+ !toMenuList.value ||
+ // No "from" language was provided.
+ !fromMenuList.value ||
+ // The is the requested translation pair.
+ (requestedTranslationPair &&
+ requestedTranslationPair.fromLanguage === fromMenuList.value &&
+ requestedTranslationPair.toLanguage === toMenuList.value);
+ }
+
+ /**
+ * When a language is not supported and the menu is manually invoked, an error message
+ * is shown. This method switches the panel back to the language selection view.
+ * Note that this bypasses the showSubView method since the main view doesn't support
+ * a subview.
+ */
+ async onChangeSourceLanguage(event) {
+ const { panel } = this.elements;
+ panel.addEventListener("popuphidden", async () => {}, { once: true });
+ PanelMultiView.hidePopup(panel);
+
+ await this.#showDefaultView(true /* force this view to be shown */);
+
+ PanelMultiView.openPopup(panel, this.elements.appMenuButton, {
+ position: "bottomright topright",
+ triggeringEvent: event,
+ }).catch(error => this.console.error(error));
+ }
+
+ async #reloadLangList() {
+ try {
+ await this.#ensureLangListsBuilt();
+ await this.#showDefaultView();
+ } catch (error) {
+ this.elements.errorHintAction.disabled = false;
+ }
+ }
+
+ /**
+ * Opens the TranslationsPanel.
+ *
+ * @param {Event} event
+ */
+ async open(event) {
+ const { panel, button } = this.elements;
+
+ await this.#ensureLangListsBuilt();
+
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+
+ if (requestedTranslationPair) {
+ await this.#showRevisitView(requestedTranslationPair).catch(error => {
+ this.console.error(error);
+ });
+ } else {
+ await this.#showDefaultView().catch(error => {
+ this.console.error(error);
+ });
+ }
+
+ this.#populateSettingsMenuItems();
+
+ const [targetButton, openedFromAppMenu] = button.contains(event.target)
+ ? [button, false]
+ : [this.elements.appMenuButton, true];
+
+ panel.addEventListener(
+ "ViewShown",
+ () => TranslationsTelemetry.onOpenPanel(openedFromAppMenu),
+ { once: true }
+ );
+
+ PanelMultiView.openPopup(panel, targetButton, {
+ position: "bottomright topright",
+ triggerEvent: event,
+ }).catch(error => this.console.error(error));
+ }
+
+ /**
+ * Removes the translations button.
+ */
+ #hideTranslationsButton() {
+ const { button, buttonLocale, buttonCircleArrows } = this.elements;
+ button.hidden = true;
+ buttonLocale.hidden = true;
+ buttonCircleArrows.hidden = true;
+ button.removeAttribute("translationsactive");
+ }
+
+ /**
+ * Returns true if translations is currently active, otherwise false.
+ *
+ * @returns {boolean}
+ */
+ #isTranslationsActive() {
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+ return requestedTranslationPair !== null;
+ }
+
+ /**
+ * Handle the translation button being clicked when there are two language options.
+ */
+ async onTranslate() {
+ PanelMultiView.hidePopup(this.elements.panel);
+
+ const actor = this.#getTranslationsActor();
+ actor.translate(
+ this.elements.fromMenuList.value,
+ this.elements.toMenuList.value
+ );
+ }
+
+ onCancel() {
+ PanelMultiView.hidePopup(this.elements.panel);
+ }
+
+ /**
+ * A handler for opening the settings context menu.
+ */
+ openSettingsPopup(button) {
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ this.#updateSettingsMenuSiteCheckboxStates();
+ const popup = button.querySelector("menupopup");
+ popup.openPopup(button);
+ }
+
+ /**
+ * Creates a new CheckboxStateMachine based on the current translated
+ * state of the page and the state of the persistent options in the
+ * translations panel settings.
+ *
+ * @returns {CheckboxStateMachine}
+ */
+ createCheckboxStateMachine() {
+ const {
+ alwaysTranslateLanguageMenuItem,
+ neverTranslateLanguageMenuItem,
+ neverTranslateSiteMenuItem,
+ } = this.elements;
+
+ const alwaysTranslateLanguage =
+ alwaysTranslateLanguageMenuItem.getAttribute("checked") === "true";
+ const neverTranslateLanguage =
+ neverTranslateLanguageMenuItem.getAttribute("checked") === "true";
+ const neverTranslateSite =
+ neverTranslateSiteMenuItem.getAttribute("checked") === "true";
+
+ return new CheckboxStateMachine(
+ this.#isTranslationsActive(),
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ );
+ }
+
+ /**
+ * Redirect the user to about:preferences
+ */
+ openManageLanguages() {
+ const window =
+ gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
+ window.openTrustedLinkIn("about:preferences#general-translations", "tab");
+ }
+
+ /**
+ * Performs the given page action.
+ *
+ * @param {PageAction} pageAction
+ */
+ async #doPageAction(pageAction) {
+ switch (pageAction) {
+ case PageAction.NO_CHANGE: {
+ break;
+ }
+ case PageAction.HIDE_BUTTON: {
+ this.#hideTranslationsButton();
+ break;
+ }
+ case PageAction.RESTORE_PAGE: {
+ await this.onRestore();
+ break;
+ }
+ case PageAction.TRANSLATE_PAGE: {
+ await this.onTranslate();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Updates the always-translate-language menuitem prefs and checked state.
+ * If auto-translate is currently active for the doc language, deactivates it.
+ * If auto-translate is currently inactive for the doc language, activates it.
+ */
+ async onAlwaysTranslateLanguage() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+ const pageAction =
+ this.createCheckboxStateMachine().onAlwaysTranslateLanguage();
+ TranslationsParent.toggleAlwaysTranslateLanguagePref(docLangTag);
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Updates the never-translate-language menuitem prefs and checked state.
+ * If never-translate is currently active for the doc language, deactivates it.
+ * If never-translate is currently inactive for the doc language, activates it.
+ */
+ async onNeverTranslateLanguage() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+ const pageAction =
+ this.createCheckboxStateMachine().onNeverTranslateLanguage();
+ TranslationsParent.toggleNeverTranslateLanguagePref(docLangTag);
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Updates the never-translate-site menuitem permissions and checked state.
+ * If never-translate is currently active for the site, deactivates it.
+ * If never-translate is currently inactive for the site, activates it.
+ */
+ async onNeverTranslateSite() {
+ const pageAction = this.createCheckboxStateMachine().onNeverTranslateSite();
+ await this.#getTranslationsActor().toggleNeverTranslateSitePermissions();
+ this.#updateSettingsMenuSiteCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Handle the restore button being clicked.
+ */
+ async onRestore() {
+ const { panel } = this.elements;
+ PanelMultiView.hidePopup(panel);
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+
+ this.#getTranslationsActor().restorePage(docLangTag);
+ }
+
+ /**
+ * Set the state of the translations button in the URL bar.
+ *
+ * @param {CustomEvent} event
+ */
+ handleEvent = async event => {
+ switch (event.type) {
+ case "TranslationsParent:LanguageState":
+ const {
+ detectedLanguages,
+ requestedTranslationPair,
+ error,
+ isEngineReady,
+ } = event.detail;
+
+ const { panel, button, buttonLocale, buttonCircleArrows } =
+ this.elements;
+
+ const hasSupportedLanguage =
+ detectedLanguages?.docLangTag &&
+ detectedLanguages?.userLangTag &&
+ detectedLanguages?.isDocLangTagSupported;
+
+ if (detectedLanguages) {
+ // Ensure the cached detected languages are up to date, for instance whenever
+ // the user switches tabs.
+ TranslationsPanel.detectedLanguages = detectedLanguages;
+ }
+
+ /**
+ * Defer this check to the end of the `if` statement since it requires work.
+ */
+ const shouldNeverTranslate = async () => {
+ return Boolean(
+ TranslationsParent.shouldNeverTranslateLanguage(
+ detectedLanguages?.docLangTag
+ ) ||
+ // The site is present in the never-translate list.
+ (await this.#getTranslationsActor().shouldNeverTranslateSite())
+ );
+ };
+
+ if (
+ // We've already requested to translate this page, so always show the icon.
+ requestedTranslationPair ||
+ // There was an error translating, so always show the icon. This can happen
+ // when a user manually invokes the translation and we wouldn't normally show
+ // the icon.
+ error ||
+ // Finally check that this is a supported language that we should translate.
+ (hasSupportedLanguage && !(await shouldNeverTranslate()))
+ ) {
+ button.hidden = false;
+ if (requestedTranslationPair) {
+ // The translation is active, update the urlbar button.
+ button.setAttribute("translationsactive", true);
+ if (isEngineReady) {
+ // Show the locale of the page in the button.
+ buttonLocale.hidden = false;
+ buttonCircleArrows.hidden = true;
+ buttonLocale.innerText = requestedTranslationPair.toLanguage;
+ } else {
+ // Show the spinning circle arrows to indicate that the engine is
+ // still loading.
+ buttonCircleArrows.hidden = false;
+ buttonLocale.hidden = true;
+ }
+ } else {
+ // The translation is not active, update the urlbar button.
+ button.removeAttribute("translationsactive");
+ buttonLocale.hidden = true;
+ buttonCircleArrows.hidden = true;
+ }
+ } else {
+ this.#hideTranslationsButton();
+ }
+
+ switch (error) {
+ case null:
+ this.elements.error.hidden = true;
+ break;
+ case "engine-load-failure":
+ this.elements.error.hidden = false;
+ this.#showError({
+ message: "translations-panel-error-translating",
+ });
+ const targetButton = button.hidden
+ ? this.elements.appMenuButton
+ : button;
+
+ // Re-open the menu on an error.
+ PanelMultiView.openPopup(panel, targetButton, {
+ position: "bottomright topright",
+ }).catch(panelError => this.console.error(panelError));
+
+ break;
+ default:
+ console.error("Unknown translation error", error);
+ }
+ break;
+ }
+ };
+})();