chromium/chrome/browser/resources/ash/settings/os_languages_page/os_languages_page_v2.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'os-settings-languages-page-v2' is the languages sub-page
 * for languages and inputs settings.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/js/action_link.js';
import 'chrome://resources/ash/common/cr_elements/action_link.css.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import './change_device_language_dialog.js';
import './os_add_languages_dialog.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {CrCheckboxElement} from 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import {CrLazyRenderElement} from 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {DomRepeatEvent, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';
import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {recordSettingChange} from '../metrics_recorder.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {LanguagesMetricsProxyImpl, LanguagesPageInteraction} from './languages_metrics_proxy.js';
import {LanguageHelper, LanguagesModel, LanguageState} from './languages_types.js';
import {getTemplate} from './os_languages_page_v2.html.js';

/**
 * Millisecond delay that can be used when closing an action menu to keep it
 * briefly on-screen so users can see the changes.
 */
const MENU_CLOSE_DELAY = 100;

const OsSettingsLanguagesPageV2ElementBase =
    RouteObserverMixin(PrefsMixin(I18nMixin(DeepLinkingMixin(PolymerElement))));

export interface OsSettingsLanguagesPageV2Element {
  $: {
    addLanguages: CrButtonElement,
    menu: CrLazyRenderElement<CrActionMenuElement>,
  };
}

export class OsSettingsLanguagesPageV2Element extends
    OsSettingsLanguagesPageV2ElementBase {
  static get is() {
    return 'os-settings-languages-page-v2' as const;
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * Read-only reference to the languages model provided by the
       * 'os-settings-languages' instance.
       */
      languages: {
        type: Object,
        notify: true,
      },

      languageHelper: Object,

      /**
       * The language to display the details for and its index.
       */
      detailLanguage_: Object,

      showAddLanguagesDialog_: Boolean,

      showChangeDeviceLanguageDialog_: {
        type: Boolean,
        value: false,
      },

      isGuest_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isGuest');
        },
      },

      isSecondaryUser_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isSecondaryUser');
        },
      },

      primaryUserEmail_: {
        type: String,
        value() {
          return loadTimeData.getString('primaryUserEmail');
        },
      },

      isPerAppLanguageEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isPerAppLanguageEnabled');
        },
      },

      languageSettingsV2Update2Enabled_: Boolean,
    };
  }

  // Public API: Bidirectional data flow.
  // override prefs: any;  // From PrefsMixin.

  // Public API: Downwards data flow.
  languages: LanguagesModel|undefined;
  languageHelper: LanguageHelper;

  // API proxies.
  private languagesMetricsProxy_ = LanguagesMetricsProxyImpl.getInstance();

  // Internal properties for mixins.
  // From DeepLinkingMixin.
  override supportedSettingIds = new Set([
    Setting.kAddLanguage,
    Setting.kChangeDeviceLanguage,
    Setting.kOfferTranslation,
  ]);

  // Internal state.
  private detailLanguage_?: {state: LanguageState, index: number};
  // This property does not have a default value in `static get properties()`.
  // TODO(b/265556480): Update the initial value to be false.
  private showAddLanguagesDialog_: boolean;
  private showChangeDeviceLanguageDialog_: boolean;

  // loadTimeData flags and strings.
  private isGuest_: boolean;
  private isSecondaryUser_: boolean;
  private primaryUserEmail_: string;
  private isPerAppLanguageEnabled_: boolean;
  // TODO: b/263823772 - Inline this variable.
  private languageSettingsV2Update2Enabled_ = true;

  override currentRouteChanged(route: Route): void {
    // Does not apply to this page.
    if (route !== routes.OS_LANGUAGES_LANGUAGES) {
      return;
    }

    this.attemptDeepLink();
  }

  private getLanguageDisplayName_(language: string): string {
    // This `getLanguage` assertion is potentially unsafe and could fail.
    // TODO(b/265554088): Prove that this assertion is safe, or rewrite this to
    // avoid this assertion.
    return this.languageHelper.getLanguage(language)!.displayName;
  }

  private onChangeDeviceLanguageClick_(): void {
    this.showChangeDeviceLanguageDialog_ = true;
  }

  private onChangeDeviceLanguageDialogClose_(): void {
    this.showChangeDeviceLanguageDialog_ = false;
    focusWithoutInk(
        // Safety: This method is only called when the change device
        // language dialog is closed, but that can only be opened if
        // #changeDeviceLanguage was clicked.
        castExists(this.shadowRoot!.querySelector('#changeDeviceLanguage')));
  }

  private getChangeDeviceLanguageButtonDescription_(language: string): string {
    return this.i18n(
        'changeDeviceLanguageButtonDescription',
        this.getLanguageDisplayName_(language));
  }

  /**
   * Navigates to app languages subpage.
   */
  private onAppLanguagesClick_(): void {
    Router.getInstance().navigateTo(routes.OS_LANGUAGES_APP_LANGUAGES);
  }

  /**
   * Stamps and opens the Add Languages dialog, registering a listener to
   * disable the dialog's dom-if again on close.
   */
  private onAddLanguagesClick_(e: Event): void {
    e.preventDefault();
    this.languagesMetricsProxy_.recordAddLanguages();
    this.showAddLanguagesDialog_ = true;
  }

  private onAddLanguagesDialogClose_(): void {
    this.showAddLanguagesDialog_ = false;
    focusWithoutInk(this.$.addLanguages);
  }

  /**
   * Checks if there are supported languages that are not enabled but can be
   * enabled.
   * @return True if there is at least one available language.
   */
  private canEnableSomeSupportedLanguage_(languages: LanguagesModel|
                                          undefined): boolean {
    return languages !== undefined && languages.supported.some(language => {
      return this.languageHelper.canEnableLanguage(language);
    });
  }

  /**
   * @return True if the translate checkbox should be disabled.
   */
  private disableTranslateCheckbox_(): boolean {
    if (!this.detailLanguage_ || !this.detailLanguage_.state) {
      return true;
    }

    const languageState = this.detailLanguage_.state;
    if (!languageState.language || !languageState.language.supportsTranslate) {
      return true;
    }

    if (this.languageHelper.isOnlyTranslateBlockedLanguage(languageState)) {
      return true;
    }

    // This assertion of `this.languages` is potentially unsafe and could fail.
    // TODO(b/265553377): Prove that this assertion is safe, or rewrite this to
    // avoid this assertion.
    return this.languageHelper.convertLanguageCodeForTranslate(
               languageState.language.code) === this.languages!.translateTarget;
  }

  /**
   * Handler for changes to the translate checkbox.
   */
  private onTranslateCheckboxChange_(e: CustomEvent<boolean>): void {
    // Safety: This method is only called from a 'change' event from a
    // <cr-checkbox>, so the event target must be a <cr-checkbox>.
    const checked = (e.target as CrCheckboxElement).checked;
    if (checked) {
      this.languageHelper.enableTranslateLanguage(
          // Safety: This method is only called from the action menu, which only
          // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
          // should always be defined here.
          this.detailLanguage_!.state.language.code);
    } else {
      this.languageHelper.disableTranslateLanguage(
          // Safety: This method is only called from the action menu, which only
          // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
          // should always be defined here.
          this.detailLanguage_!.state.language.code);
    }
    this.languagesMetricsProxy_.recordTranslateCheckboxChanged(checked);
    recordSettingChange(
        checked ? Setting.kEnableTranslateLanguage :
                  Setting.kDisableTranslateLanguage);
    this.closeMenuSoon_();
  }

  /**
   * Closes the shared action menu after a short delay, so when a checkbox is
   * clicked it can be seen to change state before disappearing.
   */
  private closeMenuSoon_(): void {
    const menu = this.$.menu.get();
    setTimeout(() => {
      if (menu.open) {
        menu.close();
      }
    }, MENU_CLOSE_DELAY);
  }

  /**
   * @return True if the "Move to top" option for |language| should be visible.
   */
  private showMoveToTop_(): boolean {
    // "Move To Top" is a no-op for the top language.
    return this.detailLanguage_ !== undefined &&
        this.detailLanguage_.index === 0;
  }

  /**
   * @return True if the "Move up" option for |language| should be visible.
   */
  private showMoveUp_(): boolean {
    // "Move up" is a no-op for the top language, and redundant with
    // "Move to top" for the 2nd language.
    return this.detailLanguage_ !== undefined &&
        this.detailLanguage_.index !== 0 && this.detailLanguage_.index !== 1;
  }

  /**
   * @return True if the "Move down" option for |language| should be visible.
   */
  private showMoveDown_(): boolean {
    return this.languages !== undefined && this.detailLanguage_ !== undefined &&
        this.detailLanguage_.index !== this.languages.enabled.length - 1;
  }

  /**
   * Moves the language to the top of the list.
   */
  private onMoveToTopClick_(): void {
    this.$.menu.get().close();
    this.languageHelper.moveLanguageToFront(
        // Safety: This method is only called from the action menu, which only
        // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
        // should always be defined here.
        this.detailLanguage_!.state.language.code);
    recordSettingChange(Setting.kMoveLanguageToFront);
  }

  /**
   * Moves the language up in the list.
   */
  private onMoveUpClick_(): void {
    this.$.menu.get().close();
    this.languageHelper.moveLanguage(
        // Safety: This method is only called from the action menu, which only
        // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
        // should always be defined here.
        this.detailLanguage_!.state.language.code,
        /*upDirection=*/ true);
    recordSettingChange(Setting.kMoveLanguageUp);
  }

  /**
   * Moves the language down in the list.
   */
  private onMoveDownClick_(): void {
    this.$.menu.get().close();
    this.languageHelper.moveLanguage(
        // Safety: This method is only called from the action menu, which only
        // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
        // should always be defined here.
        this.detailLanguage_!.state.language.code,
        /*upDirection=*/ false);
    recordSettingChange(Setting.kMoveLanguageDown);
  }

  /**
   * Disables the language.
   */
  private onRemoveLanguageClick_(): void {
    this.$.menu.get().close();
    this.languageHelper.disableLanguage(
        // Safety: This method is only called from the action menu, which only
        // appears when `onDotsClick_()` is called, so `this.detailLanguage_`
        // should always be defined here.
        this.detailLanguage_!.state.language.code);
    recordSettingChange(Setting.kRemoveLanguage);
  }

  private onDotsClick_(e: DomRepeatEvent<LanguageState>): void {
    // Sets a copy of the LanguageState object since it is not data-bound to
    // the languages model directly.
    this.detailLanguage_ = {
      state: e.model.item,
      index: e.model.index,
    };

    const menu = this.$.menu.get();
    // Safety: This event comes from the DOM, so the target should always be an
    // element.
    menu.showAt(e.target as HTMLElement);
  }

  private onTranslateToggleChange_(e: CustomEvent<unknown>): void {
    this.languagesMetricsProxy_.recordToggleTranslate(
        // Safety: This method is only called from a
        // 'settings-boolean-control-changed' event from a
        // <settings-toggle-button>, so the event target must be a
        // <settings-toggle-button>.
        (e.target as SettingsToggleButtonElement).checked);
  }

  /**
   * @param languageCode The language code identifying a language.
   * @param translateTarget The translate target language.
   * @return class name for whether it's a translate-target or not.
   */
  private getTranslationTargetClass_(
      languageCode: string, translateTarget: string): string {
    return this.languageHelper.convertLanguageCodeForTranslate(languageCode) ===
            translateTarget ?
        'translate-target' :
        'non-translate-target';
  }

  private getOfferTranslationLabel_(update2Enabled: boolean): string {
    return this.i18n(
        update2Enabled ? 'offerGoogleTranslateLabel' : 'offerTranslationLabel');
  }

  private getOfferTranslationSublabel_(update2Enabled: boolean): string {
    return update2Enabled ? '' : this.i18n('offerTranslationSublabel');
  }

  private getLanguagePreferenceTitle_(update2Enabled: boolean): string {
    return this.i18n(
        update2Enabled ? 'websiteLanguagesTitle' : 'languagesPreferenceTitle');
  }

  private getLanguagePreferenceDescription_(update2Enabled: boolean):
      TrustedHTML {
    return this.i18nAdvanced(
        update2Enabled ? 'websiteLanguagesDescription' :
                         'languagesPreferenceDescription');
  }

  private openManageGoogleAccountLanguage_(): void {
    this.languagesMetricsProxy_.recordInteraction(
        LanguagesPageInteraction.OPEN_MANAGE_GOOGLE_ACCOUNT_LANGUAGE);
    window.open(loadTimeData.getString('googleAccountLanguagesURL'));
  }

  private onLanguagePreferenceDescriptionLinkClick_(): void {
    this.languagesMetricsProxy_.recordInteraction(
        LanguagesPageInteraction.OPEN_WEB_LANGUAGES_LEARN_MORE);
  }
}

customElements.define(
    OsSettingsLanguagesPageV2Element.is, OsSettingsLanguagesPageV2Element);

declare global {
  interface HTMLElementTagNameMap {
    [OsSettingsLanguagesPageV2Element.is]: OsSettingsLanguagesPageV2Element;
  }
}