chromium/chrome/browser/resources/ash/settings/os_languages_page/input_page.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-input-page' is the input sub-page
 * for language and input method settings.
 */

import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
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/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.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/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import './add_input_methods_dialog.js';
import './add_spellcheck_languages_dialog.js';
import './os_edit_dictionary_page.js';
import '../keyboard_shortcut_banner/keyboard_shortcut_banner.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';
import '../os_settings_page/os_settings_animated_pages.js';
import 'chrome://resources/ash/common/shortcut_input_ui/shortcut_input_key.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {MetaKey, ShortcutLabelProperties} from 'chrome://resources/ash/common/shortcut_input_ui/shortcut_utils.js';
import {assert} from 'chrome://resources/js/assert.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 {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {recordSettingChange} from '../metrics_recorder.js';
import {AcceleratorAction} from '../mojom-webui/accelerator_actions.mojom-webui.js';
import {AcceleratorFetcher, AcceleratorFetcherInterface, AcceleratorFetcherObserverReceiver} from '../mojom-webui/accelerator_fetcher.mojom-webui.js';
import {StandardAcceleratorProperties} from '../mojom-webui/accelerator_info.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {hasOptionsPageInSettings} from './input_method_util.js';
import {getTemplate} from './input_page.html.js';
import {InputsShortcutReminderState, LanguagesMetricsProxyImpl, LanguagesPageInteraction} from './languages_metrics_proxy.js';
import {LanguageHelper, LanguagesModel, LanguageState, SpellCheckLanguageState} from './languages_types.js';

const OsSettingsInputPageElementBase =
    RouteOriginMixin(PrefsMixin(I18nMixin(DeepLinkingMixin(PolymerElement))));

export interface OsSettingsInputPageElement {
  $: {
    addInputMethod: CrButtonElement,
  };
}

export class OsSettingsInputPageElement extends OsSettingsInputPageElementBase {
  static get is() {
    return 'os-settings-input-page' 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,
      },

      languageHelper: Object,

      spellCheckLanguages_: {
        type: Array,
        computed: `getSpellCheckLanguages_(
            languages.spellCheckOnLanguages.*, languages.enabled.*)`,
      },

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

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

      languageSettingsJapaneseEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('systemJapanesePhysicalTyping');
        },
      },

      /**
       * Whether the shortcut reminder for the last used IME is currently
       * showing.
       */
      showLastUsedImeShortcutReminder_: {
        type: Boolean,
        computed: `shouldShowLastUsedImeShortcutReminder_(
            languages.inputMethods.enabled.length,
            prefs.ash.shortcut_reminders.last_used_ime_dismissed.value)`,
      },

      /**
       * Whether the shortcut reminder for the next IME is currently showing.
       */
      showNextImeShortcutReminder_: {
        type: Boolean,
        computed: `shouldShowNextImeShortcutReminder_(
            languages.inputMethods.enabled.length,
            prefs.ash.shortcut_reminders.next_ime_dismissed.value)`,
      },

      /**
       * The body of the currently showing shortcut reminders.
       */
      shortcutReminderBody_: {
        type: Array,
        computed: `getShortcutReminderBody_(showLastUsedImeShortcutReminder_,
            showNextImeShortcutReminder_)`,
      },

      onDeviceGrammarCheckEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('onDeviceGrammarCheckEnabled');
        },
      },

      languagePacksInSettingsEnabled_: Boolean,

      allowEmojiSuggestion_: Boolean,

      allowOrca_: Boolean,

      allowSuggestionSection_: Boolean,

      acceleratorFetcher: Object,

      isShortcutCustomizationEnabled_: Boolean,

      lastUsedImeAccelerator_: Object,

      nextImeAccelerator_: Object,

      metaKey_: Object,
    };
  }

  // 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.kAddInputMethod,
    Setting.kShowEmojiSuggestions,
    Setting.kShowInputOptionsInShelf,
    Setting.kShowOrca,
    Setting.kSpellCheckOnOff,
  ]);
  // From RouteOriginMixin.
  override route = routes.OS_LANGUAGES_INPUT;

  // Internal state.
  private showAddSpellcheckLanguagesDialog_: boolean;
  private showAddInputMethodsDialog_: boolean;

  // Accelerator fetcher properties.
  // TODO(yyhyyh@): Move these members to somewhere common.
  acceleratorFetcher: AcceleratorFetcherInterface|null;
  private isShortcutCustomizationEnabled_ =
      loadTimeData.getBoolean('isShortcutCustomizationEnabled');
  private lastUsedImeAccelerator_?: StandardAcceleratorProperties;
  private nextImeAccelerator_?: StandardAcceleratorProperties;
  private acceleratorFetcherObserverReceiver_:
      AcceleratorFetcherObserverReceiver;
  private metaKey_ = MetaKey.kSearch;

  // loadTimeData flags.
  private onDeviceGrammarCheckEnabled_: boolean;
  private languageSettingsJapaneseEnabled_: boolean;
  private languagePacksInSettingsEnabled_ =
      loadTimeData.getBoolean('languagePacksInSettingsEnabled');
  private readonly allowEmojiSuggestion_: boolean =
      loadTimeData.getBoolean('allowEmojiSuggestion');
  private readonly allowOrca_: boolean = loadTimeData.getBoolean('allowOrca');
  private readonly allowSuggestionSection_: boolean =
      this.allowOrca_ || this.allowEmojiSuggestion_;

  // Computed properties.
  private spellCheckLanguages_: SpellCheckLanguageState[]|undefined;
  private showLastUsedImeShortcutReminder_: boolean;
  private showNextImeShortcutReminder_: boolean;
  private shortcutReminderBody_: TrustedHTML[];

  override ready(): void {
    super.ready();

    if (this.isShortcutCustomizationEnabled_) {
      this.acceleratorFetcher = AcceleratorFetcher.getRemote();

      assert(this.acceleratorFetcher);
      this.acceleratorFetcherObserverReceiver_ =
          new AcceleratorFetcherObserverReceiver(this);

      this.acceleratorFetcher.getMetaKeyToDisplay().then(({metaKey}) => {
        this.metaKey_ = metaKey;
      });

      this.acceleratorFetcher!.observeAcceleratorChanges(
          [
            AcceleratorAction.kSwitchToLastUsedIme,
            AcceleratorAction.kSwitchToNextIme,
          ],
          this.acceleratorFetcherObserverReceiver_.$
              .bindNewPipeAndPassRemote());
    }

    this.addFocusConfig(
        routes.OS_LANGUAGES_EDIT_DICTIONARY, '#editDictionarySubpageTrigger');
  }

  override currentRouteChanged(newRoute: Route, oldRoute?: Route): void {
    super.currentRouteChanged(newRoute, oldRoute);

    // Does not apply to this page.
    if (newRoute !== this.route) {
      return;
    }

    this.attemptDeepLink();
  }

  onAcceleratorsUpdated(
      action: AcceleratorAction,
      accelerators: StandardAcceleratorProperties[]): void {
    const hasUpdatedAccelerators = accelerators.length > 0;

    // If accelerators available, update the string display with only the first
    // accelerator.
    if (action === AcceleratorAction.kSwitchToLastUsedIme) {
      this.lastUsedImeAccelerator_ =
          hasUpdatedAccelerators ? accelerators[0] : undefined;
    } else if (action === AcceleratorAction.kSwitchToNextIme) {
      this.nextImeAccelerator_ =
          hasUpdatedAccelerators ? accelerators[0] : undefined;
    }
  }

  private getShortcutLabelProperties_(): ShortcutLabelProperties[] {
    const shortcutLabelProperties: ShortcutLabelProperties[] = [];

    if (this.lastUsedImeAccelerator_) {
      shortcutLabelProperties.push({
        ...this.lastUsedImeAccelerator_,
        shortcutLabelText:
            this.i18nAdvanced('imeCustomizedShortcutReminderLastUsed'),
        metaKey: this.metaKey_,
      });
    }

    if (this.nextImeAccelerator_) {
      shortcutLabelProperties.push({
        ...this.nextImeAccelerator_,
        shortcutLabelText:
            this.i18nAdvanced('imeCustomizedShortcutReminderNext'),
        metaKey: this.metaKey_,
      });
    }

    return shortcutLabelProperties;
  }

  private onShowImeMenuChange_(e: Event): void {
    this.languagesMetricsProxy_.recordToggleShowInputOptionsOnShelf(
        // Safety: This method is only called from a
        // <settings-toggle-button> event handler.
        (e.target as SettingsToggleButtonElement).checked);
  }

  private inputMethodsLimitedByPolicy_(): boolean {
    const allowedInputMethodsPref =
        this.getPref('settings.language.allowed_input_methods');
    return !!allowedInputMethodsPref && allowedInputMethodsPref.value.length;
  }

  /**
   * Handler for click events on an input method on the main page,
   * which sets it as the current input method.
   */
  private onInputMethodClick_(
      e: DomRepeatEvent<chrome.languageSettingsPrivate.InputMethod>): void {
    // Clicks on the button are handled in onInputMethodOptionsClick_.
    // Safety: This event comes from the DOM, so the target should always be an
    // element.
    if ((e.target as Element).tagName === 'CR-ICON-BUTTON') {
      return;
    }

    this.languageHelper.setCurrentInputMethod(e.model.item.id);
    this.languagesMetricsProxy_.recordInteraction(
        LanguagesPageInteraction.SWITCH_INPUT_METHOD);
    recordSettingChange(Setting.kSetCurrentInputMethod);
  }

  /**
   * Handler for <Enter> events on an input method on the main page,
   * which sets it as the current input method.
   */
  private onInputMethodKeyPress_(
      e: DomRepeatEvent<
          chrome.languageSettingsPrivate.InputMethod, KeyboardEvent>): void {
    // Ignores key presses other than <Enter>.
    if (e.key !== 'Enter') {
      return;
    }

    this.languageHelper.setCurrentInputMethod(e.model.item.id);
    recordSettingChange(Setting.kSetCurrentInputMethod);
  }

  /**
   * Opens the input method extension's options page in a new tab (or focuses
   * an existing instance of the IME's options).
   */
  private openExtensionOptionsPage_(
      e: DomRepeatEvent<chrome.languageSettingsPrivate.InputMethod>): void {
    this.languageHelper.openInputMethodOptions(e.model.item.id);
  }

  /**
   * @param id The input method ID.
   * @return True if there is a options page in ChromeOS settings for the input
   *     method ID.
   */
  private hasOptionsPageInSettings_(id: string): boolean {
    return hasOptionsPageInSettings(id, {
      isPhysicalKeyboardAutocorrectAllowed:
          loadTimeData.getBoolean('isPhysicalKeyboardAutocorrectAllowed'),
      isPhysicalKeyboardPredictiveWritingAllowed:
          loadTimeData.getBoolean('isPhysicalKeyboardPredictiveWritingAllowed'),
      isJapaneseSettingsAllowed:
          loadTimeData.getBoolean('systemJapanesePhysicalTyping'),
      isVietnameseFirstPartyInputSettingsAllowed:
          loadTimeData.getBoolean('allowFirstPartyVietnameseInput'),
    });
  }

  private navigateToOptionsPageInSettings_(
      e: DomRepeatEvent<chrome.languageSettingsPrivate.InputMethod>): void {
    const params = new URLSearchParams();
    params.append('id', e.model.item.id);
    Router.getInstance().navigateTo(
        routes.OS_LANGUAGES_INPUT_METHOD_OPTIONS, params);
  }

  /**
   * @param id The input method ID.
   * @param currentId The ID of the currently enabled input method.
   * @return True if the IDs match.
   */
  private isCurrentInputMethod_(id: string, currentId: string): boolean {
    return id === currentId;
  }

  /**
   * @param id The input method ID.
   * @param currentId The ID of the currently enabled input method.
   * @return The class for the input method item.
   */
  private getInputMethodItemClass_(id: string, currentId: string): string {
    return this.isCurrentInputMethod_(id, currentId) ? 'selected' : '';
  }

  /**
   * @param id The selected input method ID.
   * @param currentId The ID of the currently enabled input method.
   * @return The default tab index '0' if the selected input method is not
   *     currently enabled; otherwise, returns an empty string which effectively
   *     unsets the tabindex attribute.
   */
  private getInputMethodTabIndex_(id: string, currentId: string): string {
    return id === currentId ? '' : '0';
  }

  private getOpenOptionsPageLabel_(inputMethodName: string): string {
    return this.i18n('openOptionsPage', inputMethodName);
  }

  private onAddInputMethodClick_(): void {
    this.languagesMetricsProxy_.recordAddInputMethod();
    this.showAddInputMethodsDialog_ = true;
  }

  private onAddInputMethodsDialogClose_(): void {
    this.showAddInputMethodsDialog_ = false;
    focusWithoutInk(this.$.addInputMethod);
  }

  private onAddSpellcheckLanguagesClick_(): void {
    this.showAddSpellcheckLanguagesDialog_ = true;
  }

  private onAddSpellcheckLanguagesDialogClose_(): void {
    this.showAddSpellcheckLanguagesDialog_ = false;

    // 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.
    if (this.languages!.spellCheckOnLanguages.length > 0) {
      // User has at least one spell check language after closing the dialog.
      // If spell checking is disabled, enabled it.
      this.setPrefValue('browser.enable_spellchecking', true);
    }

    // Because #addSpellcheckLanguages is not statically created (as it is
    // within a <template is="dom-if">), we need to use
    // this.shadowRoot.querySelector("#addSpellcheckLanguages") instead of
    // this.$.addSpellCheckLanguages.
    // TODO(b/263823772): Move addSpellcheckLanguages to `this.$` once we remove
    // LSV2 Update 2 code.
    focusWithoutInk(
        // Safety: This method is only called when the spell check
        // language dialog is closed, but that can only be opened if
        // #addSpellchecklanguages was clicked.
        castExists(this.shadowRoot!.querySelector('#addSpellcheckLanguages')));
  }

  private disableRemoveInputMethod_(
      targetInputMethod: chrome.languageSettingsPrivate.InputMethod): boolean {
    // Third-party IMEs can always be removed.
    if (!this.languageHelper.isComponentIme(targetInputMethod)) {
      return false;
    }

    // Disable remove if there is no other component IME (pre-installed
    // system IMES) enabled.
    // Safety: This method is only called from a button inside a `dom-repeat`
    // over `languages.inputMethods.enabled`, so if this method is called,
    // `this.languages` must be defined.
    return !this.languages!
                // Safety: `LanguagesModel.inputMethods` is always defined on
                // CrOS.
                .inputMethods!.enabled.some(
                    inputMethod => inputMethod.id !== targetInputMethod.id &&
                        this.languageHelper.isComponentIme(inputMethod));
  }

  private getRemoveInputMethodTooltip_(
      inputMethod: chrome.languageSettingsPrivate.InputMethod): string {
    return this.i18n('removeInputMethodTooltip', inputMethod.displayName);
  }

  private onRemoveInputMethodClick_(
      e: DomRepeatEvent<chrome.languageSettingsPrivate.InputMethod>): void {
    this.languageHelper.removeInputMethod(e.model.item.id);
    recordSettingChange(Setting.kRemoveInputMethod);
  }

  private getRemoveSpellcheckLanguageTooltip_(lang: SpellCheckLanguageState):
      string {
    return this.i18n(
        'removeSpellCheckLanguageTooltip', lang.language.displayName);
  }

  private onRemoveSpellcheckLanguageClick_(
      e: DomRepeatEvent<LanguageState|SpellCheckLanguageState>): void {
    this.languageHelper.toggleSpellCheck(e.model.item.language.code, false);
    recordSettingChange(Setting.kRemoveSpellCheckLanguage);
  }

  /**
   * Called whenever the spell check toggle is changed by the user.
   */
  private onSpellcheckToggleChange_(e: Event): void {
    // Safety: This 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>.
    const toggle = (e.target as SettingsToggleButtonElement);

    this.languagesMetricsProxy_.recordToggleSpellCheck(toggle.checked);

    if (toggle.checked &&
        // 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.
        this.languages!.spellCheckOnLanguages.length === 0) {
      // We never want to enable spell check without the user having a spell
      // check language. When this happens, we try estimating their expected
      // spell check language (their device language, assuming that the user has
      // an input method which supports that language).
      // If that doesn't work, we fall back on prompting the user to enable a
      // spell check language and immediately disable spell check before this
      // happens. If the user then adds a spell check language, we finally
      // enable spell check (see |onAddSpellcheckLanguagesDialogClose_|).

      // Safety: `LanguagesModel.prospectiveUILanguage` is always defined on
      // CrOS, and we checked that `this.languages` is defined above.
      const deviceLanguageCode =
          castExists(this.languages!.prospectiveUILanguage);
      // However, deviceLanguage itself may be undefined as it is possible that
      // it was set outside of CrOS language settings (normally when debugging
      // or in tests).
      const deviceLanguage =
          this.languageHelper.getLanguage(deviceLanguageCode);
      if (deviceLanguage && deviceLanguage.supportsSpellcheck &&
          // Safety: `LanguagesModel.inputMethods` is always defined on CrOS,
          // and we checked that `this.languages` is defined above.
          this.languages!.inputMethods!.enabled.some(
              inputMethod =>
                  inputMethod.languageCodes.includes(deviceLanguageCode))) {
        this.languageHelper.toggleSpellCheck(deviceLanguageCode, true);
      } else {
        this.onAddSpellcheckLanguagesClick_();

        // "Undo" the toggle change by reverting it back to the original pref
        // value. The toggle will be flipped on once the user finishes adding
        // a spell check language.
        toggle.resetToPrefValue();
        // We don't need to commit the pref change below, so early return.
        return;
      }
    }

    // Manually commit the pref change as we've set noSetPref on the toggle
    // button.
    toggle.sendPrefChange();
  }

  /**
   * Returns the value to use as the |pref| attribute for the policy indicator
   * of spellcheck languages, based on whether or not the language is enabled.
   * @param isEnabled Whether the language is enabled or not.
   */
  private getIndicatorPrefForManagedSpellcheckLanguage_(isEnabled: boolean):
      chrome.settingsPrivate.PrefObject<string[]> {
    return isEnabled ?
        this.getPref<string[]>('spellcheck.forced_dictionaries') :
        this.getPref<string[]>('spellcheck.blocked_dictionaries');
  }

  /**
   * Returns an array of spell check languages for the UI.
   */
  private getSpellCheckLanguages_(): SpellCheckLanguageState[]|undefined {
    if (this.languages === undefined) {
      return undefined;
    }
    const languages = [...this.languages.spellCheckOnLanguages];
    languages.sort(
        (a, b) => a.language.displayName.localeCompare(b.language.displayName));
    return languages;
  }

  /**
   * Handler for enabling or disabling spell check for a specific language.
   */
  private onSpellCheckLanguageChange_(
      e: DomRepeatEvent<LanguageState|SpellCheckLanguageState>): void {
    const item = e.model.item;
    if (!item.language.supportsSpellcheck) {
      return;
    }

    this.languageHelper.toggleSpellCheck(
        item.language.code, !item.spellCheckEnabled);
  }

  /**
   * Handler for clicking on the name of the language. The action taken must
   * match the control that is available.
   */
  private onSpellCheckNameClick_(
      e: DomRepeatEvent<LanguageState|SpellCheckLanguageState>): void {
    // Safety: The button associated with this event listener is disabled in
    // the template if the below is true.
    assert(!this.isSpellCheckNameClickDisabled_(e.model.item));
    this.onSpellCheckLanguageChange_(e);
  }

  /**
   * Name only supports clicking when language is not managed, supports
   * spellcheck, and the dictionary has been downloaded with no errors.
   */
  private isSpellCheckNameClickDisabled_(item: LanguageState|
                                         SpellCheckLanguageState): boolean {
    return item.isManaged || item.downloadDictionaryFailureCount > 0 ||
        !this.getPref('browser.enable_spellchecking').value;
  }

  /**
   * Handler to initiate another attempt at downloading the spell check
   * dictionary for a specified language.
   */
  private onRetryDictionaryDownloadClick_(
      e: DomRepeatEvent<LanguageState|SpellCheckLanguageState>): void {
    // Safety: The button associated with this event listener is disabled in
    // the template if `!item.downloadDictionaryFailureCount`, and dictionary
    // download failure count cannot ever be negative.
    assert(e.model.item.downloadDictionaryFailureCount > 0);
    this.languageHelper.retryDownloadDictionary(e.model.item.language.code);
  }

  private getDictionaryDownloadRetryAriaLabel_(item: LanguageState): string {
    return this.i18n(
        'languagesDictionaryDownloadRetryDescription',
        item.language.displayName);
  }

  private onEditDictionaryClick_(): void {
    this.languagesMetricsProxy_.recordInteraction(
        LanguagesPageInteraction.OPEN_CUSTOM_SPELL_CHECK);
    Router.getInstance().navigateTo(routes.OS_LANGUAGES_EDIT_DICTIONARY);
  }

  private onJapaneseManageUserDictionaryClick_(): void {
    Router.getInstance().navigateTo(
        routes.OS_LANGUAGES_JAPANESE_MANAGE_USER_DICTIONARY);
  }

  private onLanguagePackNoticeLinkClick_(): void {
    this.languagesMetricsProxy_.recordInteraction(
        LanguagesPageInteraction.OPEN_LANGUAGE_PACKS_LEARN_MORE);
  }

  private shouldShowLastUsedImeShortcutReminder_(): boolean {
    // User has already dismissed the shortcut reminder.
    if (this.getPref('ash.shortcut_reminders.last_used_ime_dismissed').value) {
      return false;
    }
    // Need at least 2 input methods to be shown the reminder.
    // Safety: `LanguagesModel.inputMethods` is always defined on CrOS.
    return !!this.languages && this.languages.inputMethods!.enabled.length >= 2;
  }

  private shouldShowNextImeShortcutReminder_(): boolean {
    // User has already dismissed the shortcut reminder.
    if (this.getPref('ash.shortcut_reminders.next_ime_dismissed').value) {
      return false;
    }
    // Need at least 3 input methods to be shown the reminder.
    // Safety: `LanguagesModel.inputMethods` is always defined on CrOS.
    return !!this.languages && this.languages.inputMethods!.enabled.length >= 3;
  }

  private getShortcutReminderBody_(): TrustedHTML[] {
    const reminderBody: TrustedHTML[] = [];
    if (this.showLastUsedImeShortcutReminder_) {
      reminderBody.push(this.i18nAdvanced('imeShortcutReminderLastUsed'));
    }
    if (this.showNextImeShortcutReminder_) {
      reminderBody.push(this.i18nAdvanced('imeShortcutReminderNext'));
    }
    return reminderBody;
  }

  private shouldShowShortcutReminder_(): boolean {
    // `this.shortcutReminderBody_` should always be truthy here.
    // TODO(b/238031866): Remove `this.shortcutReminderBody_` here, or
    // investigate why removing it does not work.
    return this.shortcutReminderBody_ && this.shortcutReminderBody_.length > 0;
  }

  private onShortcutReminderDismiss_(): void {
    // Record the metric - assume that both reminders were dismissed unless one
    // of them wasn't shown.
    // Safety: The shortcut reminder is only shown if
    // `shouldShowShortcutReminder_` is true, so `this.shortcutReminderBody`
    // contains at least one thing, so at least one of
    // `this.showLastUsedImeShortcutReminder_` or
    // `this.showNextImeShortcutReminder_` should be true.
    assert(
        this.showLastUsedImeShortcutReminder_ ||
        this.showNextImeShortcutReminder_);
    let dismissedState = InputsShortcutReminderState.LAST_USED_IME_AND_NEXT_IME;
    if (!this.showLastUsedImeShortcutReminder_) {
      dismissedState = InputsShortcutReminderState.NEXT_IME;
    } else if (!this.showNextImeShortcutReminder_) {
      dismissedState = InputsShortcutReminderState.LAST_USED_IME;
    }
    this.languagesMetricsProxy_.recordShortcutReminderDismissed(dismissedState);

    if (this.showLastUsedImeShortcutReminder_) {
      this.setPrefValue('ash.shortcut_reminders.last_used_ime_dismissed', true);
    }
    if (this.showNextImeShortcutReminder_) {
      this.setPrefValue('ash.shortcut_reminders.next_ime_dismissed', true);
    }
  }

  private shouldShowSpinner_(imeId: string): boolean {
    return this.languagePacksInSettingsEnabled_ &&
        this.languageHelper.getImeLanguagePackStatus(imeId) ===
        chrome.inputMethodPrivate.LanguagePackStatus.IN_PROGRESS;
  }

  private shouldShowLanguagePackError_(imeId: string): boolean {
    if (!this.languagePacksInSettingsEnabled_) {
      return false;
    }
    const status = this.languageHelper.getImeLanguagePackStatus(imeId);
    return status ===
        chrome.inputMethodPrivate.LanguagePackStatus.ERROR_OTHER ||
        status ===
        chrome.inputMethodPrivate.LanguagePackStatus.ERROR_NEEDS_REBOOT;
  }

  private getLanguagePacksErrorMessage_(imeId: string): string {
    const status = this.languageHelper.getImeLanguagePackStatus(imeId);
    switch (status) {
      case chrome.inputMethodPrivate.LanguagePackStatus.ERROR_NEEDS_REBOOT:
      // We currently have a string - `inputMethodLanguagePacksNeedsRebootError`
      // in WebUI,
      // `IDS_OS_SETTINGS_INPUT_METHOD_LANGUAGE_PACKS_NEEDS_REBOOT_ERROR` in the
      // GRD file - to special case the `ERROR_NEEDS_REBOOT` case. However, the
      // string is not finalised, and therefore should not be shown to the user.
      // TODO: b/315725816 - Either finalise the string and add it here, or
      // remove the string altogether.
      case chrome.inputMethodPrivate.LanguagePackStatus.ERROR_OTHER:
        return this.i18n('inputMethodLanguagePacksGeneralError');
      default:
        console.error('Invalid status:', status);
        return '';
    }
  }
}

customElements.define(
    OsSettingsInputPageElement.is, OsSettingsInputPageElement);

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